Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: add SkipIfEmpty property to data source attributes to control t…
…est execution behavior
  • Loading branch information
thomhurst committed Oct 25, 2025
commit c653d7ce95bf1a05cc2d577af9434b2c31484dfe
6 changes: 6 additions & 0 deletions TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR

public string? Skip { get; set; }

/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public ArgumentsAttribute(params object?[]? values)
{
if (values == null || values.Length == 0)
Expand Down Expand Up @@ -73,6 +76,9 @@ public sealed class ArgumentsAttribute<T>(T value) : TypedDataSourceAttribute<T>
{
public string? Skip { get; set; }

/// <inheritdoc />
public override bool SkipIfEmpty { get; set; }

public override async IAsyncEnumerable<Func<Task<T>>> GetTypedDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
yield return () => Task.FromResult(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ namespace TUnit.Core;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = true)]
public abstract class AsyncUntypedDataSourceGeneratorAttribute : Attribute, IAsyncUntypedDataSourceGeneratorAttribute
{
/// <inheritdoc />
public virtual bool SkipIfEmpty { get; set; }

protected abstract IAsyncEnumerable<Func<Task<object?[]?>>> GenerateDataSourcesAsync(DataGeneratorMetadata dataGeneratorMetadata);

public async IAsyncEnumerable<Func<Task<object?[]?>>> GenerateAsync(DataGeneratorMetadata dataGeneratorMetadata)
Expand Down
7 changes: 5 additions & 2 deletions TUnit.Core/Attributes/TestData/DelegateDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ internal sealed class DelegateDataSourceAttribute : Attribute, IDataSourceAttrib
private readonly Func<DataGeneratorMetadata, IAsyncEnumerable<object?[]>> _factory;
private readonly bool _isShared;
private List<Func<Task<object?[]?>>>? _cachedFactories;


/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public DelegateDataSourceAttribute(Func<DataGeneratorMetadata, IAsyncEnumerable<object?[]>> factory, bool isShared = false)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_isShared = isShared;
}

public async IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
if (_isShared && _cachedFactories != null)
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Core/Attributes/TestData/EmptyDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ namespace TUnit.Core;
/// </summary>
internal sealed class EmptyDataSourceAttribute : Attribute, IDataSourceAttribute
{
/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public async IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
yield return () => Task.FromResult<object?[]?>([
Expand Down
5 changes: 5 additions & 0 deletions TUnit.Core/Attributes/TestData/IDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,9 @@
public interface IDataSourceAttribute
{
public IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata);

/// <summary>
/// When true, if the data source returns no data, the test will be skipped instead of failing.
/// </summary>
bool SkipIfEmpty { get; set; }
}
12 changes: 9 additions & 3 deletions TUnit.Core/Attributes/TestData/MethodDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public class MethodDataSourceAttribute : Attribute, IDataSourceAttribute

public object?[] Arguments { get; set; } = [];

/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public MethodDataSourceAttribute(string methodNameProvidingDataSource)
{
if (methodNameProvidingDataSource is null or { Length: < 1 })
Expand Down Expand Up @@ -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<object?[]?>([]);
}
Expand All @@ -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<object?[]?>([]);
}
Expand All @@ -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<object?[]?>([]);
}
Expand Down
5 changes: 4 additions & 1 deletion TUnit.Core/Attributes/TestData/NoDataSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
internal class NoDataSource : IDataSourceAttribute
{
public static readonly NoDataSource Instance = new();


/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public async IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
yield return () => Task.FromResult<object?[]?>([]);
Expand Down
7 changes: 5 additions & 2 deletions TUnit.Core/Attributes/TestData/StaticDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ namespace TUnit.Core;
internal sealed class StaticDataSourceAttribute : Attribute, IDataSourceAttribute
{
private readonly object?[][] _data;


/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public StaticDataSourceAttribute(params object?[][] data)
{
_data = data ?? throw new ArgumentNullException(nameof(data));
}

public async IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
foreach (var row in _data)
Expand Down
5 changes: 4 additions & 1 deletion TUnit.Core/Attributes/TestData/TypedDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ namespace TUnit.Core;

public abstract class TypedDataSourceAttribute<T> : Attribute, ITypedDataSourceAttribute<T>
{
/// <inheritdoc />
public virtual bool SkipIfEmpty { get; set; }

public abstract IAsyncEnumerable<Func<Task<T>>> GetTypedDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata);

public async IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
// This method provides compatibility with the IDataSourceAttribute interface.
Expand Down
98 changes: 98 additions & 0 deletions TUnit.Engine/Building/TestBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
classDataAttributeIndex++;

var classDataLoopIndex = 0;
var hasAnyClassData = false;
await foreach (var classDataFactory in GetInitializedDataRowsAsync(
classDataSource,
DataGeneratorMetadataCreator.CreateDataGeneratorMetadata
Expand All @@ -178,6 +179,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
contextAccessor
)))
{
hasAnyClassData = true;
classDataLoopIndex++;

var classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []);
Expand Down Expand Up @@ -238,6 +240,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
methodDataAttributeIndex++;

var methodDataLoopIndex = 0;
var hasAnyMethodData = false;
await foreach (var methodDataFactory in GetInitializedDataRowsAsync(
methodDataSource,
DataGeneratorMetadataCreator.CreateDataGeneratorMetadata
Expand All @@ -250,6 +253,7 @@ public async Task<IEnumerable<AbstractExecutableTest>> BuildTestsFromMetadataAsy
contextAccessor
)))
{
hasAnyMethodData = true;
methodDataLoopIndex++;

for (var i = 0; i < repeatCount + 1; i++)
Expand Down Expand Up @@ -387,8 +391,102 @@ public async Task<IEnumerable<AbstractExecutableTest>> 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<object>(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<string, object?>(),
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<object>(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<string, object?>(),
ClassConstructor = testBuilderContext.ClassConstructor,
DataSourceAttribute = classDataSource,
InitializedAttributes = attributes
};

var test = await BuildTestAsync(metadata, testData, testSpecificContext);
test.Context.SkipReason = skipReason;
tests.Add(test);
}
}
}
catch (Exception ex)
Expand Down
3 changes: 3 additions & 0 deletions TUnit.Engine/EmptyDataSourceAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ namespace TUnit.Engine;

internal class EmptyDataSourceAttribute : IDataSourceAttribute
{
/// <inheritdoc />
public bool SkipIfEmpty { get; set; }

public async IAsyncEnumerable<Func<Task<object?[]?>>> GetDataRowsAsync(DataGeneratorMetadata dataGeneratorMetadata)
{
yield return () => Task.FromResult<object?[]?>([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<GetDataRowsAsync>d__8))]
[.(typeof(.ArgumentsAttribute.<GetDataRowsAsync>d__12))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
Expand All @@ -82,7 +83,8 @@ namespace
public ArgumentsAttribute(T value) { }
public int Order { get; }
public string? Skip { get; set; }
[.(typeof(.ArgumentsAttribute<T>.<GetTypedDataRowsAsync>d__6))]
public override bool SkipIfEmpty { get; set; }
[.(typeof(.ArgumentsAttribute<T>.<GetTypedDataRowsAsync>d__10))]
public override .<<.<T>>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
public . OnTestRegistered(.TestRegisteredContext context) { }
}
Expand Down Expand Up @@ -181,7 +183,8 @@ namespace
public abstract class AsyncUntypedDataSourceGeneratorAttribute : , .IDataSourceAttribute
{
protected AsyncUntypedDataSourceGeneratorAttribute() { }
[.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.<GenerateAsync>d__1))]
public virtual bool SkipIfEmpty { get; set; }
[.(typeof(.AsyncUntypedDataSourceGeneratorAttribute.<GenerateAsync>d__5))]
public .<<.<object?[]?>>> GenerateAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
protected abstract .<<.<object?[]?>>> GenerateDataSourcesAsync(.DataGeneratorMetadata dataGeneratorMetadata);
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
Expand Down Expand Up @@ -833,6 +836,7 @@ namespace
public interface IAccessesInstanceData { }
public interface IDataSourceAttribute
{
bool SkipIfEmpty { get; set; }
.<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata);
}
public interface IDynamicTestCreatorLocation
Expand Down Expand Up @@ -939,11 +943,12 @@ namespace
public ? ClassProvidingDataSource { get; }
public <.DataGeneratorMetadata, .<<.<object?[]?>>>>? 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.<GetDataRowsAsync>d__17))]
[.(typeof(.MethodDataSourceAttribute.<GetDataRowsAsync>d__21))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
}
[(.Class | .Method | .Property, AllowMultiple=true)]
Expand Down Expand Up @@ -1545,7 +1550,8 @@ namespace
public abstract class TypedDataSourceAttribute<T> : , .IDataSourceAttribute, .ITypedDataSourceAttribute<T>
{
protected TypedDataSourceAttribute() { }
[.(typeof(.TypedDataSourceAttribute<T>.<GetDataRowsAsync>d__1))]
public virtual bool SkipIfEmpty { get; set; }
[.(typeof(.TypedDataSourceAttribute<T>.<GetDataRowsAsync>d__5))]
public .<<.<object?[]?>>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { }
public abstract .<<.<T>>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata);
}
Expand Down
Loading
Loading