From 4bd6683a8770bc76723c5e1ccd7070933728cf33 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:26:43 +0000 Subject: [PATCH 01/14] Initial plan From 0da8df49584397cf0a3b324713765dea6e0779f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:40:52 +0000 Subject: [PATCH 02/14] Add async assertion analyzer Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../CodeFixResources.resx | 5 +- .../PreferAsyncAssertionFixer.cs | 199 ++++++++++++++++++ .../xlf/CodeFixResources.cs.xlf | 5 + .../xlf/CodeFixResources.de.xlf | 5 + .../xlf/CodeFixResources.es.xlf | 5 + .../xlf/CodeFixResources.fr.xlf | 5 + .../xlf/CodeFixResources.it.xlf | 5 + .../xlf/CodeFixResources.ja.xlf | 5 + .../xlf/CodeFixResources.ko.xlf | 5 + .../xlf/CodeFixResources.pl.xlf | 5 + .../xlf/CodeFixResources.pt-BR.xlf | 5 + .../xlf/CodeFixResources.ru.xlf | 5 + .../xlf/CodeFixResources.tr.xlf | 5 + .../xlf/CodeFixResources.zh-Hans.xlf | 5 + .../xlf/CodeFixResources.zh-Hant.xlf | 5 + .../AnalyzerReleases.Unshipped.md | 6 + .../MSTest.Analyzers/Helpers/DiagnosticIds.cs | 1 + .../PreferAsyncAssertionAnalyzer.cs | 161 ++++++++++++++ src/Analyzers/MSTest.Analyzers/Resources.resx | 11 +- .../MSTest.Analyzers/xlf/Resources.cs.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.de.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.es.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.fr.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.it.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.ja.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.ko.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.pl.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.pt-BR.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.ru.xlf | 15 ++ .../MSTest.Analyzers/xlf/Resources.tr.xlf | 15 ++ .../xlf/Resources.zh-Hans.xlf | 15 ++ .../xlf/Resources.zh-Hant.xlf | 15 ++ .../PreferAsyncAssertionAnalyzerTests.cs | 125 +++++++++++ 33 files changed, 766 insertions(+), 2 deletions(-) create mode 100644 src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs create mode 100644 src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs create mode 100644 test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/CodeFixResources.resx b/src/Analyzers/MSTest.Analyzers.CodeFixes/CodeFixResources.resx index e07487d5a9..44fd5da78f 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/CodeFixResources.resx +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/CodeFixResources.resx @@ -222,4 +222,7 @@ Remove duplicate TestMethod attribute - \ No newline at end of file + + Use async assertion + + diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs new file mode 100644 index 0000000000..69cbbe8993 --- /dev/null +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Composition; + +using Analyzer.Utilities; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Formatting; + +using MSTest.Analyzers.Helpers; + +namespace MSTest.Analyzers; + +/// +/// Code fixer for . +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferAsyncAssertionFixer))] +[Shared] +public sealed class PreferAsyncAssertionFixer : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds { get; } + = ImmutableArray.Create(DiagnosticIds.PreferAsyncAssertionRuleId); + + /// + public override FixAllProvider GetFixAllProvider() + // See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/FixAllProvider.md for more information on Fix All Providers + => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + SyntaxNode root = await context.Document.GetRequiredSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + SyntaxNode diagnosticNode = root.FindNode(context.Span); + + if (diagnosticNode.AncestorsAndSelf().OfType().FirstOrDefault() is not { } invocationExpression) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: CodeFixResources.UseAsyncAssertionFix, + createChangedDocument: ct => UseAsyncAssertionAsync(context.Document, invocationExpression, ct), + equivalenceKey: nameof(PreferAsyncAssertionFixer)), + context.Diagnostics); + } + + private static async Task UseAsyncAssertionAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) + { + DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + + InvocationExpressionSyntax newInvocationExpression = ReplaceAssertMethodName(invocationExpression); + if (newInvocationExpression.ArgumentList.Arguments.Count > 0 && + TryReplaceLambda(newInvocationExpression.ArgumentList.Arguments[0], out ArgumentSyntax? newArgument)) + { + newInvocationExpression = newInvocationExpression.WithArgumentList( + newInvocationExpression.ArgumentList.WithArguments(newInvocationExpression.ArgumentList.Arguments.Replace(newInvocationExpression.ArgumentList.Arguments[0], newArgument))); + } + + AwaitExpressionSyntax awaitExpression = SyntaxFactory.AwaitExpression(newInvocationExpression.WithoutLeadingTrivia()) + .WithLeadingTrivia(invocationExpression.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + + if (invocationExpression.Ancestors().OfType().FirstOrDefault() is { } methodDeclaration) + { + MethodDeclarationSyntax newMethodDeclaration = methodDeclaration.ReplaceNode(invocationExpression, awaitExpression); + editor.ReplaceNode(methodDeclaration, AddAsyncModifierAndTaskReturnType(newMethodDeclaration)); + } + else + { + editor.ReplaceNode(invocationExpression, awaitExpression); + } + + return editor.GetChangedDocument(); + } + + private static InvocationExpressionSyntax ReplaceAssertMethodName(InvocationExpressionSyntax invocationExpression) + { + if (invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccessExpression) + { + return invocationExpression; + } + + SimpleNameSyntax asyncName = memberAccessExpression.Name switch + { + GenericNameSyntax genericName => genericName.WithIdentifier(SyntaxFactory.Identifier( + genericName.Identifier.LeadingTrivia, + genericName.Identifier.ValueText + "Async", + genericName.Identifier.TrailingTrivia)), + IdentifierNameSyntax identifierName => identifierName.WithIdentifier(SyntaxFactory.Identifier( + identifierName.Identifier.LeadingTrivia, + identifierName.Identifier.ValueText + "Async", + identifierName.Identifier.TrailingTrivia)), + _ => memberAccessExpression.Name, + }; + + return invocationExpression.WithExpression(memberAccessExpression.WithName(asyncName)); + } + + private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(returnValue: true)] out ArgumentSyntax? newArgument) + { + if (argument.Expression is not LambdaExpressionSyntax lambdaExpression || + !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) + { + newArgument = null; + return false; + } + + LambdaExpressionSyntax newLambdaExpression = lambdaExpression switch + { + SimpleLambdaExpressionSyntax simpleLambda => simpleLambda.WithBody(asyncExpression.WithTriviaFrom(lambdaExpression.Body)), + ParenthesizedLambdaExpressionSyntax parenthesizedLambda => parenthesizedLambda.WithBody(asyncExpression.WithTriviaFrom(lambdaExpression.Body)), + _ => lambdaExpression, + }; + + newArgument = argument.WithExpression(newLambdaExpression); + return true; + } + + private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType(MethodDeclarationSyntax methodDeclaration) + { + MethodDeclarationSyntax newMethodDeclaration = methodDeclaration; + + if (!newMethodDeclaration.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.AsyncKeyword))) + { + newMethodDeclaration = newMethodDeclaration.WithModifiers(newMethodDeclaration.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.AsyncKeyword))); + } + + if (newMethodDeclaration.ReturnType.IsVoid()) + { + newMethodDeclaration = newMethodDeclaration.WithReturnType(SyntaxFactory.IdentifierName("Task").WithTriviaFrom(newMethodDeclaration.ReturnType)); + } + + return newMethodDeclaration.WithAdditionalAnnotations(Formatter.Annotation); + } + + private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expression, [NotNullWhen(returnValue: true)] out ExpressionSyntax? asyncExpression) + { + if (WalkDownParentheses(expression) is not LambdaExpressionSyntax lambdaExpression) + { + asyncExpression = null; + return false; + } + + if (lambdaExpression.Body is ExpressionSyntax expressionBody) + { + return TryGetBlockedTaskExpression(expressionBody, out asyncExpression); + } + + if (lambdaExpression.Body is BlockSyntax blockSyntax && + blockSyntax.Statements.Count == 1 && + blockSyntax.Statements[0] is ExpressionStatementSyntax expressionStatement) + { + return TryGetBlockedTaskExpression(expressionStatement.Expression, out asyncExpression); + } + + asyncExpression = null; + return false; + } + + private static bool TryGetBlockedTaskExpression(ExpressionSyntax expression, [NotNullWhen(returnValue: true)] out ExpressionSyntax? asyncExpression) + { + ExpressionSyntax currentExpression = WalkDownParentheses(expression); + if (currentExpression is InvocationExpressionSyntax getResultInvocation && + getResultInvocation.ArgumentList.Arguments.Count == 0 && + getResultInvocation.Expression is MemberAccessExpressionSyntax getResultMemberAccess && + getResultMemberAccess.Name.Identifier.ValueText == "GetResult" && + WalkDownParentheses(getResultMemberAccess.Expression) is InvocationExpressionSyntax getAwaiterInvocation && + getAwaiterInvocation.ArgumentList.Arguments.Count == 0 && + getAwaiterInvocation.Expression is MemberAccessExpressionSyntax getAwaiterMemberAccess && + getAwaiterMemberAccess.Name.Identifier.ValueText == "GetAwaiter") + { + asyncExpression = WalkDownParentheses(getAwaiterMemberAccess.Expression); + return true; + } + + asyncExpression = null; + return false; + } + + private static ExpressionSyntax WalkDownParentheses(ExpressionSyntax expression) + { + ExpressionSyntax currentExpression = expression; + while (currentExpression is ParenthesizedExpressionSyntax parenthesizedExpression) + { + currentExpression = parenthesizedExpression.Expression; + } + + return currentExpression; + } +} diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.cs.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.cs.xlf index 9181aee656..e2696b52bd 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.cs.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.cs.xlf @@ -137,6 +137,11 @@ Transformovat přepsání Execute na ExecuteAsync + + Use async assertion + Use async assertion + + Add '[TestMethod]' Přidat [TestMethod] diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.de.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.de.xlf index 8f3a90f559..1f3fa83feb 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.de.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.de.xlf @@ -137,6 +137,11 @@ Transformieren Sie die Überschreibung von „Execute“ in „ExecuteAsync“. + + Use async assertion + Use async assertion + + Add '[TestMethod]' „[TestMethod]“ hinzufügen diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.es.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.es.xlf index febbefb1e8..2799dca34a 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.es.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.es.xlf @@ -137,6 +137,11 @@ Transformar la invalidación "Execute" en "ExecuteAsync" + + Use async assertion + Use async assertion + + Add '[TestMethod]' Agregar '[TestMethod]' diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.fr.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.fr.xlf index ea7921acdd..6218d136bf 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.fr.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.fr.xlf @@ -137,6 +137,11 @@ Transformer la redéfinition « Execute » en « ExecuteAsync » + + Use async assertion + Use async assertion + + Add '[TestMethod]' Ajouter « [TestMethod] » diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.it.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.it.xlf index 8912010920..ea0ab61d08 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.it.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.it.xlf @@ -137,6 +137,11 @@ Trasformare l'override di 'Execute' in 'ExecuteAsync' + + Use async assertion + Use async assertion + + Add '[TestMethod]' Aggiungi '[TestMethod]' diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ja.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ja.xlf index 6878e2a0ca..803be0a569 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ja.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ja.xlf @@ -137,6 +137,11 @@ 'Execute' オーバーライドを 'ExecuteAsync' に変換する + + Use async assertion + Use async assertion + + Add '[TestMethod]' '[TestMethod]' の追加 diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ko.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ko.xlf index 049786f792..3b3d046136 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ko.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ko.xlf @@ -137,6 +137,11 @@ 'Execute' 재정의를 'ExecuteAsync'로 변환 + + Use async assertion + Use async assertion + + Add '[TestMethod]' '[TestMethod]' 추가 diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pl.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pl.xlf index 9f0829d2c9..d6ff24dda3 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pl.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pl.xlf @@ -137,6 +137,11 @@ Zmień zastąpienie „Execute” na „ExecuteAsync” + + Use async assertion + Use async assertion + + Add '[TestMethod]' Dodaj „[TestMethod]” diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pt-BR.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pt-BR.xlf index f83caaf446..4ea2cb2354 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pt-BR.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.pt-BR.xlf @@ -137,6 +137,11 @@ Transformar a substituição "Execute" em "ExecuteAsync" + + Use async assertion + Use async assertion + + Add '[TestMethod]' Adicionar ''[TestMethod]" diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ru.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ru.xlf index 75c687d020..2d7551d8fa 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ru.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.ru.xlf @@ -137,6 +137,11 @@ Преобразовать переопределение "Execute" в "ExecuteAsync" + + Use async assertion + Use async assertion + + Add '[TestMethod]' Добавить "[TestMethod]" diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.tr.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.tr.xlf index e2cbfb6e8b..8a7e73f78b 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.tr.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.tr.xlf @@ -137,6 +137,11 @@ 'Execute' geçersiz kılmasını 'ExecuteAsync' olarak dönüştür + + Use async assertion + Use async assertion + + Add '[TestMethod]' '[TestMethod]' ekle diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hans.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hans.xlf index 82cc31998a..a2f5201dd6 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hans.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hans.xlf @@ -137,6 +137,11 @@ 将‘Execute’重写为‘ExecuteAsync’ + + Use async assertion + Use async assertion + + Add '[TestMethod]' 添加“[TestMethod]” diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hant.xlf b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hant.xlf index c201be3af0..1a5c4d6dbe 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hant.xlf +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/xlf/CodeFixResources.zh-Hant.xlf @@ -137,6 +137,11 @@ 將 'Execute' 覆寫改為 'ExecuteAsync' + + Use async assertion + Use async assertion + + Add '[TestMethod]' 新增 '[TestMethod]' diff --git a/src/Analyzers/MSTest.Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/MSTest.Analyzers/AnalyzerReleases.Unshipped.md index f2b7fad657..4711d4232a 100644 --- a/src/Analyzers/MSTest.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/Analyzers/MSTest.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,2 +1,8 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +MSTEST0064 | Usage | Info | PreferAsyncAssertionAnalyzer, [Documentation](https://learn.microsoft.com/dotnet/core/testing/mstest-analyzers/mstest0064) diff --git a/src/Analyzers/MSTest.Analyzers/Helpers/DiagnosticIds.cs b/src/Analyzers/MSTest.Analyzers/Helpers/DiagnosticIds.cs index 2c7b428295..9b1c979410 100644 --- a/src/Analyzers/MSTest.Analyzers/Helpers/DiagnosticIds.cs +++ b/src/Analyzers/MSTest.Analyzers/Helpers/DiagnosticIds.cs @@ -68,4 +68,5 @@ internal static class DiagnosticIds public const string UseOSConditionAttributeInsteadOfRuntimeCheckRuleId = "MSTEST0061"; public const string AvoidOutRefTestMethodParametersRuleId = "MSTEST0062"; public const string TestClassConstructorShouldBeValidRuleId = "MSTEST0063"; + public const string PreferAsyncAssertionRuleId = "MSTEST0064"; } diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs new file mode 100644 index 0000000000..5a29a0b542 --- /dev/null +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; + +using Analyzer.Utilities.Extensions; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +using MSTest.Analyzers.Helpers; +using MSTest.Analyzers.RoslynAnalyzerHelpers; + +namespace MSTest.Analyzers; + +/// +/// MSTEST0064: . +/// +[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] +public sealed class PreferAsyncAssertionAnalyzer : DiagnosticAnalyzer +{ + private static readonly LocalizableResourceString Title = new(nameof(Resources.PreferAsyncAssertionTitle), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableResourceString Description = new(nameof(Resources.PreferAsyncAssertionDescription), Resources.ResourceManager, typeof(Resources)); + private static readonly LocalizableResourceString MessageFormat = new(nameof(Resources.PreferAsyncAssertionMessageFormat), Resources.ResourceManager, typeof(Resources)); + + internal static readonly DiagnosticDescriptor Rule = DiagnosticDescriptorHelper.Create( + DiagnosticIds.PreferAsyncAssertionRuleId, + Title, + MessageFormat, + Description, + Category.Usage, + DiagnosticSeverity.Info, + isEnabledByDefault: true); + + /// + public override ImmutableArray SupportedDiagnostics { get; } + = ImmutableArray.Create(Rule); + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(context => + { + if (!context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.MicrosoftVisualStudioTestToolsUnitTestingAssert, out INamedTypeSymbol? assertSymbol) || + !context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.MicrosoftVisualStudioTestToolsUnitTestingTestMethodAttribute, out INamedTypeSymbol? testMethodAttributeSymbol) || + !context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask, out INamedTypeSymbol? taskSymbol)) + { + return; + } + + context.RegisterOperationAction( + context => AnalyzeInvocation(context, assertSymbol, testMethodAttributeSymbol, taskSymbol), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbol assertSymbol, INamedTypeSymbol testMethodAttributeSymbol, INamedTypeSymbol taskSymbol) + { + var operation = (IInvocationOperation)context.Operation; + IMethodSymbol targetMethod = operation.TargetMethod; + if ( + !SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType, assertSymbol) || + targetMethod.Name is not ("Throws" or "ThrowsExactly") || + context.ContainingSymbol is not IMethodSymbol containingMethod || + !containingMethod.GetAttributes().Any(attr => attr.AttributeClass.Inherits(testMethodAttributeSymbol)) || + !TryGetActionArgument(operation, out IArgumentOperation? actionArgument) || + !TryGetBlockedTaskOperationFromArgument(actionArgument.Value, out IOperation? asyncOperation)) + { + return; + } + + ITypeSymbol? asyncExpressionType = asyncOperation.Type; + if (asyncExpressionType is null || !context.Compilation.ClassifyCommonConversion(asyncExpressionType, taskSymbol).IsImplicit) + { + return; + } + + context.ReportDiagnostic(operation.CreateDiagnostic(Rule, targetMethod.Name + "Async", targetMethod.Name)); + } + + private static bool TryGetActionArgument(IInvocationOperation operation, [NotNullWhen(returnValue: true)] out IArgumentOperation? actionArgument) + { + foreach (IArgumentOperation argument in operation.Arguments) + { + if (argument.Parameter?.Ordinal == 0) + { + actionArgument = argument; + return true; + } + } + + actionArgument = null; + return false; + } + + private static bool TryGetBlockedTaskOperationFromArgument(IOperation argumentValueOperation, [NotNullWhen(returnValue: true)] out IOperation? asyncOperation) + { + if (argumentValueOperation.WalkDownConversion() is not IDelegateCreationOperation delegateCreationOperation || + delegateCreationOperation.Target is not IAnonymousFunctionOperation anonymousFunctionOperation || + !TryGetSingleOperation(anonymousFunctionOperation.Body, out IOperation? lambdaOperation)) + { + asyncOperation = null; + return false; + } + + return TryGetBlockedTaskOperation(lambdaOperation, out asyncOperation); + } + + private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNullWhen(returnValue: true)] out IOperation? operation) + { + operation = null; + + foreach (IOperation childOperation in blockOperation.Operations) + { + IOperation? candidateOperation = childOperation switch + { + IExpressionStatementOperation expressionStatementOperation => expressionStatementOperation.Operation, + IReturnOperation { ReturnedValue: { } returnedValue } => returnedValue, + IReturnOperation { IsImplicit: true } => null, + _ => childOperation, + }; + + if (candidateOperation is null) + { + continue; + } + + if (operation is not null) + { + operation = null; + return false; + } + + operation = candidateOperation; + } + + return operation is not null; + } + + private static bool TryGetBlockedTaskOperation(IOperation operation, [NotNullWhen(returnValue: true)] out IOperation? asyncOperation) + { + if (operation.WalkDownConversion() is IInvocationOperation getResultInvocation && + getResultInvocation.Arguments.Length == 0 && + getResultInvocation.TargetMethod.Name == "GetResult" && + getResultInvocation.Instance?.WalkDownConversion() is IInvocationOperation getAwaiterInvocation && + getAwaiterInvocation.Arguments.Length == 0 && + getAwaiterInvocation.TargetMethod.Name == "GetAwaiter" && + getAwaiterInvocation.Instance is { } instance) + { + asyncOperation = instance.WalkDownConversion(); + return true; + } + + asyncOperation = null; + return false; + } +} diff --git a/src/Analyzers/MSTest.Analyzers/Resources.resx b/src/Analyzers/MSTest.Analyzers/Resources.resx index 5f6bc6df64..1c336cccd8 100644 --- a/src/Analyzers/MSTest.Analyzers/Resources.resx +++ b/src/Analyzers/MSTest.Analyzers/Resources.resx @@ -747,4 +747,13 @@ The type declaring these methods should also respect the following rules: Test classes must have a public constructor that is either parameterless or accepts a single TestContext parameter. This allows the test framework to instantiate the test class properly. - \ No newline at end of file + + Prefer async assertion methods + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.cs.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.cs.xlf index ca7323dd0a..3547bb455e 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.cs.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.cs.xlf @@ -532,6 +532,21 @@ Typ deklarující tyto metody by měl také respektovat následující pravidla: Místo trvalého neúspěšného vyhodnocovacího výrazu použijte „Assert.Fail“. + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. Možnost DataTestMethodAttribute je zastaralá a neposkytuje žádné funkce navíc oproti možnosti TestMethodAttribute. Pro všechny testovací metody, včetně parametrizovaných testů, použijte možnost TestMethodAttribute. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.de.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.de.xlf index 1ff5042ac3..c13aeffae6 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.de.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.de.xlf @@ -533,6 +533,21 @@ Der Typ, der diese Methoden deklariert, sollte auch die folgenden Regeln beachte Verwenden Sie „Assert.Fail“ anstelle einer Assert-Anweisung, bei der immer ein Fehler auftritt. + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. „DataTestMethodAttribute“ ist veraltet und bietet keine zusätzliche Funktionalität im Vergleich zu „TestMethodAttribute“. Verwenden Sie „TestMethodAttribute“ für alle Testmethoden, einschließlich parametrisierter Tests. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.es.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.es.xlf index 0d5e6904d4..96840ad0e2 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.es.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.es.xlf @@ -532,6 +532,21 @@ El tipo que declara estos métodos también debe respetar las reglas siguientes: Usar "Assert.Fail" en lugar de una aserción que siempre tiene errores + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. "DataTestMethodAttribute" está obsoleto y no proporciona ninguna funcionalidad adicional sobre "TestMethodAttribute". Use "TestMethodAttribute" para todos los métodos de prueba, incluidas las pruebas parametrizadas. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf index c9a3918612..071170d8d7 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf @@ -532,6 +532,21 @@ Le type déclarant ces méthodes doit également respecter les règles suivantes Utilisez « Assert.Fail » à la place d’une assertion toujours en échec + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. « DataTestMethodAttribute » est obsolète et ne fournit aucune fonctionnalité supplémentaire par rapport à « TestMethodAttribute ». Utiliser « TestMethodAttribute » pour toutes les méthodes de test, y compris les tests paramétrés. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.it.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.it.xlf index 80116284de..3edc211f41 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.it.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.it.xlf @@ -532,6 +532,21 @@ Anche il tipo che dichiara questi metodi deve rispettare le regole seguenti: Usare 'Assert.Fail' invece di un'asserzione che ha sempre esito negativo + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute' è obsoleto e non offre funzionalità aggiuntive rispetto a 'TestMethodAttribute'. Utilizzare 'TestMethodAttribute' per tutti i metodi di test, inclusi i test con parametri. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.ja.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.ja.xlf index cd8b59f2bf..cab8c15274 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.ja.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.ja.xlf @@ -532,6 +532,21 @@ The type declaring these methods should also respect the following rules: 常に失敗しているアサートの代わりに 'Assert.Fail' を使用する + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute' は廃止されており、'TestMethodAttribute' に対して追加の機能は提供しません。パラメーター化されたテストを含むすべてのテスト メソッドには、'TestMethodAttribute' を使用してください。 diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.ko.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.ko.xlf index adb8ae1b8b..36376da69b 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.ko.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.ko.xlf @@ -532,6 +532,21 @@ The type declaring these methods should also respect the following rules: 항상 실패하는 어설션 대신 'Assert.Fail' 사용 + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute'는 더 이상 사용되지 않으며 'TestMethodAttribute'에 대한 추가 기능을 제공하지 않습니다. 매개 변수가 있는 테스트를 포함하여 모든 테스트 메서드에 'TestMethodAttribute'를 사용하세요. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.pl.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.pl.xlf index 58fccdba9b..8c4d97f335 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.pl.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.pl.xlf @@ -532,6 +532,21 @@ Typ deklarujący te metody powinien również przestrzegać następujących regu Użyj trybu „Assert.Fail” zamiast kończącej się zawsze niepowodzeniem instrukcji asercji + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. Atrybut „DataTestMethodAttribute” jest przestarzały i nie zapewnia dodatkowych funkcji w stosunku do atrybutu „TestMethodAttribute”. Użyj atrybutu „TestMethodAttribute” dla wszystkich metod testowych, w tym testów sparametryzowanych. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.pt-BR.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.pt-BR.xlf index d6b69ecae4..d570cb836a 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.pt-BR.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.pt-BR.xlf @@ -532,6 +532,21 @@ O tipo que declara esses métodos também deve respeitar as seguintes regras: Usar "Assert.Fail" em vez de uma asserção sempre com falha + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute' está obsoleto e não oferece funcionalidade adicional em relação a 'TestMethodAttribute'. Use 'TestMethodAttribute' para todos os métodos de teste, inclusive testes parametrizados. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.ru.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.ru.xlf index 08c1723229..721c0e5841 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.ru.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.ru.xlf @@ -538,6 +538,21 @@ The type declaring these methods should also respect the following rules: Используйте "Assert.Fail" вместо утверждения с постоянным сбоем + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. Атрибут "DataTestMethodAttribute" устарел и не предоставляет дополнительных функций по сравнению с "TestMethodAttribute". Используйте '"TestMethodAttribute" для всех методов тестирования, включая параметризованные тесты. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.tr.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.tr.xlf index ee076b0d93..3247324853 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.tr.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.tr.xlf @@ -532,6 +532,21 @@ Bu metotları bildiren türün ayrıca aşağıdaki kurallara uyması gerekir: Her zaman başarısız olan onaylama yerine 'Assert.Fail' seçeneğini kullanın + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute' artık kullanılmıyor ve 'TestMethodAttribute' üzerinde ek bir işlevsellik sunmuyor. Parametreli testler de dahil, tüm test yöntemleri için 'TestMethodAttribute' kullanın. diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hans.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hans.xlf index f194914d24..29c2d2428c 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hans.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hans.xlf @@ -532,6 +532,21 @@ The type declaring these methods should also respect the following rules: 使用 “Assert.Fail” 而不是始终失败的断言 + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute' 已过时,并且不再提供 'TestMethodAttribute' 之外的其他功能。对所有测试方法(包括参数化测试)使用 'TestMethodAttribute'。 diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hant.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hant.xlf index b492f375dc..143670aa7f 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hant.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.zh-Hant.xlf @@ -532,6 +532,21 @@ The type declaring these methods should also respect the following rules: 使用 'Assert.Fail',而不是一直失敗的聲明 + + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + When verifying exceptions from asynchronous code, use the async assertion methods instead of blocking the asynchronous operation with GetAwaiter().GetResult(). + + + + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + Use 'Assert.{0}' instead of blocking an async call in 'Assert.{1}' + + + + Prefer async assertion methods + Prefer async assertion methods + + 'DataTestMethodAttribute' is obsolete and provides no additional functionality over 'TestMethodAttribute'. Use 'TestMethodAttribute' for all test methods, including parameterized tests. 'DataTestMethodAttribute' 已過時,未提供比 'TestMethodAttribute' 更多的功能。針對所有測試方法使用 'TestMethodAttribute',包括參數化測試。 diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs new file mode 100644 index 0000000000..8eee803d37 --- /dev/null +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using VerifyCS = MSTest.Analyzers.Test.CSharpCodeFixVerifier< + MSTest.Analyzers.PreferAsyncAssertionAnalyzer, + MSTest.Analyzers.PreferAsyncAssertionFixer>; + +namespace MSTest.Analyzers.Test; + +[TestClass] +public sealed class PreferAsyncAssertionAnalyzerTests +{ + [TestMethod] + public async Task WhenAssertThrowsExactlyBlocksOnTask_CodeFixUsesAsyncAssertionAndUpdatesSignature() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(() => 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 WhenAssertThrowsBlocksOnGenericTask_CodeFixUsesAsyncAssertion() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + Exception exception = [|Assert.Throws(() => BarAsync().GetAwaiter().GetResult())|]; + Assert.IsNotNull(exception); + await Task.CompletedTask; + } + + private Task BarAsync() => Task.FromResult(42); + } + """; + + string fixedCode = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + Exception exception = await Assert.ThrowsAsync(() => BarAsync()); + Assert.IsNotNull(exception); + await Task.CompletedTask; + } + + private Task BarAsync() => Task.FromResult(42); + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenAssertionDoesNotBlockOnTask_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + await Assert.ThrowsExactlyAsync(() => BarAsync()); + Assert.ThrowsExactly(() => throw new InvalidOperationException()); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } +} From dd56bd5b89437e5f05b9270562a46a1d68788bb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:42:14 +0000 Subject: [PATCH 03/14] Address async assertion review feedback Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 6 +++--- .../MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 69cbbe8993..f2d29ba4a2 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -105,7 +105,7 @@ private static InvocationExpressionSyntax ReplaceAssertMethodName(InvocationExpr return invocationExpression.WithExpression(memberAccessExpression.WithName(asyncName)); } - private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(returnValue: true)] out ArgumentSyntax? newArgument) + private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true)] out ArgumentSyntax? newArgument) { if (argument.Expression is not LambdaExpressionSyntax lambdaExpression || !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) @@ -142,7 +142,7 @@ private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType(MethodD return newMethodDeclaration.WithAdditionalAnnotations(Formatter.Annotation); } - private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expression, [NotNullWhen(returnValue: true)] out ExpressionSyntax? asyncExpression) + private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) { if (WalkDownParentheses(expression) is not LambdaExpressionSyntax lambdaExpression) { @@ -166,7 +166,7 @@ private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expre return false; } - private static bool TryGetBlockedTaskExpression(ExpressionSyntax expression, [NotNullWhen(returnValue: true)] out ExpressionSyntax? asyncExpression) + private static bool TryGetBlockedTaskExpression(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) { ExpressionSyntax currentExpression = WalkDownParentheses(expression); if (currentExpression is InvocationExpressionSyntax getResultInvocation && diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index 5a29a0b542..dfff4df1cf 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -82,7 +82,7 @@ context.ContainingSymbol is not IMethodSymbol containingMethod || context.ReportDiagnostic(operation.CreateDiagnostic(Rule, targetMethod.Name + "Async", targetMethod.Name)); } - private static bool TryGetActionArgument(IInvocationOperation operation, [NotNullWhen(returnValue: true)] out IArgumentOperation? actionArgument) + private static bool TryGetActionArgument(IInvocationOperation operation, [NotNullWhen(true)] out IArgumentOperation? actionArgument) { foreach (IArgumentOperation argument in operation.Arguments) { @@ -97,7 +97,7 @@ private static bool TryGetActionArgument(IInvocationOperation operation, [NotNul return false; } - private static bool TryGetBlockedTaskOperationFromArgument(IOperation argumentValueOperation, [NotNullWhen(returnValue: true)] out IOperation? asyncOperation) + private static bool TryGetBlockedTaskOperationFromArgument(IOperation argumentValueOperation, [NotNullWhen(true)] out IOperation? asyncOperation) { if (argumentValueOperation.WalkDownConversion() is not IDelegateCreationOperation delegateCreationOperation || delegateCreationOperation.Target is not IAnonymousFunctionOperation anonymousFunctionOperation || @@ -110,7 +110,7 @@ delegateCreationOperation.Target is not IAnonymousFunctionOperation anonymousFun return TryGetBlockedTaskOperation(lambdaOperation, out asyncOperation); } - private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNullWhen(returnValue: true)] out IOperation? operation) + private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNullWhen(true)] out IOperation? operation) { operation = null; @@ -141,7 +141,7 @@ private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNu return operation is not null; } - private static bool TryGetBlockedTaskOperation(IOperation operation, [NotNullWhen(returnValue: true)] out IOperation? asyncOperation) + private static bool TryGetBlockedTaskOperation(IOperation operation, [NotNullWhen(true)] out IOperation? asyncOperation) { if (operation.WalkDownConversion() is IInvocationOperation getResultInvocation && getResultInvocation.Arguments.Length == 0 && From 1dd5492fdbcda6b81462b76a44dbbaf8944d39b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:43:32 +0000 Subject: [PATCH 04/14] Share async blocking method name constants Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 4 ++-- .../MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index f2d29ba4a2..4f5cac5448 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -172,11 +172,11 @@ private static bool TryGetBlockedTaskExpression(ExpressionSyntax expression, [No if (currentExpression is InvocationExpressionSyntax getResultInvocation && getResultInvocation.ArgumentList.Arguments.Count == 0 && getResultInvocation.Expression is MemberAccessExpressionSyntax getResultMemberAccess && - getResultMemberAccess.Name.Identifier.ValueText == "GetResult" && + getResultMemberAccess.Name.Identifier.ValueText == PreferAsyncAssertionAnalyzer.GetResultMethodName && WalkDownParentheses(getResultMemberAccess.Expression) is InvocationExpressionSyntax getAwaiterInvocation && getAwaiterInvocation.ArgumentList.Arguments.Count == 0 && getAwaiterInvocation.Expression is MemberAccessExpressionSyntax getAwaiterMemberAccess && - getAwaiterMemberAccess.Name.Identifier.ValueText == "GetAwaiter") + getAwaiterMemberAccess.Name.Identifier.ValueText == PreferAsyncAssertionAnalyzer.GetAwaiterMethodName) { asyncExpression = WalkDownParentheses(getAwaiterMemberAccess.Expression); return true; diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index dfff4df1cf..b8905896d5 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -20,6 +20,9 @@ namespace MSTest.Analyzers; [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] public sealed class PreferAsyncAssertionAnalyzer : DiagnosticAnalyzer { + internal const string GetAwaiterMethodName = "GetAwaiter"; + internal const string GetResultMethodName = "GetResult"; + private static readonly LocalizableResourceString Title = new(nameof(Resources.PreferAsyncAssertionTitle), Resources.ResourceManager, typeof(Resources)); private static readonly LocalizableResourceString Description = new(nameof(Resources.PreferAsyncAssertionDescription), Resources.ResourceManager, typeof(Resources)); private static readonly LocalizableResourceString MessageFormat = new(nameof(Resources.PreferAsyncAssertionMessageFormat), Resources.ResourceManager, typeof(Resources)); @@ -145,10 +148,10 @@ private static bool TryGetBlockedTaskOperation(IOperation operation, [NotNullWhe { if (operation.WalkDownConversion() is IInvocationOperation getResultInvocation && getResultInvocation.Arguments.Length == 0 && - getResultInvocation.TargetMethod.Name == "GetResult" && + getResultInvocation.TargetMethod.Name == GetResultMethodName && getResultInvocation.Instance?.WalkDownConversion() is IInvocationOperation getAwaiterInvocation && getAwaiterInvocation.Arguments.Length == 0 && - getAwaiterInvocation.TargetMethod.Name == "GetAwaiter" && + getAwaiterInvocation.TargetMethod.Name == GetAwaiterMethodName && getAwaiterInvocation.Instance is { } instance) { asyncOperation = instance.WalkDownConversion(); From 96e9fc58547342a43935da59e4052647495aa734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 15 May 2026 20:46:27 +0200 Subject: [PATCH 05/14] Fix PreferAsyncAssertion review issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 34 ++++++- .../PreferAsyncAssertionAnalyzer.cs | 16 +++- .../PreferAsyncAssertionAnalyzerTests.cs | 96 +++++++++++++++++++ 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 4f5cac5448..02e29663cc 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -13,6 +13,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Editing; using Microsoft.CodeAnalysis.Formatting; +using Microsoft.CodeAnalysis.Operations; using MSTest.Analyzers.Helpers; @@ -56,13 +57,14 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) private static async Task UseAsyncAssertionAsync(Document document, InvocationExpressionSyntax invocationExpression, CancellationToken cancellationToken) { DocumentEditor editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + SemanticModel semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false); InvocationExpressionSyntax newInvocationExpression = ReplaceAssertMethodName(invocationExpression); - if (newInvocationExpression.ArgumentList.Arguments.Count > 0 && - TryReplaceLambda(newInvocationExpression.ArgumentList.Arguments[0], out ArgumentSyntax? newArgument)) + if (TryGetActionArgumentIndex(invocationExpression, semanticModel, cancellationToken, out int actionArgumentIndex) && + TryReplaceLambda(newInvocationExpression.ArgumentList.Arguments[actionArgumentIndex], out ArgumentSyntax? newArgument)) { newInvocationExpression = newInvocationExpression.WithArgumentList( - newInvocationExpression.ArgumentList.WithArguments(newInvocationExpression.ArgumentList.Arguments.Replace(newInvocationExpression.ArgumentList.Arguments[0], newArgument))); + newInvocationExpression.ArgumentList.WithArguments(newInvocationExpression.ArgumentList.Arguments.Replace(newInvocationExpression.ArgumentList.Arguments[actionArgumentIndex], newArgument))); } AwaitExpressionSyntax awaitExpression = SyntaxFactory.AwaitExpression(newInvocationExpression.WithoutLeadingTrivia()) @@ -105,6 +107,32 @@ private static InvocationExpressionSyntax ReplaceAssertMethodName(InvocationExpr return invocationExpression.WithExpression(memberAccessExpression.WithName(asyncName)); } + private static bool TryGetActionArgumentIndex( + InvocationExpressionSyntax invocationExpression, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out int actionArgumentIndex) + { + if (semanticModel.GetOperation(invocationExpression, cancellationToken) is not IInvocationOperation invocationOperation) + { + actionArgumentIndex = -1; + return false; + } + + foreach (IArgumentOperation argumentOperation in invocationOperation.Arguments) + { + if (argumentOperation.Parameter?.Name == "action" && + argumentOperation.Syntax is ArgumentSyntax argumentSyntax) + { + actionArgumentIndex = invocationExpression.ArgumentList.Arguments.IndexOf(argumentSyntax); + return actionArgumentIndex >= 0; + } + } + + actionArgumentIndex = -1; + return false; + } + private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true)] out ArgumentSyntax? newArgument) { if (argument.Expression is not LambdaExpressionSyntax lambdaExpression || diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index b8905896d5..2100a95f44 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -70,6 +70,7 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTy targetMethod.Name is not ("Throws" or "ThrowsExactly") || context.ContainingSymbol is not IMethodSymbol containingMethod || !containingMethod.GetAttributes().Any(attr => attr.AttributeClass.Inherits(testMethodAttributeSymbol)) || + IsInsideNestedFunction(operation) || !TryGetActionArgument(operation, out IArgumentOperation? actionArgument) || !TryGetBlockedTaskOperationFromArgument(actionArgument.Value, out IOperation? asyncOperation)) { @@ -89,7 +90,7 @@ private static bool TryGetActionArgument(IInvocationOperation operation, [NotNul { foreach (IArgumentOperation argument in operation.Arguments) { - if (argument.Parameter?.Ordinal == 0) + if (argument.Parameter?.Name == "action") { actionArgument = argument; return true; @@ -100,6 +101,19 @@ private static bool TryGetActionArgument(IInvocationOperation operation, [NotNul return false; } + private static bool IsInsideNestedFunction(IOperation operation) + { + for (IOperation? current = operation.Parent; current is not null; current = current.Parent) + { + if (current is IAnonymousFunctionOperation or ILocalFunctionOperation) + { + return true; + } + } + + return false; + } + private static bool TryGetBlockedTaskOperationFromArgument(IOperation argumentValueOperation, [NotNullWhen(true)] out IOperation? asyncOperation) { if (argumentValueOperation.WalkDownConversion() is not IDelegateCreationOperation delegateCreationOperation || diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 8eee803d37..d08545dace 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -98,6 +98,102 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenAssertThrowsExactlyBlocksOnTask_WithNamedArgumentsOutOfOrder_CodeFixUsesAsyncAssertion() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(message: "boom", 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(message: "boom", action: () => BarAsync()); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenAssertionIsInsideNestedLambda_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + Action assertion = () => Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + assertion(); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenAssertionIsInsideLocalFunction_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + void RunAssertion() + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + } + + RunAssertion(); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + [TestMethod] public async Task WhenAssertionDoesNotBlockOnTask_NoDiagnostic() { From 4c1fe75942b224a7f6183aa4653be79ba5ffcd50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:22:22 +0000 Subject: [PATCH 06/14] Plan review feedback fixes Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../Resources/xlf/FrameworkMessages.cs.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.de.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.es.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.fr.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.it.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.ja.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.ko.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.pl.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.pt-BR.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.ru.xlf | 125 ++++++++++++++++++ .../Resources/xlf/FrameworkMessages.tr.xlf | 125 ++++++++++++++++++ .../xlf/FrameworkMessages.zh-Hans.xlf | 125 ++++++++++++++++++ .../xlf/FrameworkMessages.zh-Hant.xlf | 125 ++++++++++++++++++ 13 files changed, 1625 insertions(+) diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf index 2d91e4afc7..b2828f2192 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.cs.xlf @@ -68,6 +68,111 @@ Očekávaná délka řetězce je {0}, ale byla {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Nebyla očekávána žádná hodnota kromě:<{1}>. Aktuálně:<{2}>. {0} @@ -78,6 +183,26 @@ Očekáván rozdíl, který je větší jak <{3}> mezi očekávanou hodnotou <{1}> a aktuální hodnotou <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Obě hodnoty jsou <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf index bc6994fd6a..3b0497e62d 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.de.xlf @@ -68,6 +68,111 @@ Die erwartete Länge der Zeichenfolge ist {0}, war aber {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Es wurde ein beliebiger Wert erwartet außer:<{1}>. Tatsächlich:<{2}>. {0} @@ -78,6 +183,26 @@ Es wurde eine Differenz größer als <{3}> zwischen dem erwarteten Wert <{1}> und dem tatsächlichen Wert <{2}> erwartet. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Beide Werte sind <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf index add81a9682..eadedba044 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.es.xlf @@ -68,6 +68,111 @@ Se esperaba una longitud de cadena {0} pero fue {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Se esperaba cualquier valor excepto <{1}>, pero es <{2}>. {0} @@ -78,6 +183,26 @@ Se esperaba una diferencia mayor que <{3}> entre el valor esperado <{1}> y el valor actual <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Ambos valores son <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf index b910b08c9e..21e859b0b8 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.fr.xlf @@ -68,6 +68,111 @@ La longueur de chaîne attendue {0} mais était {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Toute valeur attendue sauf :<{1}>. Réel :<{2}>. {0} @@ -78,6 +183,26 @@ Différence attendue supérieure à <{3}> comprise entre la valeur attendue <{1}> et la valeur réelle <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Les deux valeurs sont <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf index a4706cea2d..6d2040c828 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.it.xlf @@ -68,6 +68,111 @@ La lunghezza della stringa prevista è {0} ma era {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Previsto qualsiasi valore tranne:<{1}>. Effettivo:<{2}>. {0} @@ -78,6 +183,26 @@ Prevista una differenza maggiore di <{3}> tra il valore previsto <{1}> e il valore effettivo <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Entrambi i valori sono <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf index 5f3fa8c2ab..3fa2da18a2 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ja.xlf @@ -68,6 +68,111 @@ 期待される文字列の長さは {0} ですが、実際は {1} でした。 + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} <{1}> 以外の任意の値が必要ですが、<{2}> が指定されています。{0} @@ -78,6 +183,26 @@ 指定する値 <{1}> と実際の値 <{2}> との間には、<{3}> を超える差が必要です。{0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} どちらの値も<null>です。{0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf index 12c6954c61..caed5cd6c5 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ko.xlf @@ -68,6 +68,111 @@ 문자열 길이 {0}(을)를 예상했지만 {1}입니다. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} 예상 값: <{1}>을(를) 제외한 모든 값. 실제 값: <{2}>. {0} @@ -78,6 +183,26 @@ 예상 값 <{1}>과(와) 실제 값 <{2}>의 차이가 <{3}>보다 커야 합니다. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} 두 값 모두 <null>입니다. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf index 17ae1b9d3c..5da692e8b6 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pl.xlf @@ -68,6 +68,111 @@ Oczekiwano ciągu o długości {0}, ale miał wartość {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Oczekiwano dowolnej wartości za wyjątkiem:<{1}>. Rzeczywista:<{2}>. {0} @@ -78,6 +183,26 @@ Oczekiwano różnicy większej niż <{3}> pomiędzy oczekiwaną wartością <{1}> a rzeczywistą wartością <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Obie wartości to <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf index 598fb3b128..935e661ad4 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.pt-BR.xlf @@ -68,6 +68,111 @@ Comprimento esperado da cadeia de caracteres {0}, mas foi {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Esperado qualquer valor exceto:<{1}>. Real:<{2}>. {0} @@ -78,6 +183,26 @@ Esperada uma diferença maior que <{3}> entre o valor esperado <{1}> e o valor real <{2}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Ambos os valores são <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf index a9796fb817..310e5898df 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.ru.xlf @@ -68,6 +68,111 @@ Ожидалась длина строки: {0}, фактическая длина строки: {1}. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Ожидается любое значение, кроме: <{1}>. Фактически: <{2}>. {0} @@ -78,6 +183,26 @@ Между ожидаемым значением <{1}> и фактическим значением <{2}> требуется разница более чем <{3}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Оба значения равны <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf index e6a9ba121b..fea50fcbe0 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.tr.xlf @@ -68,6 +68,111 @@ Beklenen dize uzunluğu {0} idi, ancak dize uzunluğu {1} oldu. + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} Şunun dışında bir değer bekleniyor:<{1}>. Gerçek:<{2}>. {0} @@ -78,6 +183,26 @@ Beklenen değer <{1}> ile gerçek değer <{2}> arasında, şundan büyük olan fark bekleniyor: <{3}>. {0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} Her iki değer: <null>. {0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf index 19197e286b..6fa3cb17f3 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hans.xlf @@ -68,6 +68,111 @@ 字符串长度应为 {0},但为 {1}。 + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} 应为: <{1}> 以外的任意值,实际为: <{2}>。{0} @@ -78,6 +183,26 @@ 预期值 <{1}> 和实际值 <{2}> 之间的差应大于 <{3}>。{0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} 两个值均为 <null>。{0} diff --git a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf index 6876681112..366ab94673 100644 --- a/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf +++ b/src/TestFramework/TestFramework/Resources/xlf/FrameworkMessages.zh-Hant.xlf @@ -68,6 +68,111 @@ 預期的字串長度為 {0},但為 {1}。 + + Expected values to be structurally equivalent. + Expected values to be structurally equivalent. + + + + Expected values to be structurally equivalent (strict mode). + Expected values to be structurally equivalent (strict mode). + + + + reading the actual dictionary threw {0}: {1}. + reading the actual dictionary threw {0}: {1}. + + + + enumerating the actual collection threw {0}: {1}. + enumerating the actual collection threw {0}: {1}. + + + + reading the actual member threw {0}: {1}. + reading the actual member threw {0}: {1}. + + + + Mismatch at '{0}': {1} + Mismatch at '{0}': {1} + {0} is a member path (e.g., 'Order.Items[2].Price'), {1} is a localized reason describing the kind of mismatch. + + + reading the expected dictionary threw {0}: {1}. + reading the expected dictionary threw {0}: {1}. + + + + enumerating the expected collection threw {0}: {1}. + enumerating the expected collection threw {0}: {1}. + + + + reading the expected member threw {0}: {1}. + reading the expected member threw {0}: {1}. + + + + actual has unexpected members not present on expected: {0}. + actual has unexpected members not present on expected: {0}. + + + + IEquatable.Equals threw {0}: {1}. + IEquatable.Equals threw {0}: {1}. + + + + collections differ in length (expected {0} elements, actual {1}). + collections differ in length (expected {0} elements, actual {1}). + + + + comparison exceeded the maximum supported depth of {0}. + comparison exceeded the maximum supported depth of {0}. + + + + key {0} present on expected is missing from actual. + key {0} present on expected is missing from actual. + + + + member '{0}' present on expected is missing from actual. + member '{0}' present on expected is missing from actual. + + + + one side is null, the other is not. + one side is null, the other is not. + + + + graph topology differs (the same reference on one side appears paired with different references on the other side). + graph topology differs (the same reference on one side appears paired with different references on the other side). + + + + incompatible types (expected '{0}', actual '{1}'). + incompatible types (expected '{0}', actual '{1}'). + + + + key {0} present on actual is not on expected. + key {0} present on actual is not on expected. + + + + values are not equal. + values are not equal. + + + + <root> + <root> + Placeholder used in equivalence-mismatch messages when the difference is at the top of the object graph. + Expected any value except:<{1}>. Actual:<{2}>. {0} 預期任何值 (<{1}> 除外)。實際: <{2}>。{0} @@ -78,6 +183,26 @@ 預期值 <{1}> 和實際值 <{2}> 之間的預期差異大於 <{3}>。{0} + + Could not complete structural comparison. + Could not complete structural comparison. + + + + Could not complete structural comparison (strict mode). + Could not complete structural comparison (strict mode). + + + + Expected values to be structurally different. + Expected values to be structurally different. + + + + Expected values to be structurally different (strict mode). + Expected values to be structurally different (strict mode). + + Both values are <null>. {0} 兩個值均為 <null>。{0} From b71536c4255c45a259de4afab76328a55853d2c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 14:27:40 +0000 Subject: [PATCH 07/14] Address async assertion review feedback Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 136 +++++++++- .../PreferAsyncAssertionAnalyzer.cs | 13 +- .../PreferAsyncAssertionAnalyzerTests.cs | 236 ++++++++++++++++++ 3 files changed, 368 insertions(+), 17 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 02e29663cc..133a1f0423 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -74,7 +74,7 @@ private static async Task UseAsyncAssertionAsync(Document document, In if (invocationExpression.Ancestors().OfType().FirstOrDefault() is { } methodDeclaration) { MethodDeclarationSyntax newMethodDeclaration = methodDeclaration.ReplaceNode(invocationExpression, awaitExpression); - editor.ReplaceNode(methodDeclaration, AddAsyncModifierAndTaskReturnType(newMethodDeclaration)); + editor.ReplaceNode(methodDeclaration, AddAsyncModifierAndTaskReturnType(newMethodDeclaration, methodDeclaration, semanticModel, cancellationToken)); } else { @@ -85,13 +85,15 @@ private static async Task UseAsyncAssertionAsync(Document document, In } private static InvocationExpressionSyntax ReplaceAssertMethodName(InvocationExpressionSyntax invocationExpression) - { - if (invocationExpression.Expression is not MemberAccessExpressionSyntax memberAccessExpression) + => invocationExpression.Expression switch { - return invocationExpression; - } + MemberAccessExpressionSyntax memberAccessExpression => invocationExpression.WithExpression(memberAccessExpression.WithName(AppendAsyncSuffix(memberAccessExpression.Name))), + SimpleNameSyntax simpleName => invocationExpression.WithExpression(AppendAsyncSuffix(simpleName)), + _ => invocationExpression, + }; - SimpleNameSyntax asyncName = memberAccessExpression.Name switch + private static SimpleNameSyntax AppendAsyncSuffix(SimpleNameSyntax name) + => name switch { GenericNameSyntax genericName => genericName.WithIdentifier(SyntaxFactory.Identifier( genericName.Identifier.LeadingTrivia, @@ -101,12 +103,9 @@ private static InvocationExpressionSyntax ReplaceAssertMethodName(InvocationExpr identifierName.Identifier.LeadingTrivia, identifierName.Identifier.ValueText + "Async", identifierName.Identifier.TrailingTrivia)), - _ => memberAccessExpression.Name, + _ => name, }; - return invocationExpression.WithExpression(memberAccessExpression.WithName(asyncName)); - } - private static bool TryGetActionArgumentIndex( InvocationExpressionSyntax invocationExpression, SemanticModel semanticModel, @@ -153,23 +152,50 @@ private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true) return true; } - private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType(MethodDeclarationSyntax methodDeclaration) + private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType( + MethodDeclarationSyntax methodDeclaration, + MethodDeclarationSyntax originalMethodDeclaration, + SemanticModel semanticModel, + CancellationToken cancellationToken) { MethodDeclarationSyntax newMethodDeclaration = methodDeclaration; + bool isAsync = newMethodDeclaration.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.AsyncKeyword)); - if (!newMethodDeclaration.Modifiers.Any(modifier => modifier.IsKind(SyntaxKind.AsyncKeyword))) + if (!isAsync) { newMethodDeclaration = newMethodDeclaration.WithModifiers(newMethodDeclaration.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.AsyncKeyword))); } if (newMethodDeclaration.ReturnType.IsVoid()) { - newMethodDeclaration = newMethodDeclaration.WithReturnType(SyntaxFactory.IdentifierName("Task").WithTriviaFrom(newMethodDeclaration.ReturnType)); + newMethodDeclaration = newMethodDeclaration.WithReturnType(GetTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken).WithTriviaFrom(newMethodDeclaration.ReturnType)); + } + else if (!isAsync && IsTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken) && newMethodDeclaration.Body is { } body) + { + newMethodDeclaration = newMethodDeclaration.WithBody((BlockSyntax)new TaskReturnStatementRewriter().Visit(body)!); } return newMethodDeclaration.WithAdditionalAnnotations(Formatter.Annotation); } + private static TypeSyntax GetTaskReturnType(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) + { + INamedTypeSymbol? taskSymbol = semanticModel.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask); + return taskSymbol is not null && + SymbolEqualityComparer.Default.Equals( + semanticModel.GetSpeculativeTypeInfo(methodDeclaration.ReturnType.SpanStart, SyntaxFactory.IdentifierName("Task"), SpeculativeBindingOption.BindAsTypeOrNamespace).Type, + taskSymbol) + ? SyntaxFactory.IdentifierName("Task") + : SyntaxFactory.ParseTypeName("System.Threading.Tasks.Task"); + } + + private static bool IsTaskReturnType(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) + { + INamedTypeSymbol? taskSymbol = semanticModel.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask); + return taskSymbol is not null && + SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(methodDeclaration.ReturnType, cancellationToken).Type, taskSymbol); + } + private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) { if (WalkDownParentheses(expression) is not LambdaExpressionSyntax lambdaExpression) @@ -190,6 +216,13 @@ private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expre return TryGetBlockedTaskExpression(expressionStatement.Expression, out asyncExpression); } + if (lambdaExpression.Body is BlockSyntax returnBlockSyntax && + returnBlockSyntax.Statements.Count == 1 && + returnBlockSyntax.Statements[0] is ReturnStatementSyntax { Expression: { } returnExpression }) + { + return TryGetBlockedTaskExpression(returnExpression, out asyncExpression); + } + asyncExpression = null; return false; } @@ -224,4 +257,81 @@ private static ExpressionSyntax WalkDownParentheses(ExpressionSyntax expression) return currentExpression; } + + private sealed class TaskReturnStatementRewriter : CSharpSyntaxRewriter + { + public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) + => node; + + public override SyntaxNode? VisitParenthesizedLambdaExpression(ParenthesizedLambdaExpressionSyntax node) + => node; + + public override SyntaxNode? VisitAnonymousMethodExpression(AnonymousMethodExpressionSyntax node) + => node; + + public override SyntaxNode? VisitLocalFunctionStatement(LocalFunctionStatementSyntax node) + => node; + + public override SyntaxNode? VisitBlock(BlockSyntax node) + { + List? rewrittenStatements = null; + + for (int i = 0; i < node.Statements.Count; i++) + { + StatementSyntax statement = node.Statements[i]; + if (statement is ReturnStatementSyntax { Expression: { } returnExpression } returnStatement) + { + rewrittenStatements ??= AddUnchangedStatements(node.Statements, i); + rewrittenStatements.AddRange(CreateAwaitAndReturnStatements(returnStatement, returnExpression)); + continue; + } + + var rewrittenStatement = (StatementSyntax)Visit(statement)!; + if (rewrittenStatements is not null) + { + rewrittenStatements.Add(rewrittenStatement); + } + else if (!ReferenceEquals(statement, rewrittenStatement)) + { + rewrittenStatements = AddUnchangedStatements(node.Statements, i); + rewrittenStatements.Add(rewrittenStatement); + } + } + + return rewrittenStatements is null + ? node + : node.WithStatements(SyntaxFactory.List(rewrittenStatements)); + } + + public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) + => node.Expression is { } expression + ? SyntaxFactory.Block(CreateAwaitAndReturnStatements(node, expression)).WithAdditionalAnnotations(Formatter.Annotation) + : node; + + private static List AddUnchangedStatements(SyntaxList statements, int endIndex) + { + var rewrittenStatements = new List(statements.Count + 1); + for (int j = 0; j < endIndex; j++) + { + rewrittenStatements.Add(statements[j]); + } + + return rewrittenStatements; + } + + private static StatementSyntax[] CreateAwaitAndReturnStatements(ReturnStatementSyntax returnStatement, ExpressionSyntax expression) + { + ExpressionStatementSyntax awaitStatement = SyntaxFactory.ExpressionStatement( + SyntaxFactory.AwaitExpression(expression.WithoutLeadingTrivia())) + .WithLeadingTrivia(returnStatement.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + + ReturnStatementSyntax newReturnStatement = returnStatement + .WithExpression(null) + .WithLeadingTrivia(SyntaxFactory.ElasticMarker) + .WithAdditionalAnnotations(Formatter.Annotation); + + return [awaitStatement, newReturnStatement]; + } + } } diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index 2100a95f44..5d2a1a3655 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -70,7 +70,7 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTy targetMethod.Name is not ("Throws" or "ThrowsExactly") || context.ContainingSymbol is not IMethodSymbol containingMethod || !containingMethod.GetAttributes().Any(attr => attr.AttributeClass.Inherits(testMethodAttributeSymbol)) || - IsInsideNestedFunction(operation) || + IsInsideUnsupportedAwaitContext(operation) || !TryGetActionArgument(operation, out IArgumentOperation? actionArgument) || !TryGetBlockedTaskOperationFromArgument(actionArgument.Value, out IOperation? asyncOperation)) { @@ -101,11 +101,12 @@ private static bool TryGetActionArgument(IInvocationOperation operation, [NotNul return false; } - private static bool IsInsideNestedFunction(IOperation operation) + private static bool IsInsideUnsupportedAwaitContext(IOperation operation) { for (IOperation? current = operation.Parent; current is not null; current = current.Parent) { - if (current is IAnonymousFunctionOperation or ILocalFunctionOperation) + if (current is IAnonymousFunctionOperation or ILocalFunctionOperation || + current.Kind == OperationKind.Lock) { return true; } @@ -133,11 +134,15 @@ private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNu foreach (IOperation childOperation in blockOperation.Operations) { + if (childOperation.IsImplicit) + { + continue; + } + IOperation? candidateOperation = childOperation switch { IExpressionStatementOperation expressionStatementOperation => expressionStatementOperation.Operation, IReturnOperation { ReturnedValue: { } returnedValue } => returnedValue, - IReturnOperation { IsImplicit: true } => null, _ => childOperation, }; diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index d08545dace..02cb88dd59 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -5,6 +5,10 @@ MSTest.Analyzers.PreferAsyncAssertionAnalyzer, MSTest.Analyzers.PreferAsyncAssertionFixer>; +using VerifyVB = MSTest.Analyzers.Test.VisualBasicCodeFixVerifier< + MSTest.Analyzers.PreferAsyncAssertionAnalyzer, + Microsoft.CodeAnalysis.Testing.EmptyCodeFixProvider>; + namespace MSTest.Analyzers.Test; [TestClass] @@ -52,6 +56,46 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenVoidTestMethodDoesNotHaveTaskInScope_CodeFixUsesFullyQualifiedTaskReturnType() + { + string code = """ + using System; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; + } + + private System.Threading.Tasks.Task BarAsync() => System.Threading.Tasks.Task.CompletedTask; + } + """; + + string fixedCode = """ + using System; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async System.Threading.Tasks.Task MyTestMethod() + { + await Assert.ThrowsExactlyAsync(() => BarAsync()); + } + + private System.Threading.Tasks.Task BarAsync() => System.Threading.Tasks.Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + [TestMethod] public async Task WhenAssertThrowsBlocksOnGenericTask_CodeFixUsesAsyncAssertion() { @@ -98,6 +142,145 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenAssertThrowsBlocksOnGenericTaskInBlockLambda_CodeFixRemovesBlockingReturnStatement() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + Exception exception = [|Assert.Throws(() => { return BarAsync().GetAwaiter().GetResult(); })|]; + Assert.IsNotNull(exception); + await Task.CompletedTask; + } + + private Task BarAsync() => Task.FromResult(42); + } + """; + + string fixedCode = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + Exception exception = await Assert.ThrowsAsync(() => BarAsync()); + Assert.IsNotNull(exception); + await Task.CompletedTask; + } + + private Task BarAsync() => Task.FromResult(42); + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenStaticallyImportedAssertionBlocksOnTask_CodeFixUpdatesMethodName() + { + string code = """ + using System; + using System.Threading.Tasks; + using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + Exception exception = [|ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; + Assert.IsNotNull(exception); + await Task.CompletedTask; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + string fixedCode = """ + using System; + using System.Threading.Tasks; + using static Microsoft.VisualStudio.TestTools.UnitTesting.Assert; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public async Task MyTestMethod() + { + Exception exception = await ThrowsExactlyAsync(() => BarAsync()); + Assert.IsNotNull(exception); + await Task.CompletedTask; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenNonAsyncTaskReturningTestMethodHasReturnExpression_CodeFixConvertsReturnToAwait() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public Task MyTestMethod() + { + [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|]; + return Task.CompletedTask; + } + + 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()); + await Task.CompletedTask; + return; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + [TestMethod] public async Task WhenAssertThrowsExactlyBlocksOnTask_WithNamedArgumentsOutOfOrder_CodeFixUsesAsyncAssertion() { @@ -140,6 +323,35 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenAssertionIsInsideLockStatement_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + private readonly object _gate = new(); + + [TestMethod] + public void MyTestMethod() + { + lock (_gate) + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + } + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + [TestMethod] public async Task WhenAssertionIsInsideNestedLambda_NoDiagnostic() { @@ -194,6 +406,30 @@ void RunAssertion() await VerifyCS.VerifyAnalyzerAsync(code); } + [TestMethod] + public async Task WhenVisualBasicFunctionLambdaBlocksOnGenericTask_Diagnostic() + { + string code = """ + Imports System + Imports System.Threading.Tasks + Imports Microsoft.VisualStudio.TestTools.UnitTesting + + + Public Class MyTestClass + + Public Sub MyTestMethod() + [|Assert.ThrowsExactly(Of InvalidOperationException)(Function() BarAsync().GetAwaiter().GetResult())|] + End Sub + + Private Function BarAsync() As Task(Of Integer) + Return Task.FromResult(42) + End Function + End Class + """; + + await VerifyVB.VerifyAnalyzerAsync(code); + } + [TestMethod] public async Task WhenAssertionDoesNotBlockOnTask_NoDiagnostic() { From 190da15e21b460a15f2e39fe2a4e83f5dee1fce5 Mon Sep 17 00:00:00 2001 From: Evangelink Date: Sat, 16 May 2026 19:12:15 +0200 Subject: [PATCH 08/14] Distinguish VB synthetic implicit returns from C# expression-body returns The previous skip-all-implicit guard for VB was over-broad: in C# the implicit IReturnOperation generated for an expression-bodied lambda wraps the user's expression as its ReturnedValue, so skipping it silently dropped every expression-bodied-lambda diagnostic (e.g. `() => BarAsync().GetAwaiter().GetResult()`). Only the VB Function lambda's synthetic implicit return-of-function-name-local needs to be filtered out, which we now detect by looking at the unwrapped ReturnedValue for an implicit ILocalReferenceOperation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PreferAsyncAssertionAnalyzer.cs | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index 5d2a1a3655..489cd75226 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -134,15 +134,24 @@ private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNu foreach (IOperation childOperation in blockOperation.Operations) { - if (childOperation.IsImplicit) - { - continue; - } - IOperation? candidateOperation = childOperation switch { IExpressionStatementOperation expressionStatementOperation => expressionStatementOperation.Operation, - IReturnOperation { ReturnedValue: { } returnedValue } => returnedValue, + + // For any return-with-value, treat the returned value as the candidate, except for the + // synthetic implicit return that VB 'Function' lambdas emit: it returns an implicit + // ILocalReferenceOperation to the function-name local. Treating that as a candidate + // would cause single-statement VB Function lambdas to look as if they contain + // multiple operations, and the diagnostic would be missed. + IReturnOperation { ReturnedValue: { } returnedValue } + => GetExplicitReturnedValue(returnedValue), + + // Implicit return with no value contributes no candidate (e.g. end of void lambda). + IReturnOperation => null, + + // Skip any other compiler-synthesized operation (e.g. VB exit-function labels). + _ when childOperation.IsImplicit => null, + _ => childOperation, }; @@ -163,6 +172,16 @@ private static bool TryGetSingleOperation(IBlockOperation blockOperation, [NotNu return operation is not null; } + private static IOperation? GetExplicitReturnedValue(IOperation returnedValue) + { + // The synthetic implicit return that VB Function lambdas emit returns the (also implicit) + // function-name local. Detect that case via the unwrapped operation. + IOperation unwrapped = returnedValue.WalkDownConversion(); + return unwrapped is ILocalReferenceOperation { IsImplicit: true } + ? null + : returnedValue; + } + private static bool TryGetBlockedTaskOperation(IOperation operation, [NotNullWhen(true)] out IOperation? asyncOperation) { if (operation.WalkDownConversion() is IInvocationOperation getResultInvocation && From 687b203dcdb738410ab076b805b95caf1807bef1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 17 May 2026 19:23:03 +0000 Subject: [PATCH 09/14] Address async assertion review feedback Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 40 ++++++++--- .../PreferAsyncAssertionAnalyzer.cs | 3 +- .../PreferAsyncAssertionAnalyzerTests.cs | 72 +++++++++++++++++++ 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 133a1f0423..7249014335 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -134,13 +134,39 @@ private static bool TryGetActionArgumentIndex( private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true)] out ArgumentSyntax? newArgument) { - if (argument.Expression is not LambdaExpressionSyntax lambdaExpression || - !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) + if (!TryReplaceLambdaExpression(argument.Expression, out ExpressionSyntax? newExpression)) { newArgument = null; return false; } + newArgument = argument.WithExpression(newExpression); + return true; + } + + private static bool TryReplaceLambdaExpression(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? newExpression) + { + if (expression is ParenthesizedExpressionSyntax parenthesizedExpression && + TryReplaceLambdaExpression(parenthesizedExpression.Expression, out ExpressionSyntax? parenthesizedNewExpression)) + { + newExpression = parenthesizedNewExpression.WithTriviaFrom(expression); + return true; + } + + if (expression is CastExpressionSyntax castExpression && + TryReplaceLambdaExpression(castExpression.Expression, out ExpressionSyntax? castNewExpression)) + { + newExpression = castNewExpression.WithTriviaFrom(expression); + return true; + } + + if (expression is not LambdaExpressionSyntax lambdaExpression || + !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) + { + newExpression = null; + return false; + } + LambdaExpressionSyntax newLambdaExpression = lambdaExpression switch { SimpleLambdaExpressionSyntax simpleLambda => simpleLambda.WithBody(asyncExpression.WithTriviaFrom(lambdaExpression.Body)), @@ -148,7 +174,7 @@ private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true) _ => lambdaExpression, }; - newArgument = argument.WithExpression(newLambdaExpression); + newExpression = newLambdaExpression; return true; } @@ -196,14 +222,8 @@ private static bool IsTaskReturnType(MethodDeclarationSyntax methodDeclaration, SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(methodDeclaration.ReturnType, cancellationToken).Type, taskSymbol); } - private static bool TryGetBlockedTaskExpressionFromLambda(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) + private static bool TryGetBlockedTaskExpressionFromLambda(LambdaExpressionSyntax lambdaExpression, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) { - if (WalkDownParentheses(expression) is not LambdaExpressionSyntax lambdaExpression) - { - asyncExpression = null; - return false; - } - if (lambdaExpression.Body is ExpressionSyntax expressionBody) { return TryGetBlockedTaskExpression(expressionBody, out asyncExpression); diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index 489cd75226..6fd29504f5 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -106,7 +106,8 @@ private static bool IsInsideUnsupportedAwaitContext(IOperation operation) for (IOperation? current = operation.Parent; current is not null; current = current.Parent) { if (current is IAnonymousFunctionOperation or ILocalFunctionOperation || - current.Kind == OperationKind.Lock) + current.Kind == OperationKind.Lock || + (current.Parent is ICatchClauseOperation catchClauseOperation && ReferenceEquals(catchClauseOperation.Filter, current))) { return true; } diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 02cb88dd59..347c6e47ca 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -323,6 +323,48 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenAssertionActionIsExplicitlyCast_CodeFixUsesAsyncAssertionAndRemovesCast() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(((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 WhenAssertionIsInsideLockStatement_NoDiagnostic() { @@ -352,6 +394,36 @@ public void MyTestMethod() await VerifyCS.VerifyAnalyzerAsync(code); } + [TestMethod] + public async Task WhenAssertionIsInsideExceptionFilter_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + try + { + throw new Exception(); + } + catch (Exception) when (Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()) is not null) + { + } + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + [TestMethod] public async Task WhenAssertionIsInsideNestedLambda_NoDiagnostic() { From ec68da1047bd41e3556b837e9a2ea549ee71132b Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 10:52:55 +0200 Subject: [PATCH 10/14] Handle converted async assertion arguments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 7249014335..6494325fd4 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -120,12 +120,19 @@ private static bool TryGetActionArgumentIndex( foreach (IArgumentOperation argumentOperation in invocationOperation.Arguments) { - if (argumentOperation.Parameter?.Name == "action" && - argumentOperation.Syntax is ArgumentSyntax argumentSyntax) + if (argumentOperation.Parameter?.Name != "action") { - actionArgumentIndex = invocationExpression.ArgumentList.Arguments.IndexOf(argumentSyntax); - return actionArgumentIndex >= 0; + continue; } + + ArgumentSyntax? argumentSyntax = argumentOperation.Syntax.AncestorsAndSelf().OfType().FirstOrDefault(); + if (argumentSyntax is null) + { + continue; + } + + actionArgumentIndex = invocationExpression.ArgumentList.Arguments.IndexOf(argumentSyntax); + return actionArgumentIndex >= 0; } actionArgumentIndex = -1; From ad7dd57a814b8e5f609ff244372e314f21cd79c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:29:02 +0000 Subject: [PATCH 11/14] Address async assertion review edge cases Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 100 ++++-- .../PreferAsyncAssertionAnalyzer.cs | 26 ++ .../PreferAsyncAssertionAnalyzerTests.cs | 296 ++++++++++++++++++ 3 files changed, 401 insertions(+), 21 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 6494325fd4..2ff7c98abc 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -61,15 +61,13 @@ private static async Task UseAsyncAssertionAsync(Document document, In InvocationExpressionSyntax newInvocationExpression = ReplaceAssertMethodName(invocationExpression); if (TryGetActionArgumentIndex(invocationExpression, semanticModel, cancellationToken, out int actionArgumentIndex) && - TryReplaceLambda(newInvocationExpression.ArgumentList.Arguments[actionArgumentIndex], out ArgumentSyntax? newArgument)) + TryReplaceAction(newInvocationExpression.ArgumentList.Arguments[actionArgumentIndex], out ArgumentSyntax? newArgument)) { newInvocationExpression = newInvocationExpression.WithArgumentList( newInvocationExpression.ArgumentList.WithArguments(newInvocationExpression.ArgumentList.Arguments.Replace(newInvocationExpression.ArgumentList.Arguments[actionArgumentIndex], newArgument))); } - AwaitExpressionSyntax awaitExpression = SyntaxFactory.AwaitExpression(newInvocationExpression.WithoutLeadingTrivia()) - .WithLeadingTrivia(invocationExpression.GetLeadingTrivia()) - .WithAdditionalAnnotations(Formatter.Annotation); + ExpressionSyntax awaitExpression = CreateAwaitExpression(invocationExpression, newInvocationExpression); if (invocationExpression.Ancestors().OfType().FirstOrDefault() is { } methodDeclaration) { @@ -139,9 +137,33 @@ private static bool TryGetActionArgumentIndex( return false; } - private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true)] out ArgumentSyntax? newArgument) + private static ExpressionSyntax CreateAwaitExpression(InvocationExpressionSyntax originalInvocationExpression, InvocationExpressionSyntax newInvocationExpression) { - if (!TryReplaceLambdaExpression(argument.Expression, out ExpressionSyntax? newExpression)) + AwaitExpressionSyntax awaitExpression = SyntaxFactory.AwaitExpression(newInvocationExpression.WithoutLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + + return NeedsParenthesizedAwait(originalInvocationExpression) + ? SyntaxFactory.ParenthesizedExpression(awaitExpression) + .WithLeadingTrivia(originalInvocationExpression.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation) + : awaitExpression + .WithLeadingTrivia(originalInvocationExpression.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + } + + private static bool NeedsParenthesizedAwait(InvocationExpressionSyntax invocationExpression) + => invocationExpression.Parent switch + { + MemberAccessExpressionSyntax memberAccessExpression when memberAccessExpression.Expression == invocationExpression => true, + ElementAccessExpressionSyntax elementAccessExpression when elementAccessExpression.Expression == invocationExpression => true, + InvocationExpressionSyntax parentInvocationExpression when parentInvocationExpression.Expression == invocationExpression => true, + ConditionalAccessExpressionSyntax conditionalAccessExpression when conditionalAccessExpression.Expression == invocationExpression => true, + _ => false, + }; + + private static bool TryReplaceAction(ArgumentSyntax argument, [NotNullWhen(true)] out ArgumentSyntax? newArgument) + { + if (!TryReplaceActionExpression(argument.Expression, out ExpressionSyntax? newExpression)) { newArgument = null; return false; @@ -151,22 +173,32 @@ private static bool TryReplaceLambda(ArgumentSyntax argument, [NotNullWhen(true) return true; } - private static bool TryReplaceLambdaExpression(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? newExpression) + private static bool TryReplaceActionExpression(ExpressionSyntax expression, [NotNullWhen(true)] out ExpressionSyntax? newExpression) { if (expression is ParenthesizedExpressionSyntax parenthesizedExpression && - TryReplaceLambdaExpression(parenthesizedExpression.Expression, out ExpressionSyntax? parenthesizedNewExpression)) + TryReplaceActionExpression(parenthesizedExpression.Expression, out ExpressionSyntax? parenthesizedNewExpression)) { newExpression = parenthesizedNewExpression.WithTriviaFrom(expression); return true; } if (expression is CastExpressionSyntax castExpression && - TryReplaceLambdaExpression(castExpression.Expression, out ExpressionSyntax? castNewExpression)) + TryReplaceActionExpression(castExpression.Expression, out ExpressionSyntax? castNewExpression)) { newExpression = castNewExpression.WithTriviaFrom(expression); return true; } + if (expression is AnonymousMethodExpressionSyntax anonymousMethodExpression && + TryGetBlockedTaskExpressionFromBlock(anonymousMethodExpression.Block, out ExpressionSyntax? anonymousMethodAsyncExpression)) + { + newExpression = SyntaxFactory.ParenthesizedLambdaExpression() + .WithParameterList(SyntaxFactory.ParameterList()) + .WithBody(anonymousMethodAsyncExpression.WithTriviaFrom(anonymousMethodExpression.Block)) + .WithTriviaFrom(expression); + return true; + } + if (expression is not LambdaExpressionSyntax lambdaExpression || !TryGetBlockedTaskExpressionFromLambda(lambdaExpression, out ExpressionSyntax? asyncExpression)) { @@ -199,18 +231,33 @@ private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType( newMethodDeclaration = newMethodDeclaration.WithModifiers(newMethodDeclaration.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.AsyncKeyword))); } - if (newMethodDeclaration.ReturnType.IsVoid()) + bool wasVoid = originalMethodDeclaration.ReturnType.IsVoid(); + + if (wasVoid) { newMethodDeclaration = newMethodDeclaration.WithReturnType(GetTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken).WithTriviaFrom(newMethodDeclaration.ReturnType)); + if (newMethodDeclaration.ExpressionBody is { } expressionBody) + { + newMethodDeclaration = ConvertExpressionBodyToBlock(newMethodDeclaration, expressionBody); + } } - else if (!isAsync && IsTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken) && newMethodDeclaration.Body is { } body) + else if (!isAsync && IsTaskOrValueTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken) && newMethodDeclaration.Body is { } body) { - newMethodDeclaration = newMethodDeclaration.WithBody((BlockSyntax)new TaskReturnStatementRewriter().Visit(body)!); + newMethodDeclaration = newMethodDeclaration.WithBody((BlockSyntax)new AwaitableReturnStatementRewriter().Visit(body)!); } return newMethodDeclaration.WithAdditionalAnnotations(Formatter.Annotation); } + private static MethodDeclarationSyntax ConvertExpressionBodyToBlock(MethodDeclarationSyntax methodDeclaration, ArrowExpressionClauseSyntax expressionBody) + => methodDeclaration + .WithExpressionBody(null) + .WithSemicolonToken(default) + .WithBody(SyntaxFactory.Block( + SyntaxFactory.ExpressionStatement(expressionBody.Expression) + .WithLeadingTrivia(expressionBody.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation))); + private static TypeSyntax GetTaskReturnType(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) { INamedTypeSymbol? taskSymbol = semanticModel.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask); @@ -222,11 +269,13 @@ private static TypeSyntax GetTaskReturnType(MethodDeclarationSyntax methodDeclar : SyntaxFactory.ParseTypeName("System.Threading.Tasks.Task"); } - private static bool IsTaskReturnType(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) + private static bool IsTaskOrValueTaskReturnType(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) { INamedTypeSymbol? taskSymbol = semanticModel.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksTask); - return taskSymbol is not null && - SymbolEqualityComparer.Default.Equals(semanticModel.GetTypeInfo(methodDeclaration.ReturnType, cancellationToken).Type, taskSymbol); + INamedTypeSymbol? valueTaskSymbol = semanticModel.Compilation.GetTypeByMetadataName(WellKnownTypeNames.SystemThreadingTasksValueTask); + ITypeSymbol? returnTypeSymbol = semanticModel.GetTypeInfo(methodDeclaration.ReturnType, cancellationToken).Type; + return (taskSymbol is not null && SymbolEqualityComparer.Default.Equals(returnTypeSymbol, taskSymbol)) || + (valueTaskSymbol is not null && SymbolEqualityComparer.Default.Equals(returnTypeSymbol, valueTaskSymbol)); } private static bool TryGetBlockedTaskExpressionFromLambda(LambdaExpressionSyntax lambdaExpression, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) @@ -236,16 +285,25 @@ private static bool TryGetBlockedTaskExpressionFromLambda(LambdaExpressionSyntax return TryGetBlockedTaskExpression(expressionBody, out asyncExpression); } - if (lambdaExpression.Body is BlockSyntax blockSyntax && - blockSyntax.Statements.Count == 1 && + if (lambdaExpression.Body is BlockSyntax blockSyntax) + { + return TryGetBlockedTaskExpressionFromBlock(blockSyntax, out asyncExpression); + } + + asyncExpression = null; + return false; + } + + private static bool TryGetBlockedTaskExpressionFromBlock(BlockSyntax blockSyntax, [NotNullWhen(true)] out ExpressionSyntax? asyncExpression) + { + if (blockSyntax.Statements.Count == 1 && blockSyntax.Statements[0] is ExpressionStatementSyntax expressionStatement) { return TryGetBlockedTaskExpression(expressionStatement.Expression, out asyncExpression); } - if (lambdaExpression.Body is BlockSyntax returnBlockSyntax && - returnBlockSyntax.Statements.Count == 1 && - returnBlockSyntax.Statements[0] is ReturnStatementSyntax { Expression: { } returnExpression }) + if (blockSyntax.Statements.Count == 1 && + blockSyntax.Statements[0] is ReturnStatementSyntax { Expression: { } returnExpression }) { return TryGetBlockedTaskExpression(returnExpression, out asyncExpression); } @@ -285,7 +343,7 @@ private static ExpressionSyntax WalkDownParentheses(ExpressionSyntax expression) return currentExpression; } - private sealed class TaskReturnStatementRewriter : CSharpSyntaxRewriter + private sealed class AwaitableReturnStatementRewriter : CSharpSyntaxRewriter { public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) => node; diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index 6fd29504f5..d7c1da2f6a 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -68,8 +68,10 @@ private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTy if ( !SymbolEqualityComparer.Default.Equals(targetMethod.ContainingType, assertSymbol) || targetMethod.Name is not ("Throws" or "ThrowsExactly") || + HasInterpolatedStringHandlerParameter(targetMethod) || context.ContainingSymbol is not IMethodSymbol containingMethod || !containingMethod.GetAttributes().Any(attr => attr.AttributeClass.Inherits(testMethodAttributeSymbol)) || + IsUnsupportedVoidTestMethod(containingMethod) || IsInsideUnsupportedAwaitContext(operation) || !TryGetActionArgument(operation, out IArgumentOperation? actionArgument) || !TryGetBlockedTaskOperationFromArgument(actionArgument.Value, out IOperation? asyncOperation)) @@ -86,6 +88,15 @@ context.ContainingSymbol is not IMethodSymbol containingMethod || context.ReportDiagnostic(operation.CreateDiagnostic(Rule, targetMethod.Name + "Async", targetMethod.Name)); } + private static bool HasInterpolatedStringHandlerParameter(IMethodSymbol targetMethod) + => targetMethod.Parameters.Any(static parameter => + parameter.RefKind != RefKind.None && + parameter.Type.GetAttributes().Any(static attr => attr.AttributeClass?.Name == "InterpolatedStringHandlerAttribute")); + + private static bool IsUnsupportedVoidTestMethod(IMethodSymbol containingMethod) + => containingMethod.ReturnsVoid && + (containingMethod.IsOverride || containingMethod.IsImplementationOfAnyInterfaceMember()); + private static bool TryGetActionArgument(IInvocationOperation operation, [NotNullWhen(true)] out IArgumentOperation? actionArgument) { foreach (IArgumentOperation argument in operation.Arguments) @@ -113,6 +124,21 @@ private static bool IsInsideUnsupportedAwaitContext(IOperation operation) } } + return IsInsideUnsafeSyntax(operation.Syntax); + } + + private static bool IsInsideUnsafeSyntax(SyntaxNode syntax) + { + foreach (SyntaxNode node in syntax.AncestorsAndSelf()) + { + string syntaxTypeName = node.GetType().Name; + if (syntaxTypeName == "UnsafeStatementSyntax" || + (syntaxTypeName == "MethodDeclarationSyntax" && node.ChildTokens().Any(static token => token.ValueText == "unsafe"))) + { + return true; + } + } + return false; } diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 347c6e47ca..600c71dee9 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + using VerifyCS = MSTest.Analyzers.Test.CSharpCodeFixVerifier< MSTest.Analyzers.PreferAsyncAssertionAnalyzer, MSTest.Analyzers.PreferAsyncAssertionFixer>; @@ -281,6 +284,51 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenNonAsyncValueTaskReturningTestMethodHasReturnExpression_CodeFixConvertsReturnToAwait() + { + 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())|]; + return ValueTask.CompletedTask; + } + + 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 ValueTask MyTestMethod() + { + await Assert.ThrowsExactlyAsync(() => BarAsync()); + await ValueTask.CompletedTask; + return; + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + [TestMethod] public async Task WhenAssertThrowsExactlyBlocksOnTask_WithNamedArgumentsOutOfOrder_CodeFixUsesAsyncAssertion() { @@ -365,6 +413,131 @@ public async Task MyTestMethod() await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + [TestMethod] + public async Task WhenAssertionActionIsAnonymousMethod_CodeFixUsesAsyncAssertionAndRemovesBlockingCall() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + [|Assert.ThrowsExactly(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 WhenAssertionResultIsUsedInMemberAccess_CodeFixParenthesizesAwait() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + string message = [|Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult())|].Message; + Assert.IsNotNull(message); + } + + 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() + { + string message = (await Assert.ThrowsExactlyAsync(() => BarAsync())).Message; + Assert.IsNotNull(message); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } + + [TestMethod] + public async Task WhenVoidTestMethodIsExpressionBodied_CodeFixConvertsBodyToBlock() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() => [|Assert.ThrowsExactly(() => 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 WhenAssertionIsInsideLockStatement_NoDiagnostic() { @@ -424,6 +597,129 @@ public void MyTestMethod() await VerifyCS.VerifyAnalyzerAsync(code); } + [TestMethod] + public async Task WhenAssertionIsInsideUnsafeBlock_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + unsafe + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + } + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + var test = new VerifyCS.Test + { + TestCode = 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); + } + + [TestMethod] + public async Task WhenAssertionUsesInterpolatedStringHandlerOverload_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class MyTestClass + { + [TestMethod] + public void MyTestMethod() + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult(), $"Message {GetMessage()}"); + } + + private Task BarAsync() => Task.CompletedTask; + private string GetMessage() => "message"; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + + [TestMethod] + public async Task WhenVoidTestMethodCannotChangeReturnType_NoDiagnostic() + { + string code = """ + using System; + using System.Threading.Tasks; + using Microsoft.VisualStudio.TestTools.UnitTesting; + + public interface ITest + { + void MyTestMethod(); + } + + public class BaseTestClass + { + public virtual void MyTestMethod() + { + } + } + + [TestClass] + public class OverrideTestClass : BaseTestClass + { + [TestMethod] + public override void MyTestMethod() + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + } + + private Task BarAsync() => Task.CompletedTask; + } + + [TestClass] + public class ImplicitInterfaceTestClass : ITest + { + [TestMethod] + public void MyTestMethod() + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + } + + private Task BarAsync() => Task.CompletedTask; + } + + [TestClass] + public class ExplicitInterfaceTestClass : ITest + { + [TestMethod] + void ITest.MyTestMethod() + { + Assert.ThrowsExactly(() => BarAsync().GetAwaiter().GetResult()); + } + + private Task BarAsync() => Task.CompletedTask; + } + """; + + await VerifyCS.VerifyAnalyzerAsync(code); + } + [TestMethod] public async Task WhenAssertionIsInsideNestedLambda_NoDiagnostic() { From 394e0453e14975b14e5e2e14e3a57815f87aa40c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:32:25 +0000 Subject: [PATCH 12/14] Incorporate validation feedback Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 19 ++++++++++++++----- .../PreferAsyncAssertionAnalyzer.cs | 5 ++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 2ff7c98abc..ff9975b330 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -250,13 +250,22 @@ private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType( } private static MethodDeclarationSyntax ConvertExpressionBodyToBlock(MethodDeclarationSyntax methodDeclaration, ArrowExpressionClauseSyntax expressionBody) - => methodDeclaration + { + BlockSyntax block = SyntaxFactory.Block( + SyntaxFactory.ExpressionStatement(expressionBody.Expression) + .WithLeadingTrivia(expressionBody.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation)); + + if (!methodDeclaration.SemicolonToken.IsKind(SyntaxKind.None)) + { + block = block.WithCloseBraceToken(block.CloseBraceToken.WithTrailingTrivia(methodDeclaration.SemicolonToken.TrailingTrivia)); + } + + return methodDeclaration .WithExpressionBody(null) .WithSemicolonToken(default) - .WithBody(SyntaxFactory.Block( - SyntaxFactory.ExpressionStatement(expressionBody.Expression) - .WithLeadingTrivia(expressionBody.GetLeadingTrivia()) - .WithAdditionalAnnotations(Formatter.Annotation))); + .WithBody(block); + } private static TypeSyntax GetTaskReturnType(MethodDeclarationSyntax methodDeclaration, SemanticModel semanticModel, CancellationToken cancellationToken) { diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index d7c1da2f6a..04585d7283 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -90,7 +90,6 @@ context.ContainingSymbol is not IMethodSymbol containingMethod || private static bool HasInterpolatedStringHandlerParameter(IMethodSymbol targetMethod) => targetMethod.Parameters.Any(static parameter => - parameter.RefKind != RefKind.None && parameter.Type.GetAttributes().Any(static attr => attr.AttributeClass?.Name == "InterpolatedStringHandlerAttribute")); private static bool IsUnsupportedVoidTestMethod(IMethodSymbol containingMethod) @@ -131,6 +130,10 @@ private static bool IsInsideUnsafeSyntax(SyntaxNode syntax) { foreach (SyntaxNode node in syntax.AncestorsAndSelf()) { + // This analyzer targets both C# and VB from a project that intentionally references + // Microsoft.CodeAnalysis.Common only. Use syntax type names to detect C# unsafe + // constructs without adding a Microsoft.CodeAnalysis.CSharp dependency that would + // make the VB analyzer unsupported (RS1038). string syntaxTypeName = node.GetType().Name; if (syntaxTypeName == "UnsafeStatementSyntax" || (syntaxTypeName == "MethodDeclarationSyntax" && node.ChildTokens().Any(static token => token.ValueText == "unsafe"))) From 2983e26cb6e04f236964a73f24dfd26e227349e7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:34:23 +0000 Subject: [PATCH 13/14] Polish async assertion fixes Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs | 4 ++-- .../MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index ff9975b330..15ef364f84 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -231,9 +231,9 @@ private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType( newMethodDeclaration = newMethodDeclaration.WithModifiers(newMethodDeclaration.Modifiers.Add(SyntaxFactory.Token(SyntaxKind.AsyncKeyword))); } - bool wasVoid = originalMethodDeclaration.ReturnType.IsVoid(); + bool originalReturnsVoid = originalMethodDeclaration.ReturnType.IsVoid(); - if (wasVoid) + if (originalReturnsVoid) { newMethodDeclaration = newMethodDeclaration.WithReturnType(GetTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken).WithTriviaFrom(newMethodDeclaration.ReturnType)); if (newMethodDeclaration.ExpressionBody is { } expressionBody) diff --git a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs index 04585d7283..87ae258ecd 100644 --- a/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/PreferAsyncAssertionAnalyzer.cs @@ -131,9 +131,9 @@ private static bool IsInsideUnsafeSyntax(SyntaxNode syntax) foreach (SyntaxNode node in syntax.AncestorsAndSelf()) { // This analyzer targets both C# and VB from a project that intentionally references - // Microsoft.CodeAnalysis.Common only. Use syntax type names to detect C# unsafe - // constructs without adding a Microsoft.CodeAnalysis.CSharp dependency that would - // make the VB analyzer unsupported (RS1038). + // Microsoft.CodeAnalysis.Common only. VB has no unsafe keyword, so use syntax type + // names to detect C# unsafe constructs without adding a Microsoft.CodeAnalysis.CSharp + // dependency that would make the VB analyzer unsupported (RS1038). string syntaxTypeName = node.GetType().Name; if (syntaxTypeName == "UnsafeStatementSyntax" || (syntaxTypeName == "MethodDeclarationSyntax" && node.ChildTokens().Any(static token => token.ValueText == "unsafe"))) From dc11ebe5d508082bbfc3f7a6ca38da31f4154150 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 18 May 2026 13:35:58 +0000 Subject: [PATCH 14/14] Address final validation suggestions Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --- .../PreferAsyncAssertionFixer.cs | 19 ++++++++++++++----- .../PreferAsyncAssertionAnalyzer.cs | 5 ++++- .../PreferAsyncAssertionAnalyzerTests.cs | 2 -- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs index 15ef364f84..3ff4437e19 100644 --- a/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs +++ b/src/Analyzers/MSTest.Analyzers.CodeFixes/PreferAsyncAssertionFixer.cs @@ -190,6 +190,7 @@ private static bool TryReplaceActionExpression(ExpressionSyntax expression, [Not } if (expression is AnonymousMethodExpressionSyntax anonymousMethodExpression && + anonymousMethodExpression.ParameterList is null or { Parameters.Count: 0 } && TryGetBlockedTaskExpressionFromBlock(anonymousMethodExpression.Block, out ExpressionSyntax? anonymousMethodAsyncExpression)) { newExpression = SyntaxFactory.ParenthesizedLambdaExpression() @@ -243,7 +244,7 @@ private static MethodDeclarationSyntax AddAsyncModifierAndTaskReturnType( } else if (!isAsync && IsTaskOrValueTaskReturnType(originalMethodDeclaration, semanticModel, cancellationToken) && newMethodDeclaration.Body is { } body) { - newMethodDeclaration = newMethodDeclaration.WithBody((BlockSyntax)new AwaitableReturnStatementRewriter().Visit(body)!); + newMethodDeclaration = newMethodDeclaration.WithBody((BlockSyntax)new AwaitableReturnStatementRewriter(body).Visit(body)!); } return newMethodDeclaration.WithAdditionalAnnotations(Formatter.Annotation); @@ -354,6 +355,13 @@ private static ExpressionSyntax WalkDownParentheses(ExpressionSyntax expression) private sealed class AwaitableReturnStatementRewriter : CSharpSyntaxRewriter { + private readonly BlockSyntax _rootBody; + + public AwaitableReturnStatementRewriter(BlockSyntax rootBody) + { + _rootBody = rootBody; + } + public override SyntaxNode? VisitSimpleLambdaExpression(SimpleLambdaExpressionSyntax node) => node; @@ -376,7 +384,8 @@ private sealed class AwaitableReturnStatementRewriter : CSharpSyntaxRewriter if (statement is ReturnStatementSyntax { Expression: { } returnExpression } returnStatement) { rewrittenStatements ??= AddUnchangedStatements(node.Statements, i); - rewrittenStatements.AddRange(CreateAwaitAndReturnStatements(returnStatement, returnExpression)); + bool isFinalRootReturn = ReferenceEquals(node, _rootBody) && i == node.Statements.Count - 1; + rewrittenStatements.AddRange(CreateAwaitAndReturnStatements(returnStatement, returnExpression, includeReturn: !isFinalRootReturn)); continue; } @@ -399,7 +408,7 @@ private sealed class AwaitableReturnStatementRewriter : CSharpSyntaxRewriter public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) => node.Expression is { } expression - ? SyntaxFactory.Block(CreateAwaitAndReturnStatements(node, expression)).WithAdditionalAnnotations(Formatter.Annotation) + ? SyntaxFactory.Block(CreateAwaitAndReturnStatements(node, expression, includeReturn: true)).WithAdditionalAnnotations(Formatter.Annotation) : node; private static List AddUnchangedStatements(SyntaxList statements, int endIndex) @@ -413,7 +422,7 @@ private static List AddUnchangedStatements(SyntaxList token.ValueText == "unsafe"))) + (syntaxTypeName == "MethodDeclarationSyntax" && HasUnsafeKeyword(node))) { return true; } @@ -145,6 +145,9 @@ private static bool IsInsideUnsafeSyntax(SyntaxNode syntax) return false; } + private static bool HasUnsafeKeyword(SyntaxNode node) + => node.ChildTokens().Any(static token => token.ValueText == "unsafe"); + private static bool TryGetBlockedTaskOperationFromArgument(IOperation argumentValueOperation, [NotNullWhen(true)] out IOperation? asyncOperation) { if (argumentValueOperation.WalkDownConversion() is not IDelegateCreationOperation delegateCreationOperation || diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs index 600c71dee9..d6daf569d5 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/PreferAsyncAssertionAnalyzerTests.cs @@ -274,7 +274,6 @@ public async Task MyTestMethod() { await Assert.ThrowsExactlyAsync(() => BarAsync()); await Task.CompletedTask; - return; } private Task BarAsync() => Task.CompletedTask; @@ -319,7 +318,6 @@ public async ValueTask MyTestMethod() { await Assert.ThrowsExactlyAsync(() => BarAsync()); await ValueTask.CompletedTask; - return; } private Task BarAsync() => Task.CompletedTask;