Description
Found while handling a review thread on #7143.
What happens
A test whose only data parameter is a single tuple, and which also declares a CancellationToken, does not run in reflection mode. The rows come from a [MethodDataSource] that returns IEnumerable<(int, int)>, for example:
public static IEnumerable<(int, int)> TupleParameterRows()
{
yield return (25, 26);
}
[Test]
[MethodDataSource(nameof(TupleParameterRows))]
public async Task Trailing<T>((T, T) pair, CancellationToken token) { }
[Test]
[MethodDataSource(nameof(TupleParameterRows))]
public async Task Leading<T>(CancellationToken token, (T, T) pair) { }
[Test]
[MethodDataSource(nameof(TupleParameterRows))]
public async Task NonGenericLeading(CancellationToken token, (int, int) pair) { }Results on the #7143 branch (fix/issue-7108-generic-mds-inference, net10.0):
| Test | Source-generated mode | Reflection mode |
|---|---|---|
Trailing<T> |
passes | GenericTypeResolutionException: Type mismatch: Cannot use argument of type 'Int32' for parameter of type 'ValueTuple2'` |
Leading<T> |
passes (after the #7143 fix) | same GenericTypeResolutionException |
NonGenericLeading |
InvalidCastException: Unable to cast object of type 'System.Int32' to type 'System.Threading.CancellationToken' |
ArgumentException: Object of type 'System.Int32' cannot be converted to type 'System.ValueTuple2[System.Int32,System.Int32]'` |
The same methods without the CancellationToken pass in both modes (Single_Tuple_Parameter_Is_Not_Spread and NonGeneric_Single_Tuple_Parameter_Is_Not_Spread in tests/TUnit.TestProject/Bugs/_7108/GenericMethodNonTupleMethodDataSourceTests.cs).
Where
- Generic methods in reflection mode:
TestGenericTypeResolver.ResolveMethodGenericArguments(src/TUnit.Engine/Services/TestGenericTypeResolver.cs, around line 420). The row arrives as its unwrapped elements (25, 26), and with a token present the resolver maps the first element to the tuple parameter(T, T)instead of rebuilding the tuple, so it throws at line 555. - Non-generic methods with a token before the tuple: both modes bind the row positionally and pass a row value to the token parameter.
- Analyzer TUnit0001 also reports the leading-token methods (
Attribute argument types 'int, int' don't match method parameter types 'CancellationToken, (T, T)'), becauseWithoutCancellationTokenParameter(src/TUnit.Analyzers/Extensions/ParameterExtensions.cs) only removes a trailing token. So a leading token is either unsupported, and the analyzer is right, or supported, and the analyzer and the non-generic path are wrong. The generator already puts a leading token back at its declared position for generic methods.
How to reproduce
Add the methods above to a test class in TUnit.TestProject (wrap the two leading-token methods in #pragma warning disable TUnit0001), build, and run with a --treenode-filter for that class, once with --reflection and once without.
Expected
A single tuple parameter receives the whole row in both modes, wherever the CancellationToken is declared, and TUnit0001 agrees with what the engine accepts.
Activity
fix(sourcegen): keep a leading CancellationToken before a single tupl…
thomhurst commentedon Oct 20, 2026
One more case of the analyzer gap: TUnit0001 also flags
[Arguments(1, null)]onRun<T>(T first, CancellationToken token, T second), becauseWithoutCancellationTokenParameterstrips only a trailing token. Both engine modes run that row (after #7198, asRun<object>). The fixtureBugs/_7155/NullAndValueTypeEdgeCaseTests.csin #7198 suppresses TUnit0001 for now.thomhurst commentedon Oct 20, 2026
A related case for generic classes, found while reviewing #7203. When class type arguments are inferred from a method
[Arguments]row, values are matched to parameters by declared index, and a leadingCancellationTokenis skipped without shifting that index. In both modes,class C<T> { [Test, Arguments(1)] public void Run(CancellationToken token, T value) {} }therefore infers nothing, and the row is dropped without any message.InferClassTypesFromMethodArgumentsinsrc/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs(themethodParam.Type.Name == "CancellationToken"continuekeepsargIndexaligned to the declared parameters).InferClassTypeArgumentsFromMethodRowinsrc/TUnit.Engine/Discovery/ReflectionTestDataCollector.csandInferClassTypeArgumentsinReflectionGenericConstraintViolations.cs(parameters[i]againstvalues[i]).Expected: data values are matched to the data-bound parameters with tokens filtered out first, as the generic-method inference path already does.
test: cover tokens interleaved with a spread row, and a generic tuple…
fix: keep trailing-array tuple elements and skip tokens in generic in…
fix: skip CancellationToken parameters in reflection MethodDataSource…
test: drop the reflection-mode skip now that #7173 is fixed
fix(sourcegen): infer generic method types from any MethodDataSource …
fix: bind a CancellationToken declared anywhere and keep a single tup…