From c653d7ce95bf1a05cc2d577af9434b2c31484dfe Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 25 Oct 2025 20:16:21 +0100 Subject: [PATCH 1/2] feat: add SkipIfEmpty property to data source attributes to control test execution behavior --- .../Attributes/TestData/ArgumentsAttribute.cs | 6 ++ ...typedDataSourceSourceGeneratorAttribute.cs | 3 + .../TestData/DelegateDataSourceAttribute.cs | 7 +- .../TestData/EmptyDataSourceAttribute.cs | 3 + .../TestData/IDataSourceAttribute.cs | 5 + .../TestData/MethodDataSourceAttribute.cs | 12 ++- .../Attributes/TestData/NoDataSource.cs | 5 +- .../TestData/StaticDataSourceAttribute.cs | 7 +- .../TestData/TypedDataSourceAttribute.cs | 5 +- TUnit.Engine/Building/TestBuilder.cs | 98 +++++++++++++++++++ TUnit.Engine/EmptyDataSourceAttribute.cs | 3 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 16 ++- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 16 ++- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 16 ++- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 16 ++- TUnit.UnitTests/EmptyDataSourceTests.cs | 13 +++ 16 files changed, 202 insertions(+), 29 deletions(-) diff --git a/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs b/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs index 62f3be1d06..66bc973a79 100644 --- a/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs +++ b/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs @@ -36,6 +36,9 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR public string? Skip { get; set; } + /// + public bool SkipIfEmpty { get; set; } + public ArgumentsAttribute(params object?[]? values) { if (values == null || values.Length == 0) @@ -73,6 +76,9 @@ public sealed class ArgumentsAttribute(T value) : TypedDataSourceAttribute { public string? Skip { get; set; } + /// + public override bool SkipIfEmpty { get; set; } + public override async IAsyncEnumerable>> GetTypedDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { yield return () => Task.FromResult(value); diff --git a/TUnit.Core/Attributes/TestData/AsyncUntypedDataSourceSourceGeneratorAttribute.cs b/TUnit.Core/Attributes/TestData/AsyncUntypedDataSourceSourceGeneratorAttribute.cs index d2bf7ce4c8..033f74c33f 100644 --- a/TUnit.Core/Attributes/TestData/AsyncUntypedDataSourceSourceGeneratorAttribute.cs +++ b/TUnit.Core/Attributes/TestData/AsyncUntypedDataSourceSourceGeneratorAttribute.cs @@ -5,6 +5,9 @@ namespace TUnit.Core; [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = true)] public abstract class AsyncUntypedDataSourceGeneratorAttribute : Attribute, IAsyncUntypedDataSourceGeneratorAttribute { + /// + public virtual bool SkipIfEmpty { get; set; } + protected abstract IAsyncEnumerable>> GenerateDataSourcesAsync(DataGeneratorMetadata dataGeneratorMetadata); public async IAsyncEnumerable>> GenerateAsync(DataGeneratorMetadata dataGeneratorMetadata) diff --git a/TUnit.Core/Attributes/TestData/DelegateDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/DelegateDataSourceAttribute.cs index e38c290027..45a07b9795 100644 --- a/TUnit.Core/Attributes/TestData/DelegateDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/DelegateDataSourceAttribute.cs @@ -9,13 +9,16 @@ internal sealed class DelegateDataSourceAttribute : Attribute, IDataSourceAttrib private readonly Func> _factory; private readonly bool _isShared; private List>>? _cachedFactories; - + + /// + public bool SkipIfEmpty { get; set; } + public DelegateDataSourceAttribute(Func> factory, bool isShared = false) { _factory = factory ?? throw new ArgumentNullException(nameof(factory)); _isShared = isShared; } - + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { if (_isShared && _cachedFactories != null) diff --git a/TUnit.Core/Attributes/TestData/EmptyDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/EmptyDataSourceAttribute.cs index 9a1361503d..a80ea2f2d2 100644 --- a/TUnit.Core/Attributes/TestData/EmptyDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/EmptyDataSourceAttribute.cs @@ -6,6 +6,9 @@ namespace TUnit.Core; /// internal sealed class EmptyDataSourceAttribute : Attribute, IDataSourceAttribute { + /// + public bool SkipIfEmpty { get; set; } + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { yield return () => Task.FromResult([ diff --git a/TUnit.Core/Attributes/TestData/IDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/IDataSourceAttribute.cs index 364384905c..04835fe5c7 100644 --- a/TUnit.Core/Attributes/TestData/IDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/IDataSourceAttribute.cs @@ -3,4 +3,9 @@ public interface IDataSourceAttribute { public IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata); + + /// + /// When true, if the data source returns no data, the test will be skipped instead of failing. + /// + bool SkipIfEmpty { get; set; } } diff --git a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs index 6c41636817..625aa2fc0a 100644 --- a/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs @@ -28,6 +28,9 @@ public class MethodDataSourceAttribute : Attribute, IDataSourceAttribute public object?[] Arguments { get; set; } = []; + /// + public bool SkipIfEmpty { get; set; } + public MethodDataSourceAttribute(string methodNameProvidingDataSource) { if (methodNameProvidingDataSource is null or { Length: < 1 }) @@ -163,7 +166,8 @@ public MethodDataSourceAttribute( } // If the async enumerable was empty, yield one empty result like NoDataSource does - if (!hasAnyItems) + // unless SkipIfEmpty is true, in which case we yield nothing (test will be skipped) + if (!hasAnyItems && !SkipIfEmpty) { yield return () => Task.FromResult([]); } @@ -188,7 +192,8 @@ public MethodDataSourceAttribute( } // If the enumerable was empty, yield one empty result like NoDataSource does - if (!hasAnyItems) + // unless SkipIfEmpty is true, in which case we yield nothing (test will be skipped) + if (!hasAnyItems && !SkipIfEmpty) { yield return () => Task.FromResult([]); } @@ -215,7 +220,8 @@ public MethodDataSourceAttribute( } // If the enumerable was empty, yield one empty result like NoDataSource does - if (!hasAnyItems) + // unless SkipIfEmpty is true, in which case we yield nothing (test will be skipped) + if (!hasAnyItems && !SkipIfEmpty) { yield return () => Task.FromResult([]); } diff --git a/TUnit.Core/Attributes/TestData/NoDataSource.cs b/TUnit.Core/Attributes/TestData/NoDataSource.cs index 75780fbdcb..d61c2de0ff 100644 --- a/TUnit.Core/Attributes/TestData/NoDataSource.cs +++ b/TUnit.Core/Attributes/TestData/NoDataSource.cs @@ -3,7 +3,10 @@ internal class NoDataSource : IDataSourceAttribute { public static readonly NoDataSource Instance = new(); - + + /// + public bool SkipIfEmpty { get; set; } + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { yield return () => Task.FromResult([]); diff --git a/TUnit.Core/Attributes/TestData/StaticDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/StaticDataSourceAttribute.cs index 56c0f43462..d520ba51ce 100644 --- a/TUnit.Core/Attributes/TestData/StaticDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/StaticDataSourceAttribute.cs @@ -7,12 +7,15 @@ namespace TUnit.Core; internal sealed class StaticDataSourceAttribute : Attribute, IDataSourceAttribute { private readonly object?[][] _data; - + + /// + public bool SkipIfEmpty { get; set; } + public StaticDataSourceAttribute(params object?[][] data) { _data = data ?? throw new ArgumentNullException(nameof(data)); } - + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { foreach (var row in _data) diff --git a/TUnit.Core/Attributes/TestData/TypedDataSourceAttribute.cs b/TUnit.Core/Attributes/TestData/TypedDataSourceAttribute.cs index b9d3a01f44..9ebf5c96c7 100644 --- a/TUnit.Core/Attributes/TestData/TypedDataSourceAttribute.cs +++ b/TUnit.Core/Attributes/TestData/TypedDataSourceAttribute.cs @@ -4,8 +4,11 @@ namespace TUnit.Core; public abstract class TypedDataSourceAttribute : Attribute, ITypedDataSourceAttribute { + /// + public virtual bool SkipIfEmpty { get; set; } + public abstract IAsyncEnumerable>> GetTypedDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata); - + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { // This method provides compatibility with the IDataSourceAttribute interface. diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 37b567c5ce..133dff646a 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -166,6 +166,7 @@ public async Task> BuildTestsFromMetadataAsy classDataAttributeIndex++; var classDataLoopIndex = 0; + var hasAnyClassData = false; await foreach (var classDataFactory in GetInitializedDataRowsAsync( classDataSource, DataGeneratorMetadataCreator.CreateDataGeneratorMetadata @@ -178,6 +179,7 @@ public async Task> BuildTestsFromMetadataAsy contextAccessor ))) { + hasAnyClassData = true; classDataLoopIndex++; var classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); @@ -238,6 +240,7 @@ public async Task> BuildTestsFromMetadataAsy methodDataAttributeIndex++; var methodDataLoopIndex = 0; + var hasAnyMethodData = false; await foreach (var methodDataFactory in GetInitializedDataRowsAsync( methodDataSource, DataGeneratorMetadataCreator.CreateDataGeneratorMetadata @@ -250,6 +253,7 @@ public async Task> BuildTestsFromMetadataAsy contextAccessor ))) { + hasAnyMethodData = true; methodDataLoopIndex++; for (var i = 0; i < repeatCount + 1; i++) @@ -387,8 +391,102 @@ public async Task> BuildTestsFromMetadataAsy // Context already updated at the beginning of the loop before calling factories } } + + // If no data was yielded and SkipIfEmpty is true, create a skipped test + if (!hasAnyMethodData && methodDataSource.SkipIfEmpty) + { + const string skipReason = "Data source returned no data"; + + Type[] resolvedClassGenericArgs; + try + { + resolvedClassGenericArgs = metadata.TestClassType.IsGenericTypeDefinition + ? TryInferClassGenericsFromDataSources(metadata) + : Type.EmptyTypes; + } + catch + { + resolvedClassGenericArgs = Type.EmptyTypes; + } + + var testData = new TestData + { + TestClassInstanceFactory = () => Task.FromResult(SkippedTestInstance.Instance), + ClassDataSourceAttributeIndex = classDataAttributeIndex, + ClassDataLoopIndex = classDataLoopIndex, + ClassData = classData, + MethodDataSourceAttributeIndex = methodDataAttributeIndex, + MethodDataLoopIndex = 1, // Use 1 since we're creating a single skipped test + MethodData = [], + RepeatIndex = 0, + InheritanceDepth = metadata.InheritanceDepth, + ResolvedClassGenericArguments = resolvedClassGenericArgs, + ResolvedMethodGenericArguments = Type.EmptyTypes + }; + + var testSpecificContext = new TestBuilderContext + { + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary(), + ClassConstructor = testBuilderContext.ClassConstructor, + DataSourceAttribute = methodDataSource, + InitializedAttributes = attributes + }; + + var test = await BuildTestAsync(metadata, testData, testSpecificContext); + test.Context.SkipReason = skipReason; + tests.Add(test); + } } } + + // If no class data was yielded and SkipIfEmpty is true, create a skipped test + if (!hasAnyClassData && classDataSource.SkipIfEmpty) + { + const string skipReason = "Data source returned no data"; + + Type[] resolvedClassGenericArgs; + try + { + resolvedClassGenericArgs = metadata.TestClassType.IsGenericTypeDefinition + ? TryInferClassGenericsFromDataSources(metadata) + : Type.EmptyTypes; + } + catch + { + resolvedClassGenericArgs = Type.EmptyTypes; + } + + var testData = new TestData + { + TestClassInstanceFactory = () => Task.FromResult(SkippedTestInstance.Instance), + ClassDataSourceAttributeIndex = classDataAttributeIndex, + ClassDataLoopIndex = 1, // Use 1 since we're creating a single skipped test + ClassData = [], + MethodDataSourceAttributeIndex = 0, + MethodDataLoopIndex = 0, + MethodData = [], + RepeatIndex = 0, + InheritanceDepth = metadata.InheritanceDepth, + ResolvedClassGenericArguments = resolvedClassGenericArgs, + ResolvedMethodGenericArguments = Type.EmptyTypes + }; + + var testSpecificContext = new TestBuilderContext + { + TestMetadata = metadata.MethodMetadata, + Events = new TestContextEvents(), + ObjectBag = new Dictionary(), + ClassConstructor = testBuilderContext.ClassConstructor, + DataSourceAttribute = classDataSource, + InitializedAttributes = attributes + }; + + var test = await BuildTestAsync(metadata, testData, testSpecificContext); + test.Context.SkipReason = skipReason; + tests.Add(test); + } } } catch (Exception ex) diff --git a/TUnit.Engine/EmptyDataSourceAttribute.cs b/TUnit.Engine/EmptyDataSourceAttribute.cs index 2672025726..5978bea49c 100644 --- a/TUnit.Engine/EmptyDataSourceAttribute.cs +++ b/TUnit.Engine/EmptyDataSourceAttribute.cs @@ -4,6 +4,9 @@ namespace TUnit.Engine; internal class EmptyDataSourceAttribute : IDataSourceAttribute { + /// + public bool SkipIfEmpty { get; set; } + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { yield return () => Task.FromResult([]); 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 8186499f2e..d9df936d20 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 @@ -71,8 +71,9 @@ namespace public ArgumentsAttribute(params object?[]? values) { } public int Order { get; } public string? Skip { get; set; } + public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__8))] + [.(typeof(.ArgumentsAttribute.d__12))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -82,7 +83,8 @@ namespace public ArgumentsAttribute(T value) { } public int Order { get; } public string? Skip { get; set; } - [.(typeof(.ArgumentsAttribute.d__6))] + public override bool SkipIfEmpty { get; set; } + [.(typeof(.ArgumentsAttribute.d__10))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -181,7 +183,8 @@ namespace public abstract class AsyncUntypedDataSourceGeneratorAttribute : , .IDataSourceAttribute { protected AsyncUntypedDataSourceGeneratorAttribute() { } - [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__5))] public .<<.>> GenerateAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } protected abstract .<<.>> GenerateDataSourcesAsync(.DataGeneratorMetadata dataGeneratorMetadata); public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } @@ -833,6 +836,7 @@ namespace public interface IAccessesInstanceData { } public interface IDataSourceAttribute { + bool SkipIfEmpty { get; set; } .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } public interface IDynamicTestCreatorLocation @@ -939,11 +943,12 @@ namespace public ? ClassProvidingDataSource { get; } public <.DataGeneratorMetadata, .<<.>>>? Factory { get; set; } public string MethodNameProvidingDataSource { get; } + public bool SkipIfEmpty { get; set; } [.("Trimming", "IL2072", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] [.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] - [.(typeof(.MethodDataSourceAttribute.d__17))] + [.(typeof(.MethodDataSourceAttribute.d__21))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property, AllowMultiple=true)] @@ -1545,7 +1550,8 @@ namespace public abstract class TypedDataSourceAttribute : , .IDataSourceAttribute, .ITypedDataSourceAttribute { protected TypedDataSourceAttribute() { } - [.(typeof(.TypedDataSourceAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.TypedDataSourceAttribute.d__5))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public abstract .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } 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 0e54ceb230..52eca63f3f 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 @@ -71,8 +71,9 @@ namespace public ArgumentsAttribute(params object?[]? values) { } public int Order { get; } public string? Skip { get; set; } + public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__8))] + [.(typeof(.ArgumentsAttribute.d__12))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -82,7 +83,8 @@ namespace public ArgumentsAttribute(T value) { } public int Order { get; } public string? Skip { get; set; } - [.(typeof(.ArgumentsAttribute.d__6))] + public override bool SkipIfEmpty { get; set; } + [.(typeof(.ArgumentsAttribute.d__10))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -181,7 +183,8 @@ namespace public abstract class AsyncUntypedDataSourceGeneratorAttribute : , .IDataSourceAttribute { protected AsyncUntypedDataSourceGeneratorAttribute() { } - [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__5))] public .<<.>> GenerateAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } protected abstract .<<.>> GenerateDataSourcesAsync(.DataGeneratorMetadata dataGeneratorMetadata); public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } @@ -833,6 +836,7 @@ namespace public interface IAccessesInstanceData { } public interface IDataSourceAttribute { + bool SkipIfEmpty { get; set; } .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } public interface IDynamicTestCreatorLocation @@ -939,11 +943,12 @@ namespace public ? ClassProvidingDataSource { get; } public <.DataGeneratorMetadata, .<<.>>>? Factory { get; set; } public string MethodNameProvidingDataSource { get; } + public bool SkipIfEmpty { get; set; } [.("Trimming", "IL2072", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] [.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] - [.(typeof(.MethodDataSourceAttribute.d__17))] + [.(typeof(.MethodDataSourceAttribute.d__21))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property, AllowMultiple=true)] @@ -1545,7 +1550,8 @@ namespace public abstract class TypedDataSourceAttribute : , .IDataSourceAttribute, .ITypedDataSourceAttribute { protected TypedDataSourceAttribute() { } - [.(typeof(.TypedDataSourceAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.TypedDataSourceAttribute.d__5))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public abstract .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } 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 68477ecf97..c789b2a512 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 @@ -71,8 +71,9 @@ namespace public ArgumentsAttribute(params object?[]? values) { } public int Order { get; } public string? Skip { get; set; } + public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__8))] + [.(typeof(.ArgumentsAttribute.d__12))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -82,7 +83,8 @@ namespace public ArgumentsAttribute(T value) { } public int Order { get; } public string? Skip { get; set; } - [.(typeof(.ArgumentsAttribute.d__6))] + public override bool SkipIfEmpty { get; set; } + [.(typeof(.ArgumentsAttribute.d__10))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -181,7 +183,8 @@ namespace public abstract class AsyncUntypedDataSourceGeneratorAttribute : , .IDataSourceAttribute { protected AsyncUntypedDataSourceGeneratorAttribute() { } - [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__5))] public .<<.>> GenerateAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } protected abstract .<<.>> GenerateDataSourcesAsync(.DataGeneratorMetadata dataGeneratorMetadata); public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } @@ -833,6 +836,7 @@ namespace public interface IAccessesInstanceData { } public interface IDataSourceAttribute { + bool SkipIfEmpty { get; set; } .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } public interface IDynamicTestCreatorLocation @@ -939,11 +943,12 @@ namespace public ? ClassProvidingDataSource { get; } public <.DataGeneratorMetadata, .<<.>>>? Factory { get; set; } public string MethodNameProvidingDataSource { get; } + public bool SkipIfEmpty { get; set; } [.("Trimming", "IL2072", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] [.("Trimming", "IL2075", Justification="Method data sources require runtime discovery. AOT users should use Factory prope" + "rty.")] - [.(typeof(.MethodDataSourceAttribute.d__17))] + [.(typeof(.MethodDataSourceAttribute.d__21))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property, AllowMultiple=true)] @@ -1545,7 +1550,8 @@ namespace public abstract class TypedDataSourceAttribute : , .IDataSourceAttribute, .ITypedDataSourceAttribute { protected TypedDataSourceAttribute() { } - [.(typeof(.TypedDataSourceAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.TypedDataSourceAttribute.d__5))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public abstract .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } 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 401d09948e..12a3db5b92 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 @@ -71,8 +71,9 @@ namespace public ArgumentsAttribute(params object?[]? values) { } public int Order { get; } public string? Skip { get; set; } + public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__8))] + [.(typeof(.ArgumentsAttribute.d__12))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -82,7 +83,8 @@ namespace public ArgumentsAttribute(T value) { } public int Order { get; } public string? Skip { get; set; } - [.(typeof(.ArgumentsAttribute.d__6))] + public override bool SkipIfEmpty { get; set; } + [.(typeof(.ArgumentsAttribute.d__10))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -178,7 +180,8 @@ namespace public abstract class AsyncUntypedDataSourceGeneratorAttribute : , .IDataSourceAttribute { protected AsyncUntypedDataSourceGeneratorAttribute() { } - [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.d__5))] public .<<.>> GenerateAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } protected abstract .<<.>> GenerateDataSourcesAsync(.DataGeneratorMetadata dataGeneratorMetadata); public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } @@ -809,6 +812,7 @@ namespace public interface IAccessesInstanceData { } public interface IDataSourceAttribute { + bool SkipIfEmpty { get; set; } .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } public interface IDynamicTestCreatorLocation @@ -906,7 +910,8 @@ namespace public ? ClassProvidingDataSource { get; } public <.DataGeneratorMetadata, .<<.>>>? Factory { get; set; } public string MethodNameProvidingDataSource { get; } - [.(typeof(.MethodDataSourceAttribute.d__17))] + public bool SkipIfEmpty { get; set; } + [.(typeof(.MethodDataSourceAttribute.d__21))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } } [(.Class | .Method | .Property, AllowMultiple=true)] @@ -1497,7 +1502,8 @@ namespace public abstract class TypedDataSourceAttribute : , .IDataSourceAttribute, .ITypedDataSourceAttribute { protected TypedDataSourceAttribute() { } - [.(typeof(.TypedDataSourceAttribute.d__1))] + public virtual bool SkipIfEmpty { get; set; } + [.(typeof(.TypedDataSourceAttribute.d__5))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public abstract .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata); } diff --git a/TUnit.UnitTests/EmptyDataSourceTests.cs b/TUnit.UnitTests/EmptyDataSourceTests.cs index 6d3e336153..d92e982546 100644 --- a/TUnit.UnitTests/EmptyDataSourceTests.cs +++ b/TUnit.UnitTests/EmptyDataSourceTests.cs @@ -49,4 +49,17 @@ public async Task NonEmptyStaticDataSource_ShouldWork(string value) await Assert.That(value).IsNotNull(); await Assert.That(value).IsNotEmpty(); } + + /// + /// Tests that empty static data sources with SkipIfEmpty=true are skipped + /// + [Test] + [MethodDataSource(nameof(EmptyStaticDataSource), SkipIfEmpty = true)] + public async Task EmptyStaticDataSource_WithSkipIfEmpty_ShouldBeSkipped(string value) + { + // This test should be skipped because the data source is empty and SkipIfEmpty is true + // If it runs, that's a bug + await Assert.That(false).IsTrue(); // This should never execute + } + } \ No newline at end of file From de5cc7b190e684e3ecc534c5c14b7d35b4ce7e24 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 25 Oct 2025 22:48:48 +0100 Subject: [PATCH 2/2] feat: add SkipIfEmpty property to TupleDataSource for controlling data row execution --- TUnit.TestProject/ClassDataSourceTupleTests.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TUnit.TestProject/ClassDataSourceTupleTests.cs b/TUnit.TestProject/ClassDataSourceTupleTests.cs index 5b14954510..4986fc235e 100644 --- a/TUnit.TestProject/ClassDataSourceTupleTests.cs +++ b/TUnit.TestProject/ClassDataSourceTupleTests.cs @@ -3,6 +3,8 @@ namespace TUnit.TestProject; // Data source class that returns tuples for constructor parameters public class TupleDataSource : IDataSourceAttribute { + public bool SkipIfEmpty { get; set; } + public async IAsyncEnumerable>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata) { // Return tuples that should be unwrapped into constructor parameters