Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
67 changes: 67 additions & 0 deletions TUnit.Analyzers.Tests/Vsthrd200AsyncSuffixSuppressorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#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("DynamicTestBuilder")]
[Arguments("Before(Test)")]
[Arguments("After(Test)")]
[Arguments("BeforeEvery(Test)")]
[Arguments("AfterEvery(Test)")]
[Arguments("Before(Class)")]
[Arguments("After(Class)")]
[Arguments("Before(Assembly)")]
[Arguments("After(Assembly)")]
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
62 changes: 62 additions & 0 deletions TUnit.Analyzers/Vsthrd200AsyncSuffixSuppressor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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(context.CancellationToken).FindNode(diagnostic.Location.SourceSpan) is not { } node)
{
continue;
}

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

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

// HasTestAttribute is inheritance-aware (covers [Test], [DynamicTestBuilder], etc.) and
// IsHookMethod covers all hook levels (Before/After/BeforeEvery/AfterEvery). This is
// deliberately broader than MarkMethodStaticSuppressor (CA1822), which uses the exact-match
// IsTestMethod and only narrows to Test-level hooks — so a [DynamicTestBuilder] method may
// still see CA1822. That divergence is intentional: VSTHRD200 (async naming) never applies
// to any test/hook method, whereas CA1822 (make-static) has narrower, scope-specific intent.
if (methodSymbol.HasTestAttribute(context.Compilation)
|| methodSymbol.IsHookMethod(context.Compilation, out _, out _, out _))
{
Suppress(context, diagnostic);
}
}
}

// Exactly one descriptor is registered (VSTHRD200), and Roslyn only routes diagnostics whose id
// matches it here, so referencing the single descriptor directly is correct and avoids a
// per-call predicate scan.
private static void Suppress(SuppressionAnalysisContext context, Diagnostic diagnostic)
=> context.ReportSuppression(Suppression.Create(Vsthrd200Descriptor, diagnostic));

private static readonly SuppressionDescriptor Vsthrd200Descriptor = CreateDescriptor("VSTHRD200");

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

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