fix(analyzers): scope TUnit0031 async-void rule to tests and hooks (#6190)#6244
Conversation
…6190) AsyncVoidAnalyzer flagged every `async void` method and lambda in any project referencing TUnit, turning legitimate uses (e.g. a System.Timers.Timer.Elapsed event handler) into build errors since the rule's severity is Error. Restrict the rule to its intended scope: an `async void` method is only flagged when it carries a TUnit test or hook attribute, and an `async void` lambda only when it is declared inside such a method. Event handlers and other async void code in test projects are left alone. Adds regression tests covering a non-test async void method, an async void event-handler lambda in a non-test method, and an async void hook.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 8 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Code Review: fix(analyzers): scope TUnit0031 async-void rule to tests and hooks (#6190)
Overall: Correct fix, well-structured, and properly tested. Approved with one edge-case note.
What's Good
The root cause is correctly identified and the fix is well-targeted. Previously AsyncVoidAnalyzer raised TUnit0031 on any async void in the compilation, which punished legitimate event-handler patterns (e.g. System.Timers.Timer.Elapsed) just for referencing TUnit. The new guard—IsTestOrHookMethod—restricts the diagnostic to where it actually matters.
The refactoring to early-return style in both AnalyzeMethod and AnalyzeLambda is cleaner than the original nested if shape.
The three new test cases cover all important scenarios:
- Non-test
async voidmethod → no diagnostic ✓ async voidlambda inside a non-test method → no diagnostic ✓async voidon a hook method → diagnostic ✓
Existing tests (lines 52–189) confirm the positive cases inside [Test] methods continue to be flagged.
One Edge Case: Local Functions as an Unintentional Escape Hatch
GetEnclosingMethod stops the walk at the first non-AnonymousFunction symbol:
if (method.MethodKind != MethodKind.AnonymousFunction)
{
return method;
}MethodKind.LocalFunction is not AnonymousFunction, so a local function inside a [Test] method acts as an invisible barrier:
[Test]
public void MyTest()
{
void Helper()
{
Action a = async () => { await Task.Delay(1); }; // NOT flagged — could surprise devs
}
}The lambda walks up to Helper (a local function), IsTestOrHookMethod(Helper) returns false, and the diagnostic is suppressed. In practice this is uncommon and the test-writer probably intended the lambda there intentionally, so this is a low-priority gap. Worth a // Note: local functions are treated as a boundary... comment or a tracked follow-up rather than a blocker here.
Minor Note: IsHookMethod Out-Param Discard
private static bool IsTestOrHookMethod(IMethodSymbol methodSymbol, Compilation compilation)
{
return methodSymbol.HasTestAttribute(compilation)
|| methodSymbol.IsHookMethod(compilation, out _, out _, out _);
}Three discards is slightly awkward at the call site. The awkward signature is pre-existing, not introduced here, but if IsHookMethod ever gets a boolean-only overload this helper could simplify to a one-liner without the discards. Not a blocker.
Summary
The fix is correct, the scope is appropriate, tests are thorough, and the control-flow refactor is an improvement. The local-function edge case is noted but doesn't warrant holding this up.
…ope walk PR #6244 review: GetEnclosingMethod stopped the walk at the first non-AnonymousFunction symbol, so a local function inside a test method acted as a boundary — an `async void` lambda nested in that local function escaped the TUnit0031 diagnostic. Treat MethodKind.LocalFunction as transparent too, walking up to the real enclosing method. Adds a regression test for a lambda inside a local function inside a [Test].
There was a problem hiding this comment.
Code Review: fix(analyzers): scope TUnit0031 async-void rule to tests and hooks (#6190)
Overall: Previous review concern addressed. Correct, complete, and well-tested. Approved.
Previous Review Concern: Resolved ✓
The earlier review (from the first pass at this PR) flagged that GetEnclosingMethod would treat LocalFunction as an invisible boundary, letting async void lambdas inside local functions of test methods escape the diagnostic:
// Previous version — LocalFunction stopped the walk
if (method.MethodKind != MethodKind.AnonymousFunction)
{
return method;
}The updated implementation correctly includes LocalFunction in the "keep walking" condition:
// Current — both AnonymousFunction and LocalFunction are transparent
if (method.MethodKind is not (MethodKind.AnonymousFunction or MethodKind.LocalFunction))
{
return method;
}And the new Async_Void_Lambda_In_Local_Function_In_Test_Raises_Error test case proves exactly this scenario is now flagged. Good catch and good fix.
Correctness Analysis
GetEnclosingMethod walk logic — The walk correctly handles all nesting levels. Starting from the lambda symbol (AnonymousFunction), it steps through any enclosing local functions and lambdas, stopping only at a real method (constructor, regular method, operator, etc.). If it reaches a containing symbol that isn't an IMethodSymbol (e.g., a class), the while loop exits and returns null, which is correctly treated as "not in a test" and suppresses the diagnostic. ✓
HasTestAttribute uses BaseTestAttribute inheritance — The extension checks the full inheritance chain (GetSelfAndBaseTypes()), so custom test attributes deriving from BaseTestAttribute (e.g., [DynamicTestBuilder]) are correctly covered without any extra wiring. ✓
IsHookMethod covers both standard and "every" hook variants — The implementation delegates to IsStandardHookMethod and IsEveryHookMethod, so all TUnit hook attribute families are included. ✓
Test Coverage: Comprehensive
| Scenario | Expected | Covered |
|---|---|---|
async void regular method (no attributes) |
No diagnostic | Async_Void_NonTest_Method_Raises_No_Error ✓ |
async void lambda in non-test method |
No diagnostic | Async_Void_EventHandler_Lambda_In_NonTest_Method_Raises_No_Error ✓ |
async void lambda inside local function inside [Test] |
Diagnostic | Async_Void_Lambda_In_Local_Function_In_Test_Raises_Error ✓ |
async void hook method |
Diagnostic | Async_Void_Hook_Raises_Error ✓ |
async void [Test] method |
Diagnostic | Covered by existing tests ✓ |
async void lambda inside [Test] method |
Diagnostic | Covered by existing tests ✓ |
No gaps.
Negligible Notes (not blockers)
- The BOM removal on the first line is a harmless cleanup.
- The
IsHookMethod(compilation, out _, out _, out _)triple-discard remains slightly awkward but is pre-existing API design, not introduced here. A boolean-only overload ofIsHookMethodwould be a nice follow-up if that surface ever gets refactored.
The fix correctly narrows TUnit0031's scope from "all async void in the compilation" to "async void in TUnit test methods and hooks", which is exactly what was intended. Implementation, tests, and edge-case handling are all solid.
Problem
Discussion #6190:
TUnit0031(AsyncVoidAnalyzer) flags everyasync voidmethod and lambda in any project that references TUnit — with no test/hook scoping. Because the rule's severity is Error (Rules.cs), legitimate async void code becomes a build break.Reported case: a
System.Timers.Timer.Elapsedevent handler (which requires a void signature) madeasync→ flagged as an error, despite having nothing to do with testing. The thread's "fix" (move tests to a separate project) is only a workaround — the rule ships transitively, so any TUnit-referencing project keeps hitting it.Fix
Scope the analyzer to its intended target:
async voidwhen the method carries a TUnit test attribute (HasTestAttribute, covers[Test]/[DynamicTestBuilder]) or a hook attribute (IsHookMethod, Before/After).async voidlambdas declared inside such a method (walks out of nested lambdas viaContainingSymbol).Event handlers and other async void code elsewhere in a test project are left alone.
Tests
Existing tests still pass (async void test method / lambda inside
[Test]still error). Added:async voidevent-handler method → no diagnosticasync voidevent-handler lambda in a non-test method → no diagnosticasync voidhook ([Before(HookType.Test)]) → still errorsAll 10
AsyncVoidAnalyzerTestspass (net10.0).