diff --git a/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs b/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs index a2b9a14444..974ebd8232 100644 --- a/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs +++ b/TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs @@ -36,6 +36,27 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR public string? Skip { get; set; } + /// + /// Gets or sets a custom display name for this test case. + /// Supports parameter substitution using $paramName or $arg1, $arg2, etc. + /// + /// + /// + /// [Arguments("admin", "secret", DisplayName = "Login as $arg1")] + /// + /// + public string? DisplayName { get; set; } + + /// + /// Gets or sets categories to apply to this specific test case. + /// + /// + /// + /// [Arguments("value", Categories = ["smoke", "integration"])] + /// + /// + public string[]? Categories { get; set; } + /// public bool SkipIfEmpty { get; set; } @@ -65,6 +86,22 @@ public ValueTask OnTestRegistered(TestRegisteredContext context) context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance; } + if (!string.IsNullOrEmpty(DisplayName)) + { + context.TestContext.SetDataSourceDisplayName(DisplayName!); + } + + if (Categories is { Length: > 0 }) + { + foreach (var category in Categories) + { + if (!string.IsNullOrWhiteSpace(category) && !context.TestDetails.Categories.Contains(category)) + { + context.TestDetails.Categories.Add(category); + } + } + } + return default; } @@ -76,6 +113,17 @@ public sealed class ArgumentsAttribute(T value) : TypedDataSourceAttribute { public string? Skip { get; set; } + /// + /// Gets or sets a custom display name for this test case. + /// Supports parameter substitution using $paramName or $arg1, $arg2, etc. + /// + public string? DisplayName { get; set; } + + /// + /// Gets or sets categories to apply to this specific test case. + /// + public string[]? Categories { get; set; } + /// public override bool SkipIfEmpty { get; set; } @@ -93,6 +141,22 @@ public ValueTask OnTestRegistered(TestRegisteredContext context) context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance; } + if (!string.IsNullOrEmpty(DisplayName)) + { + context.TestContext.SetDataSourceDisplayName(DisplayName!); + } + + if (Categories is { Length: > 0 }) + { + foreach (var category in Categories) + { + if (!string.IsNullOrWhiteSpace(category) && !context.TestDetails.Categories.Contains(category)) + { + context.TestDetails.Categories.Add(category); + } + } + } + return default; } diff --git a/TUnit.Core/Helpers/DisplayNameSubstitutor.cs b/TUnit.Core/Helpers/DisplayNameSubstitutor.cs new file mode 100644 index 0000000000..bd5d843f4c --- /dev/null +++ b/TUnit.Core/Helpers/DisplayNameSubstitutor.cs @@ -0,0 +1,59 @@ +namespace TUnit.Core.Helpers; + +/// +/// Utility for substituting parameter placeholders in display names. +/// Supports $paramName and $arg1, $arg2, etc. syntax. +/// +internal static class DisplayNameSubstitutor +{ + /// + /// Substitutes parameter placeholders with actual argument values. + /// + /// The display name template with placeholders. + /// The parameter metadata. + /// The actual argument values. + /// Optional custom formatters for argument values. + /// The display name with placeholders replaced by formatted argument values. + public static string Substitute( + string displayName, + ParameterMetadata[] parameters, + object?[] arguments, + List>? formatters = null) + { + if (string.IsNullOrEmpty(displayName) || !displayName.Contains('$')) + { + return displayName; + } + + var result = displayName; + var effectiveFormatters = formatters ?? []; + + // Substitute by parameter name ($paramName) + for (var i = 0; i < parameters.Length && i < arguments.Length; i++) + { + var paramName = parameters[i].Name; + if (!string.IsNullOrEmpty(paramName)) + { + var placeholder = $"${paramName}"; + if (result.Contains(placeholder)) + { + var formatted = ArgumentFormatter.Format(arguments[i], effectiveFormatters); + result = result.Replace(placeholder, formatted); + } + } + } + + // Substitute by position ($arg1, $arg2, etc.) + for (var i = 0; i < arguments.Length; i++) + { + var placeholder = $"$arg{i + 1}"; + if (result.Contains(placeholder)) + { + var formatted = ArgumentFormatter.Format(arguments[i], effectiveFormatters); + result = result.Replace(placeholder, formatted); + } + } + + return result; + } +} diff --git a/TUnit.Core/Helpers/TestDataRowUnwrapper.cs b/TUnit.Core/Helpers/TestDataRowUnwrapper.cs new file mode 100644 index 0000000000..780dda0a7f --- /dev/null +++ b/TUnit.Core/Helpers/TestDataRowUnwrapper.cs @@ -0,0 +1,90 @@ +using System.Diagnostics.CodeAnalysis; + +namespace TUnit.Core.Helpers; + +/// +/// Utility for detecting and unwrapping instances. +/// +internal static class TestDataRowUnwrapper +{ + private static readonly Type TestDataRowGenericType = typeof(TestDataRow<>); + + /// + /// Checks if the value is a and extracts metadata and data. + /// + /// The value to check. + /// The extracted data if unwrapped, otherwise the original value. + /// The extracted metadata if unwrapped, otherwise null. + /// True if the value was a TestDataRow and was unwrapped. + public static bool TryUnwrap(object? value, out object? data, [NotNullWhen(true)] out TestDataRowMetadata? metadata) + { + if (value is null) + { + data = null; + metadata = null; + return false; + } + + // Use interface-based access for AOT compatibility (avoids reflection) + if (value is ITestDataRow testDataRow) + { + data = testDataRow.GetData(); + metadata = new TestDataRowMetadata(testDataRow.DisplayName, testDataRow.Skip, testDataRow.Categories); + return true; + } + + data = value; + metadata = null; + return false; + } + + /// + /// Checks if a type is a . + /// + public static bool IsTestDataRowType(Type? type) + { + return type is not null && type.IsGenericType && type.GetGenericTypeDefinition() == TestDataRowGenericType; + } + + /// + /// Gets the inner data type from a type. + /// + public static Type? GetInnerDataType(Type testDataRowType) + { + if (!IsTestDataRowType(testDataRowType)) + { + return null; + } + + return testDataRowType.GetGenericArguments()[0]; + } + + /// + /// Unwraps an array of values, extracting TestDataRow metadata from single-element arrays. + /// + /// The array of values to unwrap. + /// A tuple of the unwrapped data array and any extracted metadata. + public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object?[] values) + { + if (values.Length == 1 && TryUnwrap(values[0], out var data, out var metadata)) + { + // Single TestDataRow - unwrap it + // If the inner data is already an array, use it directly + if (data is object?[] dataArray) + { + return (dataArray, metadata); + } + + // Check if the data is a tuple that should be expanded + if (DataSourceHelpers.IsTuple(data)) + { + return (data.ToObjectArray(), metadata); + } + + // Otherwise wrap the single value in an array + return ([data], metadata); + } + + return (values, null); + } +} diff --git a/TUnit.Core/TestContext.Metadata.cs b/TUnit.Core/TestContext.Metadata.cs index 065523cb9c..c1ff9b961c 100644 --- a/TUnit.Core/TestContext.Metadata.cs +++ b/TUnit.Core/TestContext.Metadata.cs @@ -7,7 +7,7 @@ public partial class TestContext { internal string GetDisplayName() { - if(!string.IsNullOrEmpty(CustomDisplayName)) + if (!string.IsNullOrEmpty(CustomDisplayName)) { return CustomDisplayName!; } @@ -17,6 +17,17 @@ internal string GetDisplayName() return _cachedDisplayName; } + // Check for data source display name (from TestDataRow or ArgumentsAttribute.DisplayName) + if (!string.IsNullOrEmpty(DataSourceDisplayName)) + { + _cachedDisplayName = DisplayNameSubstitutor.Substitute( + DataSourceDisplayName!, + TestDetails.MethodMetadata.Parameters, + TestDetails.TestMethodArguments, + ArgumentDisplayFormatters); + return _cachedDisplayName; + } + if (TestDetails.TestMethodArguments.Length == 0) { _cachedDisplayName = TestDetails.TestName; diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 6ef95724de..f3a2a87ac7 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -118,6 +118,21 @@ public static string WorkingDirectory internal string? CustomDisplayName { get; set; } + /// + /// Display name provided by the data source (from TestDataRow or ArgumentsAttribute.DisplayName). + /// This takes precedence over the default generated display name but can be overridden by CustomDisplayName. + /// Supports $paramName substitution. + /// + internal string? DataSourceDisplayName { get; private set; } + + /// + /// Sets the display name from the data source (TestDataRow or ArgumentsAttribute.DisplayName). + /// + internal void SetDataSourceDisplayName(string displayName) + { + DataSourceDisplayName = displayName; + } + internal TestDetails TestDetails { get; set; } = null!; diff --git a/TUnit.Core/TestDataCombination.cs b/TUnit.Core/TestDataCombination.cs index 2b9071eedc..f18ab1b767 100644 --- a/TUnit.Core/TestDataCombination.cs +++ b/TUnit.Core/TestDataCombination.cs @@ -35,6 +35,16 @@ public class TestDataCombination public string? DisplayName { get; init; } + /// + /// Skip reason from the data source. When set, this test combination will be skipped. + /// + public string? Skip { get; init; } + + /// + /// Categories from the data source to apply to this specific test combination. + /// + public string[]? Categories { get; init; } + public int RepeatIndex { get; init; } = 0; public Dictionary? ResolvedGenericTypes { get; init; } diff --git a/TUnit.Core/TestDataRow.cs b/TUnit.Core/TestDataRow.cs new file mode 100644 index 0000000000..3f542b147d --- /dev/null +++ b/TUnit.Core/TestDataRow.cs @@ -0,0 +1,50 @@ +namespace TUnit.Core; + +/// +/// Internal interface for accessing TestDataRow properties without reflection. +/// This enables AOT compatibility by avoiding dynamic property access. +/// +internal interface ITestDataRow +{ + object? GetData(); + string? DisplayName { get; } + string? Skip { get; } + string[]? Categories { get; } +} + +/// +/// Wraps test data with optional metadata for customizing test execution. +/// Use this when returning data from method/class data sources to specify +/// per-row display names, skip reasons, or categories. +/// +/// The type of the test data. +/// The actual test data to be passed to the test method. +/// +/// Optional custom display name for the test case. +/// Supports parameter substitution using $paramName or $arg1, $arg2, etc. +/// +/// +/// Optional skip reason. When set, the test case will be skipped with this message. +/// +/// +/// Optional categories to apply to this specific test case. +/// +/// +/// +/// public static IEnumerable<TestDataRow<(string Username, string Password)>> GetLoginData() +/// { +/// yield return new(("admin", "secret123"), DisplayName: "Admin login"); +/// yield return new(("guest", "guest"), DisplayName: "Guest login"); +/// yield return new(("", ""), DisplayName: "Empty credentials", Skip: "Not implemented yet"); +/// } +/// +/// +public record TestDataRow( + T Data, + string? DisplayName = null, + string? Skip = null, + string[]? Categories = null +) : ITestDataRow +{ + object? ITestDataRow.GetData() => Data; +} diff --git a/TUnit.Core/TestDataRowMetadata.cs b/TUnit.Core/TestDataRowMetadata.cs new file mode 100644 index 0000000000..99d49b121d --- /dev/null +++ b/TUnit.Core/TestDataRowMetadata.cs @@ -0,0 +1,36 @@ +namespace TUnit.Core; + +/// +/// Metadata extracted from a wrapper or data source attributes. +/// +/// Custom display name for the test case. +/// Skip reason - test will be skipped if set. +/// Categories to apply to the test case. +internal record TestDataRowMetadata( + string? DisplayName, + string? Skip, + string[]? Categories +) +{ + /// + /// Returns true if any metadata property is set. + /// + public bool HasMetadata => DisplayName is not null || Skip is not null || Categories is { Length: > 0 }; + + /// + /// Merges this metadata with another, preferring non-null values from this instance. + /// + public TestDataRowMetadata MergeWith(TestDataRowMetadata? other) + { + if (other is null) + { + return this; + } + + return new TestDataRowMetadata( + DisplayName ?? other.DisplayName, + Skip ?? other.Skip, + Categories ?? other.Categories + ); + } +} diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index e4d82110a9..5ebc233899 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -319,8 +319,16 @@ await _objectLifecycleService.RegisterObjectAsync( ClassConstructor = testBuilderContext.ClassConstructor // Preserve ClassConstructor for instance creation }; - classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); - var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); + var (classDataUnwrapped, classRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); + classData = classDataUnwrapped; + var (methodData, methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); + + // Extract and merge metadata from data source attributes and TestDataRow wrappers + var classAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(classDataSource); + var methodAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(methodDataSource); + var mergedClassMetadata = DataSourceMetadataExtractor.Merge(classRowMetadata, classAttrMetadata); + var mergedMethodMetadata = DataSourceMetadataExtractor.Merge(methodRowMetadata, methodAttrMetadata); + var finalMetadata = DataSourceMetadataExtractor.Merge(mergedMethodMetadata, mergedClassMetadata); // Initialize method data objects (ObjectInitializer is phase-aware) await InitializeClassDataAsync(methodData); @@ -431,7 +439,8 @@ await _objectLifecycleService.RegisterObjectAsync( RepeatIndex = i, InheritanceDepth = metadata.InheritanceDepth, ResolvedClassGenericArguments = resolvedClassGenericArgs, - ResolvedMethodGenericArguments = resolvedMethodGenericArgs + ResolvedMethodGenericArguments = resolvedMethodGenericArgs, + Metadata = finalMetadata }; var testSpecificContext = new TestBuilderContext @@ -862,6 +871,31 @@ public async Task BuildTestAsync(TestMetadata metadata, context.Metadata.TestDetails.ClassInstance = PlaceholderInstance.Instance; + // Apply metadata from TestDataRow or data source attributes + if (testData.Metadata is { } dataRowMetadata) + { + // Apply custom display name (will be processed by DisplayNameBuilder) + if (!string.IsNullOrEmpty(dataRowMetadata.DisplayName)) + { + context.SetDataSourceDisplayName(dataRowMetadata.DisplayName!); + } + + // Apply skip reason from data source + if (!string.IsNullOrEmpty(dataRowMetadata.Skip)) + { + context.SkipReason = dataRowMetadata.Skip; + } + + // Apply categories from data source + if (dataRowMetadata.Categories is { Length: > 0 }) + { + foreach (var category in dataRowMetadata.Categories) + { + context.Metadata.TestDetails.Categories.Add(category); + } + } + } + // Arguments will be tracked by TestArgumentTrackingService during TestRegistered event // This ensures proper reference counting for shared instances @@ -1338,6 +1372,12 @@ internal class TestData /// Will be Type.EmptyTypes if the method is not generic. /// public Type[] ResolvedMethodGenericArguments { get; set; } = Type.EmptyTypes; + + /// + /// Metadata extracted from TestDataRow wrappers or data source attributes. + /// Contains custom DisplayName, Skip reason, and Categories. + /// + public TestDataRowMetadata? Metadata { get; set; } } /// @@ -1510,7 +1550,8 @@ await _objectLifecycleService.RegisterObjectAsync( metadata, classDataFactory, methodDataFactory, classDataAttributeIndex, classDataLoopIndex, methodDataAttributeIndex, methodDataLoopIndex, - i, contextAccessor); + i, contextAccessor, + classDataSource, methodDataSource); if (test != null) { @@ -1580,13 +1621,21 @@ private Task CreateInstanceForMethodDataSources( int methodDataAttributeIndex, int methodDataLoopIndex, int repeatIndex, - TestBuilderContextAccessor contextAccessor) + TestBuilderContextAccessor contextAccessor, + IDataSourceAttribute? classDataSource = null, + IDataSourceAttribute? methodDataSource = null) { try { - var classData = DataUnwrapper.Unwrap(await classDataFactory() ?? []); + var (classData, classRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); + var (methodData, methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); - var methodData = DataUnwrapper.UnwrapWithTypes(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); + // Extract and merge metadata from data source attributes and TestDataRow wrappers + var classAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(classDataSource); + var methodAttrMetadata = DataSourceMetadataExtractor.ExtractFromAttribute(methodDataSource); + var mergedClassMetadata = DataSourceMetadataExtractor.Merge(classRowMetadata, classAttrMetadata); + var mergedMethodMetadata = DataSourceMetadataExtractor.Merge(methodRowMetadata, methodAttrMetadata); + var finalMetadata = DataSourceMetadataExtractor.Merge(mergedMethodMetadata, mergedClassMetadata); // Initialize method data objects (ObjectInitializer is phase-aware) await InitializeClassDataAsync(methodData); @@ -1679,7 +1728,8 @@ private Task CreateInstanceForMethodDataSources( RepeatIndex = repeatIndex, InheritanceDepth = metadata.InheritanceDepth, ResolvedClassGenericArguments = resolvedClassGenericArgs, - ResolvedMethodGenericArguments = resolvedMethodGenericArgs + ResolvedMethodGenericArguments = resolvedMethodGenericArgs, + Metadata = finalMetadata }; var testSpecificContext = new TestBuilderContext diff --git a/TUnit.Engine/Helpers/DataSourceMetadataExtractor.cs b/TUnit.Engine/Helpers/DataSourceMetadataExtractor.cs new file mode 100644 index 0000000000..fc24980431 --- /dev/null +++ b/TUnit.Engine/Helpers/DataSourceMetadataExtractor.cs @@ -0,0 +1,79 @@ +using System.Diagnostics.CodeAnalysis; +using TUnit.Core; + +namespace TUnit.Engine.Helpers; + +/// +/// Extracts metadata (DisplayName, Skip, Categories) from data source attributes. +/// +internal static class DataSourceMetadataExtractor +{ + /// + /// Extracts metadata from a data source attribute if it has the relevant properties. + /// + /// + /// Uses DynamicDependency to ensure the trimmer preserves public properties on known TUnit data source types. + /// Custom data source attributes need to ensure their DisplayName/Skip/Categories properties are preserved + /// if they want these features to work in trimmed/AOT scenarios. + /// + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ArgumentsAttribute))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, "TUnit.Core.ArgumentsAttribute`1", "TUnit.Core")] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(MethodDataSourceAttribute))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ClassDataSourceAttribute<>))] + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(MatrixAttribute))] + [UnconditionalSuppressMessage("Trimming", "IL2075:Reflection on unknown types", + Justification = "Known TUnit data source types are preserved via DynamicDependency. Custom data sources must preserve their own properties.")] + public static TestDataRowMetadata? ExtractFromAttribute(IDataSourceAttribute? dataSource) + { + if (dataSource is null) + { + return null; + } + + var type = dataSource.GetType(); + + // Try to get DisplayName property + var displayNameProp = type.GetProperty("DisplayName"); + var displayName = displayNameProp?.GetValue(dataSource) as string; + + // Try to get Skip property + var skipProp = type.GetProperty("Skip"); + var skip = skipProp?.GetValue(dataSource) as string; + + // Try to get Categories property + var categoriesProp = type.GetProperty("Categories"); + var categories = categoriesProp?.GetValue(dataSource) as string[]; + + if (displayName is null && skip is null && categories is null) + { + return null; + } + + return new TestDataRowMetadata(displayName, skip, categories); + } + + /// + /// Merges metadata from TestDataRow wrapper with metadata from the data source attribute. + /// TestDataRow metadata takes precedence over attribute metadata. + /// + public static TestDataRowMetadata? Merge(TestDataRowMetadata? rowMetadata, TestDataRowMetadata? attributeMetadata) + { + if (rowMetadata is null && attributeMetadata is null) + { + return null; + } + + if (rowMetadata is null) + { + return attributeMetadata; + } + + if (attributeMetadata is null) + { + return rowMetadata; + } + + // Row metadata takes precedence + return rowMetadata.MergeWith(attributeMetadata); + } +} diff --git a/TUnit.Engine/Helpers/DataUnwrapper.cs b/TUnit.Engine/Helpers/DataUnwrapper.cs index df89c4fc97..02a4d5dc19 100644 --- a/TUnit.Engine/Helpers/DataUnwrapper.cs +++ b/TUnit.Engine/Helpers/DataUnwrapper.cs @@ -6,48 +6,100 @@ namespace TUnit.Engine.Helpers; internal class DataUnwrapper { + /// + /// Unwraps values, handling tuples and TestDataRow wrappers. + /// public static object?[] Unwrap(object?[] values) { - if(values.Length == 1 && DataSourceHelpers.IsTuple(values[0])) + // First check for TestDataRow wrapper + var (unwrapped, _) = TestDataRowUnwrapper.UnwrapArray(values); + + // Then handle tuple unwrapping + if (unwrapped.Length == 1 && DataSourceHelpers.IsTuple(unwrapped[0])) { - return values[0].ToObjectArray(); + return unwrapped[0].ToObjectArray(); } - return values; + return unwrapped; + } + + /// + /// Unwraps values and extracts any TestDataRow metadata. + /// + public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapWithMetadata(object?[] values) + { + // First check for TestDataRow wrapper + var (unwrapped, metadata) = TestDataRowUnwrapper.UnwrapArray(values); + + // Then handle tuple unwrapping + if (unwrapped.Length == 1 && DataSourceHelpers.IsTuple(unwrapped[0])) + { + return (unwrapped[0].ToObjectArray(), metadata); + } + + return (unwrapped, metadata); + } + + /// + /// Unwraps values with type information and extracts any TestDataRow metadata. + /// + public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapWithTypesAndMetadata( + object?[] values, + ParameterMetadata[]? expectedParameters) + { + // First check for TestDataRow wrapper + var (unwrapped, metadata) = TestDataRowUnwrapper.UnwrapArray(values); + + // Then apply type-aware unwrapping + var data = UnwrapWithTypesInternal(unwrapped, expectedParameters); + return (data, metadata); } - + public static object?[] UnwrapWithTypes(object?[] values, ParameterMetadata[]? expectedParameters) + { + // First handle TestDataRow unwrapping + var (unwrapped, _) = TestDataRowUnwrapper.UnwrapArray(values); + return UnwrapWithTypesInternal(unwrapped, expectedParameters); + } + + private static object?[] UnwrapWithTypesInternal(object?[] values, ParameterMetadata[]? expectedParameters) { // If no parameter information, fall back to default behavior if (expectedParameters == null || expectedParameters.Length == 0) { - return Unwrap(values); + if (values.Length == 1 && DataSourceHelpers.IsTuple(values[0])) + { + return values[0].ToObjectArray(); + } + + return values; } - + // Special case: If we have a single value that's a tuple, and a single parameter that expects a tuple, // don't unwrap it - if (values.Length == 1 && - expectedParameters.Length == 1 && + if (values.Length == 1 && + expectedParameters.Length == 1 && DataSourceHelpers.IsTuple(values[0]) && IsTupleType(expectedParameters[0].Type)) { return values; } - + // Otherwise use the default unwrapping - if(values.Length == 1 && DataSourceHelpers.IsTuple(values[0])) + if (values.Length == 1 && DataSourceHelpers.IsTuple(values[0])) { var paramTypes = new Type[expectedParameters.Length]; for (var i = 0; i < expectedParameters.Length; i++) { paramTypes[i] = expectedParameters[i].Type; } + return values[0].ToObjectArrayWithTypes(paramTypes); } return values; } - + private static bool IsTupleType(Type type) { if (!type.IsGenericType) 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 693efbbaa0..95a071ab82 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,11 +71,13 @@ namespace public sealed class ArgumentsAttribute : , .IDataSourceAttribute, ., . { public ArgumentsAttribute(params object?[]? values) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__12))] + [.(typeof(.ArgumentsAttribute.d__20))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -83,10 +85,12 @@ namespace public sealed class ArgumentsAttribute : .TypedDataSourceAttribute, ., . { public ArgumentsAttribute(T value) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public override bool SkipIfEmpty { get; set; } - [.(typeof(.ArgumentsAttribute.d__10))] + [.(typeof(.ArgumentsAttribute.d__18))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -1348,6 +1352,7 @@ namespace public class TestDataCombination { public TestDataCombination() { } + public string[]? Categories { get; init; } public <.>[] ClassDataFactories { get; init; } public int ClassDataSourceIndex { get; init; } public int ClassLoopIndex { get; init; } @@ -1358,6 +1363,15 @@ namespace public int MethodLoopIndex { get; init; } public int RepeatIndex { get; init; } public .? ResolvedGenericTypes { get; init; } + public string? Skip { get; init; } + } + public class TestDataRow : <.TestDataRow> + { + public TestDataRow(T Data, string? DisplayName = null, string? Skip = null, string[]? Categories = null) { } + public string[]? Categories { get; init; } + public T Data { get; init; } + public string? DisplayName { get; init; } + public string? Skip { get; init; } } public class TestDefinition : .ITestDefinition { 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 3a5b603267..09b288a075 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,11 +71,13 @@ namespace public sealed class ArgumentsAttribute : , .IDataSourceAttribute, ., . { public ArgumentsAttribute(params object?[]? values) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__12))] + [.(typeof(.ArgumentsAttribute.d__20))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -83,10 +85,12 @@ namespace public sealed class ArgumentsAttribute : .TypedDataSourceAttribute, ., . { public ArgumentsAttribute(T value) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public override bool SkipIfEmpty { get; set; } - [.(typeof(.ArgumentsAttribute.d__10))] + [.(typeof(.ArgumentsAttribute.d__18))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -1348,6 +1352,7 @@ namespace public class TestDataCombination { public TestDataCombination() { } + public string[]? Categories { get; init; } public <.>[] ClassDataFactories { get; init; } public int ClassDataSourceIndex { get; init; } public int ClassLoopIndex { get; init; } @@ -1358,6 +1363,15 @@ namespace public int MethodLoopIndex { get; init; } public int RepeatIndex { get; init; } public .? ResolvedGenericTypes { get; init; } + public string? Skip { get; init; } + } + public class TestDataRow : <.TestDataRow> + { + public TestDataRow(T Data, string? DisplayName = null, string? Skip = null, string[]? Categories = null) { } + public string[]? Categories { get; init; } + public T Data { get; init; } + public string? DisplayName { get; init; } + public string? Skip { get; init; } } public class TestDefinition : .ITestDefinition { 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 529f3101a9..3b45e76512 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,11 +71,13 @@ namespace public sealed class ArgumentsAttribute : , .IDataSourceAttribute, ., . { public ArgumentsAttribute(params object?[]? values) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__12))] + [.(typeof(.ArgumentsAttribute.d__20))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -83,10 +85,12 @@ namespace public sealed class ArgumentsAttribute : .TypedDataSourceAttribute, ., . { public ArgumentsAttribute(T value) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public override bool SkipIfEmpty { get; set; } - [.(typeof(.ArgumentsAttribute.d__10))] + [.(typeof(.ArgumentsAttribute.d__18))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -1348,6 +1352,7 @@ namespace public class TestDataCombination { public TestDataCombination() { } + public string[]? Categories { get; init; } public <.>[] ClassDataFactories { get; init; } public int ClassDataSourceIndex { get; init; } public int ClassLoopIndex { get; init; } @@ -1358,6 +1363,15 @@ namespace public int MethodLoopIndex { get; init; } public int RepeatIndex { get; init; } public .? ResolvedGenericTypes { get; init; } + public string? Skip { get; init; } + } + public class TestDataRow : <.TestDataRow> + { + public TestDataRow(T Data, string? DisplayName = null, string? Skip = null, string[]? Categories = null) { } + public string[]? Categories { get; init; } + public T Data { get; init; } + public string? DisplayName { get; init; } + public string? Skip { get; init; } } public class TestDefinition : .ITestDefinition { 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 de5560aeb5..6a42eba10a 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,11 +71,13 @@ namespace public sealed class ArgumentsAttribute : , .IDataSourceAttribute, ., . { public ArgumentsAttribute(params object?[]? values) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public bool SkipIfEmpty { get; set; } public object?[] Values { get; } - [.(typeof(.ArgumentsAttribute.d__12))] + [.(typeof(.ArgumentsAttribute.d__20))] public .<<.>> GetDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -83,10 +85,12 @@ namespace public sealed class ArgumentsAttribute : .TypedDataSourceAttribute, ., . { public ArgumentsAttribute(T value) { } + public string[]? Categories { get; set; } + public string? DisplayName { get; set; } public int Order { get; } public string? Skip { get; set; } public override bool SkipIfEmpty { get; set; } - [.(typeof(.ArgumentsAttribute.d__10))] + [.(typeof(.ArgumentsAttribute.d__18))] public override .<<.>> GetTypedDataRowsAsync(.DataGeneratorMetadata dataGeneratorMetadata) { } public . OnTestRegistered(.TestRegisteredContext context) { } } @@ -1303,6 +1307,7 @@ namespace public class TestDataCombination { public TestDataCombination() { } + public string[]? Categories { get; init; } public <.>[] ClassDataFactories { get; init; } public int ClassDataSourceIndex { get; init; } public int ClassLoopIndex { get; init; } @@ -1313,6 +1318,15 @@ namespace public int MethodLoopIndex { get; init; } public int RepeatIndex { get; init; } public .? ResolvedGenericTypes { get; init; } + public string? Skip { get; init; } + } + public class TestDataRow : <.TestDataRow> + { + public TestDataRow(T Data, string? DisplayName = null, string? Skip = null, string[]? Categories = null) { } + public string[]? Categories { get; init; } + public T Data { get; init; } + public string? DisplayName { get; init; } + public string? Skip { get; init; } } public class TestDefinition : .ITestDefinition { diff --git a/docs/docs/test-authoring/arguments.md b/docs/docs/test-authoring/arguments.md index ffb333de1b..8d2ea9e45f 100644 --- a/docs/docs/test-authoring/arguments.md +++ b/docs/docs/test-authoring/arguments.md @@ -3,7 +3,7 @@ It's common to want to repeat tests but pass in different values on each execution. We can do that with a data driven test. -Compile-time known data can be injected via `[Arguments(...)]` attributes. +Compile-time known data can be injected via `[Arguments(...)]` attributes. This attribute takes an array of arguments. It can take as many as you like, but your test method has to have the same number of parameters and they must be the same type. If you include multiple `[Arguments]` attributes, your test will be repeated that many times, containing the data passed into the attribute. @@ -38,3 +38,82 @@ public class MyTestClass } } ``` + +## Test Case Metadata + +The `[Arguments]` attribute supports optional properties to customize individual test cases: + +### Custom Display Names + +Use the `DisplayName` property to provide a human-readable name for each test case: + +```csharp +[Test] +[Arguments(1, 1, 2, DisplayName = "One plus one equals two")] +[Arguments(0, 0, 0, DisplayName = "Zero plus zero equals zero")] +[Arguments(-1, 1, 0, DisplayName = "Negative and positive cancel out")] +public async Task Addition(int a, int b, int expected) +{ + await Assert.That(a + b).IsEqualTo(expected); +} +``` + +Display names support parameter substitution using `$paramName` or positional `$arg1`, `$arg2` syntax: + +```csharp +[Test] +[Arguments(2, 3, 5, DisplayName = "Adding $a + $b = $expected")] +[Arguments(10, 5, 15, DisplayName = "$arg1 + $arg2 = $arg3")] +public async Task AdditionWithSubstitution(int a, int b, int expected) +{ + await Assert.That(a + b).IsEqualTo(expected); +} +``` + +### Categories + +Apply categories to specific test cases for filtering: + +```csharp +[Test] +[Arguments(100, 50, Categories = new[] { "LargeNumbers", "Performance" })] +[Arguments(1, 1, Categories = new[] { "SmallNumbers", "Smoke" })] +public async Task CategorizedTests(int a, int b) +{ + await Assert.That(a + b).IsGreaterThan(0); +} +``` + +### Skipping Test Cases + +Use the `Skip` property to skip specific test cases: + +```csharp +[Test] +[Arguments("Chrome", "120")] +[Arguments("Firefox", "121")] +[Arguments("Safari", "17", Skip = "Safari testing not available in CI")] +public async Task BrowserTest(string browser, string version) +{ + // Test implementation +} +``` + +### Combining Properties + +All properties can be combined: + +```csharp +[Test] +[Arguments("admin", "secret123", DisplayName = "Admin login", Categories = new[] { "Auth", "Admin" })] +[Arguments("guest", "guest", DisplayName = "Guest login", Categories = new[] { "Auth" })] +[Arguments("", "", DisplayName = "Empty credentials", Skip = "Edge case not implemented")] +public async Task LoginTest(string username, string password) +{ + // Test implementation +} +``` + +:::tip +For dynamic test data or complex objects, use [Method Data Sources](./method-data-source.md) with [TestDataRow](./test-data-row.md) for the same metadata capabilities. +::: diff --git a/docs/docs/test-authoring/test-data-row.md b/docs/docs/test-authoring/test-data-row.md new file mode 100644 index 0000000000..68ac850f5f --- /dev/null +++ b/docs/docs/test-authoring/test-data-row.md @@ -0,0 +1,200 @@ +# Test Data Row Metadata + +When using data sources like `[MethodDataSource]` or `[ClassDataSource]`, you may want to customize individual test cases with specific display names, skip reasons, or categories. TUnit provides the `TestDataRow` wrapper type for this purpose. + +## Basic Usage + +Wrap your test data in `TestDataRow` to add metadata: + +```csharp +using TUnit.Core; + +public static class LoginTestData +{ + public static IEnumerable> GetCredentials() + { + yield return new(("admin", "secret123"), DisplayName: "Admin login"); + yield return new(("guest", "guest"), DisplayName: "Guest login"); + yield return new(("", ""), DisplayName: "Empty credentials", Skip: "Not implemented yet"); + } +} + +public class LoginTests +{ + [Test] + [MethodDataSource(typeof(LoginTestData), nameof(LoginTestData.GetCredentials))] + public async Task TestLogin(string username, string password) + { + // Test implementation + } +} +``` + +## Available Properties + +`TestDataRow` provides these optional properties: + +| Property | Type | Description | +|----------|------|-------------| +| `DisplayName` | `string?` | Custom name shown in test output and IDE | +| `Skip` | `string?` | Skip reason; when set, the test is skipped | +| `Categories` | `string[]?` | Categories for filtering tests | + +## Display Name Substitution + +Display names support parameter substitution using `$paramName` or positional `$arg1`, `$arg2` syntax: + +```csharp +public static IEnumerable> GetMathData() +{ + yield return new((2, 3, 5), DisplayName: "Adding $A + $B = $Expected"); + yield return new((10, 5, 15), DisplayName: "$arg1 plus $arg2 equals $arg3"); +} +``` + +The placeholders are replaced with the actual argument values at test discovery time. + +## Working with Complex Types + +For complex objects, wrap the entire object: + +```csharp +public record UserTestCase(string Email, bool IsAdmin, string ExpectedRole); + +public static class UserTestData +{ + public static IEnumerable> GetUserCases() + { + yield return new( + new UserTestCase("admin@test.com", true, "Administrator"), + DisplayName: "Admin user gets admin role", + Categories: ["Admin", "Roles"] + ); + + yield return new( + new UserTestCase("user@test.com", false, "Standard"), + DisplayName: "Regular user gets standard role" + ); + } +} + +public class UserRoleTests +{ + [Test] + [MethodDataSource(typeof(UserTestData), nameof(UserTestData.GetUserCases))] + public async Task TestUserRole(UserTestCase testCase) + { + // testCase.Email, testCase.IsAdmin, testCase.ExpectedRole + } +} +``` + +## With Func for Reference Types + +When returning reference types, combine with `Func` to ensure fresh instances: + +```csharp +public static IEnumerable>> GetHttpClients() +{ + yield return new( + () => new HttpClient { BaseAddress = new Uri("https://api.example.com") }, + DisplayName: "Production API client" + ); + + yield return new( + () => new HttpClient { BaseAddress = new Uri("https://staging.example.com") }, + DisplayName: "Staging API client" + ); +} +``` + +## Skipping Individual Test Cases + +Use the `Skip` property to skip specific test cases while keeping others active: + +```csharp +public static IEnumerable> GetBrowsers() +{ + yield return new(("Chrome", "120"), DisplayName: "Chrome latest"); + yield return new(("Firefox", "121"), DisplayName: "Firefox latest"); + yield return new(("Safari", "17"), DisplayName: "Safari", Skip: "Safari not installed on CI"); + yield return new(("Edge", "120"), DisplayName: "Edge latest"); +} +``` + +## Categorizing Test Cases + +Apply categories to individual test cases for filtering: + +```csharp +public static IEnumerable> GetApiEndpoints() +{ + yield return new( + ("/users", "GET"), + DisplayName: "List users", + Categories: ["API", "Users", "ReadOnly"] + ); + + yield return new( + ("/users", "POST"), + DisplayName: "Create user", + Categories: ["API", "Users", "Write"] + ); + + yield return new( + ("/admin/config", "PUT"), + DisplayName: "Update config", + Categories: ["API", "Admin", "Write"] + ); +} +``` + +Run only specific categories: +```bash +dotnet run -- --filter "Category=Admin" +``` + +## With ClassDataSource + +`TestDataRow` works with `[ClassDataSource]` too: + +```csharp +public class DatabaseTestData : IEnumerable> +{ + public IEnumerator> GetEnumerator() + { + yield return new( + ("Server=localhost;Database=TestDb1", "TestDb1"), + DisplayName: "Local database" + ); + yield return new( + ("Server=remote;Database=TestDb2", "TestDb2"), + DisplayName: "Remote database", + Skip: "Remote server unavailable" + ); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public class DatabaseTests +{ + [Test] + [ClassDataSource] + public async Task TestDatabaseConnection(string connectionString, string dbName) + { + // Test implementation + } +} +``` + +## Universal Data Source Support + +`TestDataRow` works with any data source that implements `IDataSourceAttribute`, including custom data source attributes. TUnit automatically detects and unwraps `TestDataRow` instances, extracting the metadata regardless of the data source type. + +## See Also + +- [Arguments Attribute](./arguments.md) - For compile-time constant data with inline metadata +- [Method Data Sources](./method-data-source.md) - For dynamic test data generation +- [Class Data Sources](./class-data-source.md) - For class-based test data +- [Display Names](../customization-extensibility/display-names.md) - For global display name formatting diff --git a/docs/sidebars.ts b/docs/sidebars.ts index e87e4d3b52..471beceb6b 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -132,6 +132,7 @@ const sidebars: SidebarsConfig = { 'test-authoring/arguments', 'test-authoring/method-data-source', 'test-authoring/class-data-source', + 'test-authoring/test-data-row', 'test-authoring/matrix-tests', 'test-authoring/combined-data-source', 'test-authoring/nested-data-sources',