diff --git a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs index e39d59f1bb..4d8ab15ef5 100644 --- a/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs +++ b/TUnit.Core/Attributes/TestData/CombinedDataSourcesAttribute.cs @@ -81,7 +81,7 @@ namespace TUnit.Core; /// /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] -public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGeneratorAttribute, IAccessesInstanceData +public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGeneratorAttribute { protected override async IAsyncEnumerable>> GenerateDataSourcesAsync(DataGeneratorMetadata dataGeneratorMetadata) { @@ -156,9 +156,12 @@ public sealed class CombinedDataSourcesAttribute : AsyncUntypedDataSourceGenerat if (dataSourceAttr is IAccessesInstanceData && dataGeneratorMetadata.TestClassInstance == null) { var className = dataGeneratorMetadata.TestInformation?.Class.Type.Name ?? "Unknown"; + var attrName = dataSourceAttr.GetType().Name; throw new InvalidOperationException( - $"Cannot use instance-based data source attribute on parameter '{parameterMetadata.Name}' when no instance is available. " + - $"Consider using static data sources or ensure the test class is properly instantiated."); + $"Cannot use instance-based data source '{attrName}' on parameter '{parameterMetadata.Name}' in class '{className}'. " + + $"When [CombinedDataSources] is applied at the class level (constructor parameters), all data sources must be static " + + $"because no instance exists yet. Use static [MethodDataSource] or [Arguments] instead, " + + $"or move [CombinedDataSources] to the method level if you need instance-based data sources."); } // Create metadata for this single parameter diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 33684f6146..bf78e79f93 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -371,7 +371,7 @@ namespace public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { } } [(.Class | .Method)] - public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute, .IAccessesInstanceData + public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute { public CombinedDataSourcesAttribute() { } [.(typeof(.CombinedDataSourcesAttribute.d__0))] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index d97c4f38b5..3f6bc693b2 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -371,7 +371,7 @@ namespace public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { } } [(.Class | .Method)] - public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute, .IAccessesInstanceData + public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute { public CombinedDataSourcesAttribute() { } [.(typeof(.CombinedDataSourcesAttribute.d__0))] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 5280c9595c..469c999319 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -371,7 +371,7 @@ namespace public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { } } [(.Class | .Method)] - public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute, .IAccessesInstanceData + public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute { public CombinedDataSourcesAttribute() { } [.(typeof(.CombinedDataSourcesAttribute.d__0))] diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 538bd3ca00..251fb9d851 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -351,7 +351,7 @@ namespace public static .ClassMetadata GetOrAdd(string name, <.ClassMetadata> factory) { } } [(.Class | .Method)] - public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute, .IAccessesInstanceData + public sealed class CombinedDataSourcesAttribute : .AsyncUntypedDataSourceGeneratorAttribute { public CombinedDataSourcesAttribute() { } [.(typeof(.CombinedDataSourcesAttribute.d__0))] diff --git a/TUnit.TestProject/Bugs/3990/ClassLevelCombinedDataSourcesTests.cs b/TUnit.TestProject/Bugs/3990/ClassLevelCombinedDataSourcesTests.cs new file mode 100644 index 0000000000..81bb4a7f5d --- /dev/null +++ b/TUnit.TestProject/Bugs/3990/ClassLevelCombinedDataSourcesTests.cs @@ -0,0 +1,153 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs._3990; + +/// +/// Tests for GitHub Issue #3990: Support CombinedDataSources on Class level +/// + +#region Test 1: Class-level CombinedDataSources with static Arguments + +[EngineTest(ExpectedResult.Pass)] +[CombinedDataSources] +public class ClassLevelCombinedDataSources_WithStaticArguments( + [Arguments(1, 2, 3)] int x, + [Arguments("a", "b")] string y) +{ + [Test] + public async Task Test_ShouldReceiveConstructorParameters() + { + // Should create 3 x 2 = 6 test cases + await Assert.That(x).IsIn([1, 2, 3]); + await Assert.That(y).IsIn(["a", "b"]); + } +} + +#endregion + +#region Test 2: Class-level CombinedDataSources with static MethodDataSource + +public class ClassLevelDataProviders +{ + public static IEnumerable GetNumbers() + { + yield return 10; + yield return 20; + } + + public static IEnumerable GetStrings() + { + yield return "Hello"; + yield return "World"; + } + + public static IEnumerable GetColors() + { + yield return "Red"; + yield return "Blue"; + yield return "Green"; + } +} + +[EngineTest(ExpectedResult.Pass)] +[CombinedDataSources] +public class ClassLevelCombinedDataSources_WithStaticMethodDataSource( + [MethodDataSource(nameof(ClassLevelDataProviders.GetNumbers))] int number, + [MethodDataSource(nameof(ClassLevelDataProviders.GetStrings))] string text) +{ + [Test] + public async Task Test_ShouldReceiveDataFromStaticMethods() + { + // Should create 2 x 2 = 4 test cases + await Assert.That(number).IsIn([10, 20]); + await Assert.That(text).IsIn(["Hello", "World"]); + } +} + +#endregion + +#region Test 3: Class-level CombinedDataSources mixed with method-level data sources + +[EngineTest(ExpectedResult.Pass)] +[CombinedDataSources] +public class ClassLevelCombinedDataSources_MixedWithMethodLevel( + [Arguments(1, 2)] int classArg, + [Arguments("A", "B")] string classText) +{ + [Test] + [CombinedDataSources] + public async Task Test_ShouldCombineClassAndMethodData( + [Arguments(100, 200)] int methodArg, + [Arguments("X", "Y")] string methodText) + { + // Class: 2 x 2 = 4 combinations + // Method: 2 x 2 = 4 combinations + // Total: 4 x 4 = 16 test cases + await Assert.That(classArg).IsIn([1, 2]); + await Assert.That(classText).IsIn(["A", "B"]); + await Assert.That(methodArg).IsIn([100, 200]); + await Assert.That(methodText).IsIn(["X", "Y"]); + } +} + +#endregion + +#region Test 4: Class-level CombinedDataSources with mixed data source types + +[EngineTest(ExpectedResult.Pass)] +[CombinedDataSources] +public class ClassLevelCombinedDataSources_MixedDataSourceTypes( + [Arguments(1, 2)] int number, + [MethodDataSource(nameof(ClassLevelDataProviders.GetColors))] string color) +{ + [Test] + public async Task Test_ShouldMixArgumentsAndMethodDataSource() + { + // Should create 2 x 3 = 6 test cases + await Assert.That(number).IsIn([1, 2]); + await Assert.That(color).IsIn(["Red", "Blue", "Green"]); + } +} + +#endregion + +#region Test 5: Class-level CombinedDataSources with three constructor parameters + +[EngineTest(ExpectedResult.Pass)] +[CombinedDataSources] +public class ClassLevelCombinedDataSources_ThreeParameters( + [Arguments(1, 2)] int x, + [Arguments("a", "b")] string y, + [Arguments(true, false)] bool z) +{ + [Test] + public async Task Test_ShouldHandleThreeParameters() + { + // Should create 2 x 2 x 2 = 8 test cases + await Assert.That(x).IsIn([1, 2]); + await Assert.That(y).IsIn(["a", "b"]); + await Assert.That(z).IsIn([true, false]); + } +} + +#endregion + +#region Edge Case: Instance data source at class level + +/// +/// Edge case documentation: When using CombinedDataSources at the class level with +/// an instance-requiring data source (like MethodDataSource pointing to an instance method), +/// the runtime check in CombinedDataSourcesAttribute.GetParameterValues() (lines 156-165) +/// will throw an InvalidOperationException with a clear, diagnostic error message: +/// +/// "Cannot use instance-based data source '{AttributeName}' on parameter '{name}' in class '{ClassName}'. +/// When [CombinedDataSources] is applied at the class level (constructor parameters), all data sources +/// must be static because no instance exists yet. Use static [MethodDataSource] or [Arguments] instead, +/// or move [CombinedDataSources] to the method level if you need instance-based data sources." +/// +/// This provides proper error handling with actionable guidance, replacing the confusing +/// "circular dependency" message that was previously shown due to the blanket IAccessesInstanceData check. +/// +public class EdgeCaseDocumentation; + +#endregion