diff --git a/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs b/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs
index f50ffebce1..44a76d53c9 100644
--- a/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs
+++ b/TUnit.Analyzers.Tests/TimeoutCancellationTokenAnalyzerTests.cs
@@ -11,7 +11,7 @@ await Verifier.VerifyAnalyzerAsync(
"""
using TUnit.Core;
using System.Threading.Tasks;
-
+
public class TestClass
{
[Test]
@@ -35,7 +35,7 @@ await Verifier.VerifyAnalyzerAsync(
using TUnit.Core;
using System.Threading;
using System.Threading.Tasks;
-
+
public class TestClass
{
[Test]
@@ -56,7 +56,7 @@ await Verifier.VerifyAnalyzerAsync(
"""
using TUnit.Core;
using System.Threading.Tasks;
-
+
[Timeout(30_000)]
public class TestClass
{
@@ -80,7 +80,7 @@ await Verifier.VerifyAnalyzerAsync(
using TUnit.Core;
using System.Threading;
using System.Threading.Tasks;
-
+
[Timeout(30_000)]
public class TestClass
{
@@ -103,12 +103,12 @@ await Verifier.VerifyAnalyzerAsync(
using TUnit.Core;
using System.Threading;
using System.Threading.Tasks;
-
+
[Timeout(30_000)]
public class TestClass
{
private static HttpClient GetHttpClient() => new HttpClient();
-
+
[Test]
public async Task TestMethod(CancellationToken cancellationToken)
{
@@ -128,7 +128,7 @@ await Verifier.VerifyAnalyzerAsync(
using TUnit.Core;
using System.Threading;
using System.Threading.Tasks;
-
+
[Timeout(30_000)]
public class TestClass
{
@@ -136,7 +136,7 @@ internal void HelperMethod()
{
// Some helper logic
}
-
+
[Test]
public async Task TestMethod(CancellationToken cancellationToken)
{
@@ -156,7 +156,7 @@ await Verifier.VerifyAnalyzerAsync(
using TUnit.Core;
using System.Threading;
using System.Threading.Tasks;
-
+
[Timeout(30_000)]
public class TestClass
{
@@ -164,7 +164,7 @@ private async Task DoSomethingAsync()
{
await Task.Delay(100);
}
-
+
[Test]
public async Task TestMethod(CancellationToken cancellationToken)
{
@@ -183,7 +183,7 @@ await Verifier.VerifyAnalyzerAsync(
"""
using TUnit.Core;
using System.Threading.Tasks;
-
+
public class TestClass
{
// This shouldn't happen in practice as Timeout should only be on test/hook methods,
@@ -193,7 +193,7 @@ private async Task HelperMethodWithTimeout()
{
await Task.Delay(100);
}
-
+
[Test]
public async Task TestMethod()
{
@@ -203,4 +203,102 @@ public async Task TestMethod()
"""
);
}
+
+ [Test]
+ public async Task Test_Method_With_CancellationToken_Not_Last_Shows_Wrong_Order_Error()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public class TestClass
+ {
+ [Test]
+ [Arguments(1)]
+ [Timeout(30_000)]
+ public async Task {|#0:TestMethod|}(CancellationToken cancellationToken, int value)
+ {
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.CancellationTokenMustBeLastParameter)
+ .WithLocation(0)
+ );
+ }
+
+ [Test]
+ public async Task Test_Method_With_Data_And_CancellationToken_Last_Shows_No_Error()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public class TestClass
+ {
+ [Test]
+ [Arguments(1)]
+ [Timeout(30_000)]
+ public async Task TestMethod(int value, CancellationToken cancellationToken)
+ {
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+ """
+ );
+ }
+
+ [Test]
+ public async Task Test_Method_With_CancellationToken_First_Of_Multiple_Shows_Wrong_Order_Error()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public class TestClass
+ {
+ [Test]
+ [Arguments(1, "hello")]
+ [Timeout(30_000)]
+ public async Task {|#0:TestMethod|}(CancellationToken cancellationToken, int value, string text)
+ {
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.CancellationTokenMustBeLastParameter)
+ .WithLocation(0)
+ );
+ }
+
+ [Test]
+ public async Task Test_Method_With_CancellationToken_In_Middle_Shows_Wrong_Order_Error()
+ {
+ await Verifier.VerifyAnalyzerAsync(
+ """
+ using TUnit.Core;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public class TestClass
+ {
+ [Test]
+ [Arguments(1, "hello")]
+ [Timeout(30_000)]
+ public async Task {|#0:TestMethod|}(int value, CancellationToken cancellationToken, string text)
+ {
+ await Task.Delay(100, cancellationToken);
+ }
+ }
+ """,
+ Verifier.Diagnostic(Rules.CancellationTokenMustBeLastParameter)
+ .WithLocation(0)
+ );
+ }
}
\ No newline at end of file
diff --git a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
index b02d473125..f64b591235 100644
--- a/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
+++ b/TUnit.Analyzers/AnalyzerReleases.Unshipped.md
@@ -3,6 +3,7 @@
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
TUnit0061 | Usage | Error | ClassDataSource type requires parameterless constructor
+TUnit0062 | Usage | Warning | CancellationToken must be the last parameter
### Removed Rules
diff --git a/TUnit.Analyzers/Resources.resx b/TUnit.Analyzers/Resources.resx
index fff258bca8..7ae4425c17 100644
--- a/TUnit.Analyzers/Resources.resx
+++ b/TUnit.Analyzers/Resources.resx
@@ -498,6 +498,15 @@
Conflicting data source attributes
+
+ CancellationToken parameter must be the last parameter in the method signature. Move it to the end of the parameter list.
+
+
+ CancellationToken must be the last parameter
+
+
+ CancellationToken must be the last parameter
+
Generic types and methods may not be AOT-compatible when using dynamic type creation. Consider using concrete types or ensure all generic combinations are known at compile time.
diff --git a/TUnit.Analyzers/Rules.cs b/TUnit.Analyzers/Rules.cs
index d006c3d7a5..489ede5331 100644
--- a/TUnit.Analyzers/Rules.cs
+++ b/TUnit.Analyzers/Rules.cs
@@ -42,6 +42,9 @@ public static class Rules
public static readonly DiagnosticDescriptor MissingTimeoutCancellationTokenAttributes =
CreateDescriptor("TUnit0015", UsageCategory, DiagnosticSeverity.Warning);
+ public static readonly DiagnosticDescriptor CancellationTokenMustBeLastParameter =
+ CreateDescriptor("TUnit0062", UsageCategory, DiagnosticSeverity.Warning);
+
public static readonly DiagnosticDescriptor MethodMustNotBeStatic =
CreateDescriptor("TUnit0016", UsageCategory, DiagnosticSeverity.Error);
diff --git a/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs b/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs
index a23ee1e71b..78fc557e61 100644
--- a/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs
+++ b/TUnit.Analyzers/TimeoutCancellationTokenAnalyzer.cs
@@ -9,7 +9,9 @@ namespace TUnit.Analyzers;
public class TimeoutCancellationTokenAnalyzer : ConcurrentDiagnosticAnalyzer
{
public override ImmutableArray SupportedDiagnostics { get; } =
- ImmutableArray.Create(Rules.MissingTimeoutCancellationTokenAttributes);
+ ImmutableArray.Create(
+ Rules.MissingTimeoutCancellationTokenAttributes,
+ Rules.CancellationTokenMustBeLastParameter);
protected override void InitializeInternal(AnalysisContext context)
{
@@ -23,7 +25,7 @@ private void AnalyzeSymbol(SymbolAnalysisContext context)
return;
}
- if (!methodSymbol.IsTestMethod(context.Compilation) &&
+ if (!methodSymbol.IsTestMethod(context.Compilation) &&
!methodSymbol.IsHookMethod(context.Compilation, out _, out _, out _))
{
return;
@@ -51,15 +53,33 @@ private void AnalyzeSymbol(SymbolAnalysisContext context)
return;
}
- var lastParameter = parameters.Last();
+ var cancellationTokenType = context.Compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!);
- if (!SymbolEqualityComparer.Default.Equals(lastParameter.Type,
- context.Compilation.GetTypeByMetadataName(typeof(CancellationToken).FullName!)))
+ var cancellationTokenIndex = -1;
+ for (var i = 0; i < parameters.Length; i++)
{
+ if (SymbolEqualityComparer.Default.Equals(parameters[i].Type, cancellationTokenType))
+ {
+ cancellationTokenIndex = i;
+ break;
+ }
+ }
+
+ if (cancellationTokenIndex == -1)
+ {
+ // CancellationToken is not present at all
context.ReportDiagnostic(
Diagnostic.Create(Rules.MissingTimeoutCancellationTokenAttributes,
context.Symbol.Locations.FirstOrDefault())
);
}
+ else if (cancellationTokenIndex != parameters.Length - 1)
+ {
+ // CancellationToken exists but is not the last parameter
+ context.ReportDiagnostic(
+ Diagnostic.Create(Rules.CancellationTokenMustBeLastParameter,
+ context.Symbol.Locations.FirstOrDefault())
+ );
+ }
}
}