Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.SourceGenerators.Testing" Version="1.1.2" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.7.0" />
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
Expand Down
7 changes: 7 additions & 0 deletions TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@
Link="Shared\AnalyzerTestCompatibility.cs" />
</ItemGroup>

<!-- Reference the VSTHRD analyzer assembly (Analyzer="false") so its analyzer types are
usable in suppressor tests via WithAnalyzer<>, without running it against this project. -->
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" PrivateAssets="all" GeneratePathProperty="true" />
<Reference Include="$(PkgMicrosoft_VisualStudio_Threading_Analyzers)\analyzers\cs\Microsoft.VisualStudio.Threading.Analyzers.dll" Analyzer="false" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" VersionOverride="9.0.0" PrivateAssets="all" GeneratePathProperty="true" />
<Reference Include="$(PkgMicrosoft_CodeAnalysis_NetAnalyzers)\analyzers\dotnet\cs\Microsoft.CodeAnalysis.NetAnalyzers.dll" Analyzer="false" />
Expand Down
62 changes: 62 additions & 0 deletions TUnit.Analyzers.Tests/Vsthrd200AsyncSuffixSuppressorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#if NET8_0_OR_GREATER
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.VisualStudio.Threading.Analyzers;

namespace TUnit.Analyzers.Tests;

public class Vsthrd200AsyncSuffixSuppressorTests
{
private static readonly DiagnosticResult VSTHRD200 = new("VSTHRD200", DiagnosticSeverity.Warning);

[Test]
[Arguments("Test")]
[Arguments("Before(Test)")]
[Arguments("After(Test)")]
[Arguments("BeforeEvery(Test)")]
[Arguments("AfterEvery(Test)")]
public async Task WarningsOnTestAndHookMethodsAreSuppressed(string attribute) =>
await AnalyzerTestHelpers
.CreateSuppressorTest<Vsthrd200AsyncSuffixSuppressor>(
$$"""
using System.Threading.Tasks;
using TUnit.Core;
using static TUnit.Core.HookType;

public class MyTests
{
[{{attribute}}]
public async Task {|#0:Foo|}()
{
await Task.CompletedTask;
}
}
"""
)
.WithAnalyzer<VSTHRD200UseAsyncNamingConventionAnalyzer>()
.WithSpecificDiagnostics(VSTHRD200)
.WithExpectedDiagnosticsResults(VSTHRD200.WithLocation(0).WithIsSuppressed(true))
.RunAsync();

[Test]
public async Task WarningsAllowedElsewhere() =>
await AnalyzerTestHelpers
.CreateSuppressorTest<Vsthrd200AsyncSuffixSuppressor>(
"""
using System.Threading.Tasks;

public class MyTests
{
public async Task {|#0:DoSomething|}()
{
await Task.CompletedTask;
}
}
"""
)
.WithAnalyzer<VSTHRD200UseAsyncNamingConventionAnalyzer>()
.WithSpecificDiagnostics(VSTHRD200)
.WithExpectedDiagnosticsResults(VSTHRD200.WithLocation(0).WithIsSuppressed(false))
.RunAsync();
}
#endif
26 changes: 26 additions & 0 deletions TUnit.Analyzers/Extensions/MethodExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,38 @@ namespace TUnit.Analyzers.Extensions;

public static class MethodExtensions
{
/// <summary>
/// Returns true if the method is decorated with the exact <c>TUnit.Core.TestAttribute</c>.
/// </summary>
/// <remarks>
/// This is an exact-match check — it does not match subclasses of <c>BaseTestAttribute</c>
/// (e.g. <c>[DynamicTestBuilder]</c>). Use <see cref="HasTestAttribute"/> for the broader,
/// inheritance-aware check.
/// </remarks>
public static bool IsTestMethod(this IMethodSymbol methodSymbol, Compilation compilation)
{
var testAttribute = compilation.GetTypeByMetadataName("TUnit.Core.TestAttribute")!;
return methodSymbol.GetAttributes().Any(x => SymbolEqualityComparer.Default.Equals(x.AttributeClass, testAttribute));
}

/// <summary>
/// Returns true if the method is decorated with any attribute deriving from
/// <c>TUnit.Core.BaseTestAttribute</c> (e.g. <c>[Test]</c> or <c>[DynamicTestBuilder]</c>).
/// </summary>
public static bool HasTestAttribute(this IMethodSymbol methodSymbol, Compilation compilation)
{
var baseTestAttribute = compilation.GetTypeByMetadataName("TUnit.Core.BaseTestAttribute");

if (baseTestAttribute is null)
{
return false;
}

return methodSymbol.GetAttributes().Any(attribute =>
attribute.AttributeClass?.GetSelfAndBaseTypes()
.Contains(baseTestAttribute, SymbolEqualityComparer.Default) == true);
}

public static bool IsHookMethod(this IMethodSymbol methodSymbol, Compilation compilation, [NotNullWhen(true)] out INamedTypeSymbol? type, [NotNullWhen(true)] out HookLevel? hookLevel, [NotNullWhen(true)] out HookType? hookType)
{
return IsStandardHookMethod(methodSymbol, compilation, out type, out hookLevel, out hookType) || IsEveryHookMethod(methodSymbol, compilation, out type, out hookLevel, out hookType);
Expand Down
56 changes: 56 additions & 0 deletions TUnit.Analyzers/Vsthrd200AsyncSuffixSuppressor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using TUnit.Analyzers.Extensions;

namespace TUnit.Analyzers;

/// <summary>
/// Suppresses VSTHRD200 ("Use 'Async' suffix for async methods") on TUnit test
/// methods and hook methods, which intentionally do not follow the Async naming convention.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class Vsthrd200AsyncSuffixSuppressor : DiagnosticSuppressor
{
public override void ReportSuppressions(SuppressionAnalysisContext context)
{
foreach (var diagnostic in context.ReportedDiagnostics)
{
if (diagnostic.Location.SourceTree?.GetRoot().FindNode(diagnostic.Location.SourceSpan) is not { } node)
{
continue;
}

var semanticModel = context.GetSemanticModel(diagnostic.Location.SourceTree);

if (semanticModel.GetDeclaredSymbol(node) is not IMethodSymbol methodSymbol)
{
continue;
}

// IsHookMethod covers all hook levels (Before/After/BeforeEvery/AfterEvery) intentionally —
// no hook method requires the 'Async' suffix regardless of scope. (This is deliberately
// broader than MarkMethodStaticSuppressor, which only narrows CA1822 to Test-level hooks.)
if (methodSymbol.HasTestAttribute(context.Compilation)
|| methodSymbol.IsHookMethod(context.Compilation, out _, out _, out _))
{
Suppress(context, diagnostic);
}
}
}

// This suppressor only ever handles VSTHRD200, so reference the single descriptor directly
// rather than scanning SupportedSuppressions on every reported diagnostic.
private void Suppress(SuppressionAnalysisContext context, Diagnostic diagnostic)
=> context.ReportSuppression(Suppression.Create(SupportedSuppressions[0], diagnostic));

public override ImmutableArray<SuppressionDescriptor> SupportedSuppressions { get; } =
ImmutableArray.Create(CreateDescriptor("VSTHRD200"));

private static SuppressionDescriptor CreateDescriptor(string id)
=> new(
id: $"{id}Suppression",
suppressedDiagnosticId: id,
justification: "TUnit test and hook methods do not require the 'Async' suffix."
);
}
Loading