Skip to content

Generic class type arguments that violate constraints are dropped in source-gen and fail the whole assembly scan in reflection mode #7107

Closed

Description

@thomhurst

Summary

#7102 (for #7097) makes source-gen report a failed test when a method type argument inferred from a method-level data attribute breaks the method's constraints, as reflection mode already does. These cases are still not covered:

  1. A class type argument inferred from a class-level row breaks the class's constraints. Source-gen drops the row without any message. Reflection mode fails discovery of the whole assembly: one [ASSEMBLY SCAN FAILED] <assembly> test, and every source-defined test in the assembly disappears.
  2. A class-level [GenerateGenericTest] type breaks the class's constraints. Same as 1: source-gen drops it, and reflection fails the whole assembly scan.
  3. A method-level row on a generic class infers a class type argument that breaks the constraints. Source-gen drops the row. Reflection runs no rows (see the generic-class rows issue).
  4. A method-level [GenerateGenericTest] type breaks the method's constraints. Source-gen drops it. Reflection reports a failed [GENERIC METHOD RESOLUTION FAILED] test.
  5. IInfersType<T> ([Matrix<T>]) infers a type that breaks the method's constraints. Source-gen drops the test. Reflection reports one failed test per matrix value.

None of these produce a compile-time diagnostic.

Confirmed on current main (b1cceb3), net10.0. The #7102 description says it leaves class type arguments, [GenerateGenericTest] and IInfersType<T> unchanged. I did not run its branch.

Repro

Each case was built on its own, in tests/TUnit.TestProject, because case 1 and case 2 break reflection discovery for everything else.

namespace TUnit.TestProject.FollowupRepro7103;

// Case 1: class-level rows; "x" breaks `where T : struct`
[Arguments(1)]
[Arguments("x")]
public class FxClassConstraintCtor<T>(T value) where T : struct
{
    [Test]
    public async Task Run() => await Assert.That(value).IsNotEqualTo(default(T));
}

// Case 1b: the only class row is invalid
[Arguments("x")]
public class FxClassConstraintOnlyInvalid<T>(T value) where T : struct
{
    [Test]
    public async Task Run() => await Assert.That(value).IsNotEqualTo(default(T));
}

// Case 2: class-level [GenerateGenericTest] with an invalid type
[GenerateGenericTest(typeof(int))]
[GenerateGenericTest(typeof(string))]
public class FxClassConstraintGenerateGeneric<T> where T : struct
{
    [Test]
    public async Task Run() => await Assert.That(typeof(T).IsValueType).IsTrue();
}

// Case 3: a method row infers the class type argument
public class FxClassConstraintMethodRow<T> where T : struct
{
    [Test]
    [Arguments(1)]
    [Arguments("x")]
    public async Task M(T value) => await Assert.That(value).IsNotEqualTo(default(T));
}

public class FxMethodConstraintOther
{
    // Case 4: method-level [GenerateGenericTest] with an invalid type
    [Test]
    [GenerateGenericTest(typeof(int))]
    [GenerateGenericTest(typeof(string))]
    public async Task GenerateGeneric<T>() where T : struct
        => await Assert.That(typeof(T).IsValueType).IsTrue();
}

public class FxMethodConstraintMatrix
{
    // Case 5: IInfersType<T> infers an invalid type
    [Test]
    [MatrixDataSource]
    public async Task Matrix<T>([Matrix<string>("a", "b")] T value) where T : struct
        => await Assert.That(value).IsNotEqualTo(default(T));

    // Control: valid IInfersType<T>
    [Test]
    [MatrixDataSource]
    public async Task MatrixValid<T>([Matrix<int>(1, 2)] T value) where T : struct
        => await Assert.That(value).IsNotEqualTo(default(T));
}

Actual

Case Source-gen Reflection (--reflection)
1 FxClassConstraintCtor 1 passed (<Int32>); the "x" row is missing Assembly scan failed. --list-tests finds 49 tests (dynamic tests only) instead of about 18,660
1b FxClassConstraintOnlyInvalid 0 tests, no message Assembly scan failed
2 FxClassConstraintGenerateGeneric 1 passed (<Int32>); string is missing Assembly scan failed
3 FxClassConstraintMethodRow 1 passed M(1); the "x" row is missing 0 tests (method rows on generic classes never run in reflection)
4 GenerateGeneric 1 passed GenerateGeneric<int>; string is missing passed GenerateGeneric<Int32>, plus a failed [GENERIC METHOD RESOLUTION FAILED] ...FxMethodConstraintOther.GenerateGeneric: [GenerateGenericTest] constraint violation on method 'GenerateGeneric': GenericArguments[0], 'System.String', ... violates the constraint
5 Matrix 0 tests, no message 2 × failed Matrix: GenericTypeResolutionException: Type 'String' does not satisfy the 'struct' constraint for generic parameter 'T'
5 MatrixValid (control) 2 passed 2 passed

Reflection error for case 1:

failed [ASSEMBLY SCAN FAILED] TUnit.TestProject
  InvalidOperationException: Failed to create concrete type for TUnit.TestProject.FollowupRepro7103.FxClassConstraintCtor`1.
  Error: GenericArguments[0], 'System.String', on '...FxClassConstraintCtor`1[T]' violates the constraint of type 'T'.. Generic parameter count: 1, Type arguments provided: 1, Data row length: 1
    at ReflectionTestDataCollector.DiscoverGenericTests(Type genericTypeDefinition) ReflectionTestDataCollector.cs:764
    at ReflectionTestDataCollector.DiscoverTestsInAssembly(Assembly assembly) ReflectionTestDataCollector.cs:429
    at ReflectionTestDataCollector.ProcessAssemblyAsync(Assembly assembly, SemaphoreSlim semaphore) ReflectionTestDataCollector.cs:72
  ---> ArgumentException at ReflectionGenericTypeResolver.CreateConcreteType ReflectionGenericTypeResolver.cs:187

Case 2 gives the same failure, with ... from [GenerateGenericTest] in the message.

Expected

Following the reasoning in #7097 and #7102: every row or type whose inferred type arguments break a constraint shows up as one failed test with the same constraint message in both modes, for example Type 'String' does not satisfy the 'struct' constraint for generic parameter 'T'. Valid rows and types in the same class keep running. A bad row must never take down discovery of the other tests in the assembly.

Suspected cause / file pointers

Reflection:

  • src/TUnit.Engine/Discovery/ReflectionTestDataCollector.cs, DiscoverGenericTests (line 629): the catch blocks at line 691 (class [GenerateGenericTest]) and line 762 (class data rows) rethrow as InvalidOperationException. The exception escapes DiscoverTestsInAssembly and is caught in ProcessAssemblyAsync (line 74), which replaces the whole assembly with CreateFailedTestMetadataForAssembly (AssemblyScanFailed, line 1397). The streaming twin has the same pattern at lines 847 and 953. These should yield a failed TestMetadata for that row or type only. A GenericTypeCreationFailed failed-metadata helper already exists (lines 859 and 966).
  • ReflectionGenericTypeResolver.CreateConcreteType (src/TUnit.Engine/Discovery/ReflectionGenericTypeResolver.cs:187) calls MakeGenericType with no up-front constraint check. A check with TestGenericTypeResolver.ValidateAgainstConstraints wording would make the message match.
  • Case 4 already gives a failed test, but its message is the raw ArgumentException text, not the GenericTypeResolutionException wording.

Source-gen:

  • src/TUnit.Core.SourceGenerator/Generators/TestMetadataGenerator.cs, TryAddInstantiation (line 666): ValidateClassTypeConstraints fails → return at line 680 for class args. The ValidateTypeConstraints failure for [GenerateGenericTest] (line 926+) and IInfersType<T> (InferTypesFromTypeInferringAttributes, line 856 → TryAddInstantiation with no specific attribute) also returns without emitting anything. fix(engine): substitute class type arguments and array element types in build-time constraint checks #7102 adds MethodConstraintViolation and GenericConstraintViolationDataSource for method-level data attributes only. For class args, a TestEntry<TClosed> cannot be emitted for a type that breaks the class's own constraints, so a failed entry needs a non-closed shape. One option is to register it against the open type, or as a dedicated failed-test registration, with the message baked in.
  • If every inferred instantiation is invalid, concreteInstantiations.Count == 0 (around line 499) emits nothing at all, which is case 1b and case 5.

Related: #7094, #7100, #7102, #7097

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