diff --git a/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs b/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs index 50eb5a29b4..bea4a61b33 100644 --- a/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs +++ b/TUnit.Analyzers.Tests/AsyncVoidAnalyzerTests.cs @@ -188,4 +188,122 @@ public void Test() .WithLocation(0) ); } + + [Test] + public async Task Async_Void_NonTest_Method_Raises_No_Error() + { + // An async void method that is not a test or hook (e.g. an event handler) + // must not be flagged just because the project references TUnit. See #6190. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + public event EventHandler? Ticked; + + public void Setup() + { + Ticked += OnTicked; + } + + private async void OnTicked(object? sender, EventArgs e) + { + await Task.Delay(1); + } + } + """ + ); + } + + [Test] + public async Task Async_Void_EventHandler_Lambda_In_NonTest_Method_Raises_No_Error() + { + // An async void lambda subscribed to an event from a non-test method is legitimate. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + public event EventHandler? Ticked; + + public void Setup() + { + Ticked += async (sender, e) => + { + await Task.Delay(1); + }; + } + } + """ + ); + } + + [Test] + public async Task Async_Void_Lambda_In_Local_Function_In_Test_Raises_Error() + { + // A local function is not a boundary: a lambda nested inside one within a test + // is still scoped to the test and must be flagged. See #6190 review. + await Verifier + .VerifyAnalyzerAsync( + """ + using System; + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + [Test] + public void Test() + { + void Helper() + { + Action action = {|#0:async () => + { + await Task.Delay(1); + }|}; + } + + Helper(); + } + } + """, + + Verifier + .Diagnostic(Rules.AsyncVoidMethod) + .WithLocation(0) + ); + } + + [Test] + public async Task Async_Void_Hook_Raises_Error() + { + await Verifier + .VerifyAnalyzerAsync( + """ + using System.Threading.Tasks; + using TUnit.Core; + + public class MyClass + { + [Before(HookType.Test)] + public async void {|#0:Setup|}() + { + await Task.Delay(1); + } + } + """, + + Verifier + .Diagnostic(Rules.AsyncVoidMethod) + .WithLocation(0) + ); + } } diff --git a/TUnit.Analyzers/AsyncVoidAnalyzer.cs b/TUnit.Analyzers/AsyncVoidAnalyzer.cs index c553c4b8b6..ac1dafab91 100644 --- a/TUnit.Analyzers/AsyncVoidAnalyzer.cs +++ b/TUnit.Analyzers/AsyncVoidAnalyzer.cs @@ -1,8 +1,9 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; +using TUnit.Analyzers.Extensions; namespace TUnit.Analyzers; @@ -28,12 +29,22 @@ private void AnalyzeMethod(SymbolAnalysisContext context) return; } - if (methodSymbol is { IsAsync: true, ReturnsVoid: true }) + if (methodSymbol is not { IsAsync: true, ReturnsVoid: true }) { - context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, - methodSymbol.Locations.FirstOrDefault()) - ); + return; + } + + // Only flag `async void` on TUnit test methods and hooks. Other `async void` + // methods (e.g. an event handler for System.Timers.Timer.Elapsed) are legitimate + // and must not be flagged just because the project references TUnit. See #6190. + if (!IsTestOrHookMethod(methodSymbol, context.Compilation)) + { + return; } + + context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, + methodSymbol.Locations.FirstOrDefault()) + ); } private void AnalyzeLambda(SyntaxNodeAnalysisContext context) @@ -50,11 +61,46 @@ private void AnalyzeLambda(SyntaxNodeAnalysisContext context) var symbol = context.SemanticModel.GetSymbolInfo(anonymousFunction).Symbol; - if (symbol is IMethodSymbol { ReturnsVoid: true }) + if (symbol is not IMethodSymbol { ReturnsVoid: true }) { - context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, - anonymousFunction.GetLocation()) - ); + return; + } + + // Only flag `async void` lambdas declared inside a TUnit test method or hook, + // so legitimate async void event-handler lambdas elsewhere aren't flagged. See #6190. + var enclosingMethod = GetEnclosingMethod(symbol); + + if (enclosingMethod is null || !IsTestOrHookMethod(enclosingMethod, context.Compilation)) + { + return; } + + context.ReportDiagnostic(Diagnostic.Create(Rules.AsyncVoidMethod, + anonymousFunction.GetLocation()) + ); + } + + private static bool IsTestOrHookMethod(IMethodSymbol methodSymbol, Compilation compilation) + { + return methodSymbol.HasTestAttribute(compilation) + || methodSymbol.IsHookMethod(compilation, out _, out _, out _); + } + + private static IMethodSymbol? GetEnclosingMethod(ISymbol? symbol) + { + // Walk out of nested lambdas, anonymous methods, and local functions to the + // real enclosing method, so a lambda nested inside a local function of a test + // is still scoped to that test rather than treating the local function as a boundary. + while (symbol is IMethodSymbol method) + { + if (method.MethodKind is not (MethodKind.AnonymousFunction or MethodKind.LocalFunction)) + { + return method; + } + + symbol = method.ContainingSymbol; + } + + return null; } }