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 .<<.