Skip to content

Single tuple parameter plus a CancellationToken fails in reflection mode, and a leading token fails for non-generic methods #7173

Closed

Description

@thomhurst

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)'), because WithoutCancellationTokenParameter (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

  1. thomhurst commented on Oct 20, 2026

    @thomhurst
    OwnerAuthor

    One more case of the analyzer gap: TUnit0001 also flags [Arguments(1, null)] on Run<T>(T first, CancellationToken token, T second), because WithoutCancellationTokenParameter strips only a trailing token. Both engine modes run that row (after #7198, as Run<object>). The fixture Bugs/_7155/NullAndValueTypeEdgeCaseTests.cs in #7198 suppresses TUnit0001 for now.

  2. thomhurst commented on Oct 20, 2026

    @thomhurst
    OwnerAuthor

    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 leading CancellationToken is 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.

    • Generator: InferClassTypesFromMethodArguments in src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs (the methodParam.Type.Name == "CancellationToken" continue keeps argIndex aligned to the declared parameters).
    • Reflection: InferClassTypeArgumentsFromMethodRow in src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs and InferClassTypeArguments in ReflectionGenericConstraintViolations.cs (parameters[i] against values[i]).

    Expected: data values are matched to the data-bound parameters with tokens filtered out first, as the generic-method inference path already does.

  3. added a commit that references this issue on Oct 21, 2026
    36443f7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions