From 31a2317a42bd360237ef869957f14080a5740cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 23 Mar 2026 16:48:27 +0100 Subject: [PATCH 1/2] Fix MSTEST0049 false positives inside expression trees (#7585) Skip reporting MSTEST0049 diagnostics when the invocation is inside an expression tree (e.g. Moq Setup lambdas), where the code fix cannot be meaningfully applied. - Add IsInsideExpressionTree helper that walks up the operation tree to detect lambdas converted to Expression - Add SystemLinqExpressionsLambdaExpression to WellKnownTypeNames - Add 3 tests covering expression tree suppression and regular lambda non-suppression --- ...lowTestContextCancellationTokenAnalyzer.cs | 46 +++++++- .../Helpers/WellKnownTypeNames.cs | 1 + ...stContextCancellationTokenAnalyzerTests.cs | 104 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs index 388b893439..32c85e3c54 100644 --- a/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs @@ -57,8 +57,10 @@ public override void Initialize(AnalysisContext context) return; } + context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemLinqExpressionsLambdaExpression, out INamedTypeSymbol? lambdaExpressionSymbol); + context.RegisterOperationAction( - context => AnalyzeInvocation(context, cancellationTokenSymbol, testContextSymbol, classCleanupAttributeSymbol, assemblyCleanupAttributeSymbol, testMethodAttributeSymbol), + context => AnalyzeInvocation(context, cancellationTokenSymbol, testContextSymbol, classCleanupAttributeSymbol, assemblyCleanupAttributeSymbol, testMethodAttributeSymbol, lambdaExpressionSymbol), OperationKind.Invocation); }); } @@ -69,7 +71,8 @@ private static void AnalyzeInvocation( INamedTypeSymbol testContextSymbol, INamedTypeSymbol classCleanupAttributeSymbol, INamedTypeSymbol assemblyCleanupAttributeSymbol, - INamedTypeSymbol testMethodAttributeSymbol) + INamedTypeSymbol testMethodAttributeSymbol, + INamedTypeSymbol? lambdaExpressionSymbol) { var invocationOperation = (IInvocationOperation)context.Operation; IMethodSymbol method = invocationOperation.TargetMethod; @@ -102,6 +105,12 @@ private static void AnalyzeInvocation( cancellationTokenParameterName = cancellationTokenParameter.Name; } + // Skip diagnostics inside expression trees where the code fix cannot be applied. + if (IsInsideExpressionTree(invocationOperation, lambdaExpressionSymbol)) + { + return; + } + context.ReportDiagnostic(invocationOperation.Syntax.CreateDiagnostic(FlowTestContextCancellationTokenRule, properties: GetPropertiesBag(testContextMemberNameInScope, testContextState, cancellationTokenParameterName))); return; } @@ -119,6 +128,12 @@ private static void AnalyzeInvocation( cancellationTokenParameterName = cancellationTokenParameterFromDifferentOverload.Name; } + // Skip diagnostics inside expression trees where the code fix cannot be applied. + if (IsInsideExpressionTree(invocationOperation, lambdaExpressionSymbol)) + { + return; + } + context.ReportDiagnostic(invocationOperation.Syntax.CreateDiagnostic(FlowTestContextCancellationTokenRule, properties: GetPropertiesBag(testContextMemberNameInScope, testContextState, cancellationTokenParameterName))); } @@ -138,6 +153,33 @@ private static void AnalyzeInvocation( } } + private static bool IsInsideExpressionTree(IOperation operation, INamedTypeSymbol? lambdaExpressionSymbol) + { + if (lambdaExpressionSymbol is null) + { + return false; + } + + IOperation? current = operation.Parent; + while (current is not null) + { + if (current is IAnonymousFunctionOperation) + { + // Check if the parent converts this lambda to an Expression. + if (current.Parent is IConversionOperation conversion && + conversion.Type is INamedTypeSymbol namedType && + namedType.Inherits(lambdaExpressionSymbol)) + { + return true; + } + } + + current = current.Parent; + } + + return false; + } + private static IParameterSymbol? GetCancellationTokenParameterOfOverloadWithCancellationToken(IMethodSymbol method, INamedTypeSymbol cancellationTokenSymbol) { // Look for overloads of the same method that accept CancellationToken diff --git a/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs b/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs index c04213e414..ff74d8ee81 100644 --- a/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs +++ b/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs @@ -50,6 +50,7 @@ internal static class WellKnownTypeNames public const string SystemIAsyncDisposable = "System.IAsyncDisposable"; public const string SystemIDisposable = "System.IDisposable"; public const string SystemLinqEnumerable = "System.Linq.Enumerable"; + public const string SystemLinqExpressionsLambdaExpression = "System.Linq.Expressions.LambdaExpression"; public const string SystemOperatingSystem = "System.OperatingSystem"; public const string SystemReflectionMethodInfo = "System.Reflection.MethodInfo"; public const string SystemRuntimeCompilerServicesCallerFilePathAttribute = "System.Runtime.CompilerServices.CallerFilePathAttribute"; diff --git a/test/UnitTests/MSTest.Analyzers.UnitTests/FlowTestContextCancellationTokenAnalyzerTests.cs b/test/UnitTests/MSTest.Analyzers.UnitTests/FlowTestContextCancellationTokenAnalyzerTests.cs index a639858e30..a2fcda75d3 100644 --- a/test/UnitTests/MSTest.Analyzers.UnitTests/FlowTestContextCancellationTokenAnalyzerTests.cs +++ b/test/UnitTests/MSTest.Analyzers.UnitTests/FlowTestContextCancellationTokenAnalyzerTests.cs @@ -830,4 +830,108 @@ await Task.Delay( await VerifyCS.VerifyCodeFixAsync(code, fixedCode); } + + [TestMethod] + public async Task WhenInsideExpressionTree_NoDiagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + using System.Linq.Expressions; + using System.Threading; + using System.Threading.Tasks; + + [TestClass] + public class MyTestClass + { + public TestContext TestContext { get; set; } + + [TestMethod] + public void MyTestMethod() + { + Expression> expr = () => Task.Delay(1000); + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenInsideExpressionTreeWithOverloadHavingCancellationToken_NoDiagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + using System.Linq.Expressions; + using System.Threading; + using System.Threading.Tasks; + + public interface IMyService + { + Task DoWorkAsync(string input); + Task DoWorkAsync(string input, CancellationToken cancellationToken); + } + + [TestClass] + public class MyTestClass + { + public TestContext TestContext { get; set; } + + [TestMethod] + public void MyTestMethod() + { + Expression> expr = svc => svc.DoWorkAsync("test"); + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, code); + } + + [TestMethod] + public async Task WhenInsideLambdaButNotExpressionTree_Diagnostic() + { + string code = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + using System.Threading; + using System.Threading.Tasks; + + [TestClass] + public class MyTestClass + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task MyTestMethod() + { + Func action = () => [|Task.Delay(1000)|]; + await action(); + } + } + """; + + string fixedCode = """ + using Microsoft.VisualStudio.TestTools.UnitTesting; + using System; + using System.Threading; + using System.Threading.Tasks; + + [TestClass] + public class MyTestClass + { + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task MyTestMethod() + { + Func action = () => Task.Delay(1000, TestContext.CancellationToken); + await action(); + } + } + """; + + await VerifyCS.VerifyCodeFixAsync(code, fixedCode); + } } From 6cf1c5c182e8bcd3b26a1b01a54c890ee530ce17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 25 Mar 2026 09:02:51 +0100 Subject: [PATCH 2/2] Address review comments --- ...lowTestContextCancellationTokenAnalyzer.cs | 25 ++++++++----------- .../Helpers/WellKnownTypeNames.cs | 2 +- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs b/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs index 32c85e3c54..582ff7cceb 100644 --- a/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs +++ b/src/Analyzers/MSTest.Analyzers/FlowTestContextCancellationTokenAnalyzer.cs @@ -57,10 +57,10 @@ public override void Initialize(AnalysisContext context) return; } - context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemLinqExpressionsLambdaExpression, out INamedTypeSymbol? lambdaExpressionSymbol); + context.Compilation.TryGetOrCreateTypeByMetadataName(WellKnownTypeNames.SystemLinqExpressionsExpression1, out INamedTypeSymbol? linqExpressionType); context.RegisterOperationAction( - context => AnalyzeInvocation(context, cancellationTokenSymbol, testContextSymbol, classCleanupAttributeSymbol, assemblyCleanupAttributeSymbol, testMethodAttributeSymbol, lambdaExpressionSymbol), + context => AnalyzeInvocation(context, cancellationTokenSymbol, testContextSymbol, classCleanupAttributeSymbol, assemblyCleanupAttributeSymbol, testMethodAttributeSymbol, linqExpressionType), OperationKind.Invocation); }); } @@ -72,7 +72,7 @@ private static void AnalyzeInvocation( INamedTypeSymbol classCleanupAttributeSymbol, INamedTypeSymbol assemblyCleanupAttributeSymbol, INamedTypeSymbol testMethodAttributeSymbol, - INamedTypeSymbol? lambdaExpressionSymbol) + INamedTypeSymbol? linqExpressionType) { var invocationOperation = (IInvocationOperation)context.Operation; IMethodSymbol method = invocationOperation.TargetMethod; @@ -106,7 +106,7 @@ private static void AnalyzeInvocation( } // Skip diagnostics inside expression trees where the code fix cannot be applied. - if (IsInsideExpressionTree(invocationOperation, lambdaExpressionSymbol)) + if (IsInsideExpressionTree(invocationOperation, linqExpressionType)) { return; } @@ -129,7 +129,7 @@ private static void AnalyzeInvocation( } // Skip diagnostics inside expression trees where the code fix cannot be applied. - if (IsInsideExpressionTree(invocationOperation, lambdaExpressionSymbol)) + if (IsInsideExpressionTree(invocationOperation, linqExpressionType)) { return; } @@ -153,9 +153,9 @@ private static void AnalyzeInvocation( } } - private static bool IsInsideExpressionTree(IOperation operation, INamedTypeSymbol? lambdaExpressionSymbol) + private static bool IsInsideExpressionTree(IOperation operation, INamedTypeSymbol? linqExpressionType) { - if (lambdaExpressionSymbol is null) + if (linqExpressionType is null) { return false; } @@ -163,15 +163,10 @@ private static bool IsInsideExpressionTree(IOperation operation, INamedTypeSymbo IOperation? current = operation.Parent; while (current is not null) { - if (current is IAnonymousFunctionOperation) + if (current is IAnonymousFunctionOperation or ILocalFunctionOperation) { - // Check if the parent converts this lambda to an Expression. - if (current.Parent is IConversionOperation conversion && - conversion.Type is INamedTypeSymbol namedType && - namedType.Inherits(lambdaExpressionSymbol)) - { - return true; - } + return SymbolEqualityComparer.Default.Equals( + current.Parent?.Type?.OriginalDefinition, linqExpressionType); } current = current.Parent; diff --git a/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs b/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs index ff74d8ee81..93500ce963 100644 --- a/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs +++ b/src/Analyzers/MSTest.Analyzers/Helpers/WellKnownTypeNames.cs @@ -50,7 +50,7 @@ internal static class WellKnownTypeNames public const string SystemIAsyncDisposable = "System.IAsyncDisposable"; public const string SystemIDisposable = "System.IDisposable"; public const string SystemLinqEnumerable = "System.Linq.Enumerable"; - public const string SystemLinqExpressionsLambdaExpression = "System.Linq.Expressions.LambdaExpression"; + public const string SystemLinqExpressionsExpression1 = "System.Linq.Expressions.Expression`1"; public const string SystemOperatingSystem = "System.OperatingSystem"; public const string SystemReflectionMethodInfo = "System.Reflection.MethodInfo"; public const string SystemRuntimeCompilerServicesCallerFilePathAttribute = "System.Runtime.CompilerServices.CallerFilePathAttribute";