From 2ba8b3fa96c35556fe702a3e286422c8b7e20be4 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 17:27:05 +0200 Subject: [PATCH 1/4] Address remaining MSTEST0064 review feedback Follow-up to #8256. Addresses two issues raised in PR review: 1. The fixer's AwaitableReturnStatementRewriter would emit `await` inside `lock`, `unsafe`, and `fixed` blocks when a non-async `Task`/`ValueTask`-returning test method had `return X;` inside such a block, producing CS1996 (Cannot await in body of lock statement) and equivalent invalid code. The fixer now scans the containing method for return-with-expression statements inside await-forbidden blocks and skips offering the code fix when one would produce invalid code. The diagnostic is still reported so the user can manually convert the test method. 2. The analyzer reported diagnostics on explicit delegate creations such as `Assert.Throws(new Action(() => task.GetAwaiter().GetResult()))`, but the fixer left the `new Action(...)` wrapper in place while renaming to `ThrowsAsync`. Since `ThrowsAsync` takes `Func`, the resulting code did not compile. The fixer now unwraps `ObjectCreationExpressionSyntax` arguments with a single lambda or anonymous-method argument and transforms the inner expression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 101 +++++++++++ .../PreferAsyncAssertionAnalyzerTests.cs | 160 ++++++++++++++++++ 2 files changed, 261 insertions(+) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 3ff4437e19..520a694c51 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -46,6 +46,18 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) return; } + if (invocationExpression.Ancestors().OfType().FirstOrDefault() is { } methodDeclaration && + ContainsReturnExpressionInUnawaitableContext(methodDeclaration)) + { + SemanticModel? semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is not null && + !methodDeclaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.AsyncKeyword)) && + IsTaskOrValueTaskReturnType(methodDeclaration, semanticModel, context.CancellationToken)) + { + return; + } + } + context.RegisterCodeFix( CodeAction.Create( title: CodeFixResources.UseAsyncAssertionFix, @@ -54,6 +66,21 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) context.Diagnostics); } + private static bool ContainsReturnExpressionInUnawaitableContext(MethodDeclarationSyntax methodDeclaration) + { + var walker = new UnawaitableReturnDetector(); + if (methodDeclaration.Body is { } body) + { + walker.Visit(body); + } + else if (methodDeclaration.ExpressionBody is { } expressionBody) + { + walker.Visit(expressionBody); + } + + return walker.Found; + } + private static async Task UseAsyncAssertionAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) { DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); @@ -200,6 +227,14 @@ private static bool TryReplaceActionExpression(ExpressionSyntax expression, [Not return true; } + if (expression is ObjectCreationExpressionSyntax objectCreationExpression && + objectCreationExpression.ArgumentList is { Arguments.Count: 1 } objectCreationArgumentList && + TryReplaceActionExpression(objectCreationArgumentList.Arguments[0].Expression, out ExpressionSyntax? objectCreationNewExpression)) + { + newExpression = objectCreationNewExpression.WithTriviaFrom(expression); + return true; + } + if (expression is not LambdaExpressionSyntax lambdaExpression || !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) { @@ -437,4 +472,70 @@ private static StatementSyntax[] CreateAwaitAndReturnStatements(ReturnStatementS return includeReturn ? [awaitStatement, newReturnStatement] : [awaitStatement]; } } + + private sealed class UnawaitableReturnDetector : CSharpSyntaxWalker + { + private int _unawaitableDepth; + + public bool Found { get; private set; } + + public override void Visit(SyntaxNode? node) + { + if (Found) + { + return; + } + + base.Visit(node); + } + + public override void VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) + { + } + + public override void VisitParenthesizedLambdaExpression(ParenthesizedLambdaExpressionSyntax node) + { + } + + public override void VisitAnonymousMethodExpression(AnonymousMethodExpressionSyntax node) + { + } + + public override void VisitLocalFunctionStatement(LocalFunctionStatementSyntax node) + { + } + + public override void VisitLockStatement(LockStatementSyntax node) + => VisitInUnawaitableContext(node.Statement); + + public override void VisitUnsafeStatement(UnsafeStatementSyntax node) + => VisitInUnawaitableContext(node.Block); + + public override void VisitFixedStatement(FixedStatementSyntax node) + => VisitInUnawaitableContext(node.Statement); + + public override void VisitReturnStatement(ReturnStatementSyntax node) + { + if (_unawaitableDepth > 0 && node.Expression is not null) + { + Found = true; + return; + } + + base.VisitReturnStatement(node); + } + + private void VisitInUnawaitableContext(SyntaxNode? node) + { + _unawaitableDepth++; + try + { + Visit(node); + } + finally + { + _unawaitableDepth--; + } + } + } } diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index d6daf569d5..2972ee430d 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -820,4 +820,164 @@ public async Task MyTestMethod() await VerifyCS.VerifyAnalyzerAsync(code); } + + [TestMethod] + public async Task WhenAssertionActionIsExplicitDelegateCreation_CodeFixUnwrapsDelegateCreation() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(new Action(() => BarAsync().GetAwaiter().GetResult()))|]; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + string fixedCode = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + await Assert.ThrowsExactlyAsync(() => BarAsync()); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenAssertionActionIsExplicitDelegateCreationWithAnonymousMethod_CodeFixUnwrapsDelegateCreation() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(new Action(delegate { BarAsync().GetAwaiter().GetResult(); }))|]; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + string fixedCode = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + await Assert.ThrowsExactlyAsync(() => BarAsync()); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenNonAsyncTaskMethodHasReturnInsideLockBlock_DiagnosticReportedButNoCodeFixOffered() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + private readonly object _gate = new(); + + [TestMethod] + public Task MyTestMethod() + { + [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; + lock (_gate) + { + return Task.CompletedTask; + } + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + // The diagnostic is still reported, but the fixer cannot safely transform the + // method because it would emit an 'await' inside the lock body. No code fix is + // offered, so the expected fixed code is identical to the original. + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenNonAsyncValueTaskMethodHasReturnInsideUnsafeBlock_DiagnosticReportedButNoCodeFixOffered() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public ValueTask MyTestMethod() + { + [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; + unsafe + { + return ValueTask.CompletedTask; + } + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + // The diagnostic is still reported, but the fixer cannot safely transform the + // method because it would emit an 'await' inside the unsafe block. No code fix is + // offered, so the expected fixed code is identical to the original. + var test = new VerifyCS.Test + { + TestCode = code, + FixedCode = code, + }; + + test.SolutionTransforms.Add((solution, projectId) => + { + var compilationOptions = (CSharpCompilationOptions)solution.GetProject(projectId)!.CompilationOptions!; + return solution.WithProjectCompilationOptions(projectId, compilationOptions.WithAllowUnsafe(true)); + }); + + await test.RunAsync(CancellationToken.None); + } } From e6d27bb4bf45899721abe95fe6e3562f04fb9a98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 16:06:15 +0000 Subject: [PATCH 2/4] Handle target-typed new and add fixed-block regression tests Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 8 ++ .../PreferAsyncAssertionAnalyzerTests.cs | 86 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 520a694c51..e6922b8aac 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -235,6 +235,14 @@ private static bool TryReplaceActionExpression(ExpressionSyntax expression, [Not return true; } + if (expression is ImplicitObjectCreationExpressionSyntax implicitObjectCreationExpression && + implicitObjectCreationExpression.ArgumentList is { Arguments.Count: 1 } implicitObjectCreationArgumentList && + TryReplaceActionExpression(implicitObjectCreationArgumentList.Arguments[0].Expression, out ExpressionSyntax? implicitObjectCreationNewExpression)) + { + newExpression = implicitObjectCreationNewExpression.WithTriviaFrom(expression); + return true; + } + if (expression is not LambdaExpressionSyntax lambdaExpression || !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) { diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 2972ee430d..1ea900e878 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -905,6 +905,48 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenAssertionActionIsTargetTypedDelegateCreation_CodeFixUnwrapsDelegateCreation() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(new(() => BarAsync().GetAwaiter().GetResult()))|]; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + string fixedCode = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + await Assert.ThrowsExactlyAsync(() => BarAsync()); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + [TestMethod] public async Task WhenNonAsyncTaskMethodHasReturnInsideLockBlock_DiagnosticReportedButNoCodeFixOffered() { @@ -980,4 +1022,48 @@ public ValueTask MyTestMethod() await test.RunAsync(CancellationToken.None); } + + [TestMethod] + public async Task WhenNonAsyncTaskMethodHasReturnInsideFixedBlock_DiagnosticReportedButNoCodeFixOffered() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public unsafe Task MyTestMethod() + { + [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; + int[] data = { 1, 2, 3 }; + fixed (int* p = data) + { + return Task.CompletedTask; + } + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + // The diagnostic is still reported, but the fixer cannot safely transform the + // method because it would emit an 'await' inside the fixed statement body. No code fix is + // offered, so the expected fixed code is identical to the original. + var test = new VerifyCS.Test + { + TestCode = code, + FixedCode = code, + }; + + test.SolutionTransforms.Add((solution, projectId) => + { + var compilationOptions = (CSharpCompilationOptions)solution.GetProject(projectId)!.CompilationOptions!; + return solution.WithProjectCompilationOptions(projectId, compilationOptions.WithAllowUnsafe(true)); + }); + + await test.RunAsync(CancellationToken.None); + } } From 1f9479ff8eb306683dcedacab78770259981a9f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 16:46:34 +0000 Subject: [PATCH 3/4] Fix flaky MSTEST0064 regression test inputs Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionAnalyzerTests.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 1ea900e878..33de35a9fa 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -919,7 +919,7 @@ public class MyTestClass [TestMethod] public void MyTestMethod() { - [|Assert.ThrowsExactly(new(() => BarAsync().GetAwaiter().GetResult()))|]; + [|Assert.ThrowsExactly(new((Action)(() => BarAsync().GetAwaiter().GetResult())))|]; } private Task BarAsync() => Task.CompletedTask; @@ -1035,13 +1035,16 @@ public async Task WhenNonAsyncTaskMethodHasReturnInsideFixedBlock_DiagnosticRepo public class MyTestClass { [TestMethod] - public unsafe Task MyTestMethod() + public Task MyTestMethod() { [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; int[] data = { 1, 2, 3 }; - fixed (int* p = data) + unsafe { - return Task.CompletedTask; + fixed (int* p = data) + { + return Task.CompletedTask; + } } } From 520976a9d04a0cc7c675c8ad0bbc9fb22b8db494 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 18:40:39 +0000 Subject: [PATCH 4/4] Fix target-typed delegate creation MSTEST0064 test input Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionAnalyzerTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 33de35a9fa..66fd49586a 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -919,7 +919,7 @@ public class MyTestClass [TestMethod] public void MyTestMethod() { - [|Assert.ThrowsExactly(new((Action)(() => BarAsync().GetAwaiter().GetResult())))|]; + [|Assert.ThrowsExactly((Action)new(() => BarAsync().GetAwaiter().GetResult()))|]; } private Task BarAsync() => Task.CompletedTask;