Skip to content

Commit 1755430

Browse files
thomhurstclaude
andauthored
+semver:minor - feat: add DisplayName, Skip, and Categories support for parameterized tests (#4214)
* feat: add DisplayName, Skip, and Categories support for parameterized tests Adds the ability to customize individual test cases with metadata: - `TestDataRow<T>` wrapper type for method/class data sources - `DisplayName`, `Skip`, and `Categories` properties on `[Arguments]` attribute - Parameter substitution in display names using `$paramName` or `$arg1` syntax This addresses #4212 by allowing users to set custom display names for data-driven tests without needing custom formatters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * fix: address PR #4214 review comments - Add DisplayName and Categories handling to ArgumentsAttribute.OnTestRegistered This fixes the dual-mode violation where the feature only worked in reflection mode, not source-gen mode - Add AOT DynamicDependency annotations to DataSourceMetadataExtractor for proper trimmer support of known TUnit data source types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> * chore: update public API snapshots for TestDataRow properties Updates snapshots to include new Categories, DisplayName, Skip properties on ArgumentsAttribute and TestDataRow types. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 5954d07 commit 1755430

18 files changed

Lines changed: 881 additions & 29 deletions

TUnit.Core/Attributes/TestData/ArgumentsAttribute.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,27 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR
3636

3737
public string? Skip { get; set; }
3838

39+
/// <summary>
40+
/// Gets or sets a custom display name for this test case.
41+
/// Supports parameter substitution using $paramName or $arg1, $arg2, etc.
42+
/// </summary>
43+
/// <example>
44+
/// <code>
45+
/// [Arguments("admin", "secret", DisplayName = "Login as $arg1")]
46+
/// </code>
47+
/// </example>
48+
public string? DisplayName { get; set; }
49+
50+
/// <summary>
51+
/// Gets or sets categories to apply to this specific test case.
52+
/// </summary>
53+
/// <example>
54+
/// <code>
55+
/// [Arguments("value", Categories = ["smoke", "integration"])]
56+
/// </code>
57+
/// </example>
58+
public string[]? Categories { get; set; }
59+
3960
/// <inheritdoc />
4061
public bool SkipIfEmpty { get; set; }
4162

@@ -65,6 +86,22 @@ public ValueTask OnTestRegistered(TestRegisteredContext context)
6586
context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance;
6687
}
6788

89+
if (!string.IsNullOrEmpty(DisplayName))
90+
{
91+
context.TestContext.SetDataSourceDisplayName(DisplayName!);
92+
}
93+
94+
if (Categories is { Length: > 0 })
95+
{
96+
foreach (var category in Categories)
97+
{
98+
if (!string.IsNullOrWhiteSpace(category) && !context.TestDetails.Categories.Contains(category))
99+
{
100+
context.TestDetails.Categories.Add(category);
101+
}
102+
}
103+
}
104+
68105
return default;
69106
}
70107

@@ -76,6 +113,17 @@ public sealed class ArgumentsAttribute<T>(T value) : TypedDataSourceAttribute<T>
76113
{
77114
public string? Skip { get; set; }
78115

116+
/// <summary>
117+
/// Gets or sets a custom display name for this test case.
118+
/// Supports parameter substitution using $paramName or $arg1, $arg2, etc.
119+
/// </summary>
120+
public string? DisplayName { get; set; }
121+
122+
/// <summary>
123+
/// Gets or sets categories to apply to this specific test case.
124+
/// </summary>
125+
public string[]? Categories { get; set; }
126+
79127
/// <inheritdoc />
80128
public override bool SkipIfEmpty { get; set; }
81129

@@ -93,6 +141,22 @@ public ValueTask OnTestRegistered(TestRegisteredContext context)
93141
context.TestContext.Metadata.TestDetails.ClassInstance = SkippedTestInstance.Instance;
94142
}
95143

144+
if (!string.IsNullOrEmpty(DisplayName))
145+
{
146+
context.TestContext.SetDataSourceDisplayName(DisplayName!);
147+
}
148+
149+
if (Categories is { Length: > 0 })
150+
{
151+
foreach (var category in Categories)
152+
{
153+
if (!string.IsNullOrWhiteSpace(category) && !context.TestDetails.Categories.Contains(category))
154+
{
155+
context.TestDetails.Categories.Add(category);
156+
}
157+
}
158+
}
159+
96160
return default;
97161
}
98162

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
namespace TUnit.Core.Helpers;
2+
3+
/// <summary>
4+
/// Utility for substituting parameter placeholders in display names.
5+
/// Supports $paramName and $arg1, $arg2, etc. syntax.
6+
/// </summary>
7+
internal static class DisplayNameSubstitutor
8+
{
9+
/// <summary>
10+
/// Substitutes parameter placeholders with actual argument values.
11+
/// </summary>
12+
/// <param name="displayName">The display name template with placeholders.</param>
13+
/// <param name="parameters">The parameter metadata.</param>
14+
/// <param name="arguments">The actual argument values.</param>
15+
/// <param name="formatters">Optional custom formatters for argument values.</param>
16+
/// <returns>The display name with placeholders replaced by formatted argument values.</returns>
17+
public static string Substitute(
18+
string displayName,
19+
ParameterMetadata[] parameters,
20+
object?[] arguments,
21+
List<Func<object?, string?>>? formatters = null)
22+
{
23+
if (string.IsNullOrEmpty(displayName) || !displayName.Contains('$'))
24+
{
25+
return displayName;
26+
}
27+
28+
var result = displayName;
29+
var effectiveFormatters = formatters ?? [];
30+
31+
// Substitute by parameter name ($paramName)
32+
for (var i = 0; i < parameters.Length && i < arguments.Length; i++)
33+
{
34+
var paramName = parameters[i].Name;
35+
if (!string.IsNullOrEmpty(paramName))
36+
{
37+
var placeholder = $"${paramName}";
38+
if (result.Contains(placeholder))
39+
{
40+
var formatted = ArgumentFormatter.Format(arguments[i], effectiveFormatters);
41+
result = result.Replace(placeholder, formatted);
42+
}
43+
}
44+
}
45+
46+
// Substitute by position ($arg1, $arg2, etc.)
47+
for (var i = 0; i < arguments.Length; i++)
48+
{
49+
var placeholder = $"$arg{i + 1}";
50+
if (result.Contains(placeholder))
51+
{
52+
var formatted = ArgumentFormatter.Format(arguments[i], effectiveFormatters);
53+
result = result.Replace(placeholder, formatted);
54+
}
55+
}
56+
57+
return result;
58+
}
59+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace TUnit.Core.Helpers;
4+
5+
/// <summary>
6+
/// Utility for detecting and unwrapping <see cref="TestDataRow{T}"/> instances.
7+
/// </summary>
8+
internal static class TestDataRowUnwrapper
9+
{
10+
private static readonly Type TestDataRowGenericType = typeof(TestDataRow<>);
11+
12+
/// <summary>
13+
/// Checks if the value is a <see cref="TestDataRow{T}"/> and extracts metadata and data.
14+
/// </summary>
15+
/// <param name="value">The value to check.</param>
16+
/// <param name="data">The extracted data if unwrapped, otherwise the original value.</param>
17+
/// <param name="metadata">The extracted metadata if unwrapped, otherwise null.</param>
18+
/// <returns>True if the value was a TestDataRow and was unwrapped.</returns>
19+
public static bool TryUnwrap(object? value, out object? data, [NotNullWhen(true)] out TestDataRowMetadata? metadata)
20+
{
21+
if (value is null)
22+
{
23+
data = null;
24+
metadata = null;
25+
return false;
26+
}
27+
28+
// Use interface-based access for AOT compatibility (avoids reflection)
29+
if (value is ITestDataRow testDataRow)
30+
{
31+
data = testDataRow.GetData();
32+
metadata = new TestDataRowMetadata(testDataRow.DisplayName, testDataRow.Skip, testDataRow.Categories);
33+
return true;
34+
}
35+
36+
data = value;
37+
metadata = null;
38+
return false;
39+
}
40+
41+
/// <summary>
42+
/// Checks if a type is a <see cref="TestDataRow{T}"/>.
43+
/// </summary>
44+
public static bool IsTestDataRowType(Type? type)
45+
{
46+
return type is not null && type.IsGenericType && type.GetGenericTypeDefinition() == TestDataRowGenericType;
47+
}
48+
49+
/// <summary>
50+
/// Gets the inner data type from a <see cref="TestDataRow{T}"/> type.
51+
/// </summary>
52+
public static Type? GetInnerDataType(Type testDataRowType)
53+
{
54+
if (!IsTestDataRowType(testDataRowType))
55+
{
56+
return null;
57+
}
58+
59+
return testDataRowType.GetGenericArguments()[0];
60+
}
61+
62+
/// <summary>
63+
/// Unwraps an array of values, extracting TestDataRow metadata from single-element arrays.
64+
/// </summary>
65+
/// <param name="values">The array of values to unwrap.</param>
66+
/// <returns>A tuple of the unwrapped data array and any extracted metadata.</returns>
67+
public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object?[] values)
68+
{
69+
if (values.Length == 1 && TryUnwrap(values[0], out var data, out var metadata))
70+
{
71+
// Single TestDataRow<T> - unwrap it
72+
// If the inner data is already an array, use it directly
73+
if (data is object?[] dataArray)
74+
{
75+
return (dataArray, metadata);
76+
}
77+
78+
// Check if the data is a tuple that should be expanded
79+
if (DataSourceHelpers.IsTuple(data))
80+
{
81+
return (data.ToObjectArray(), metadata);
82+
}
83+
84+
// Otherwise wrap the single value in an array
85+
return ([data], metadata);
86+
}
87+
88+
return (values, null);
89+
}
90+
}

TUnit.Core/TestContext.Metadata.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public partial class TestContext
77
{
88
internal string GetDisplayName()
99
{
10-
if(!string.IsNullOrEmpty(CustomDisplayName))
10+
if (!string.IsNullOrEmpty(CustomDisplayName))
1111
{
1212
return CustomDisplayName!;
1313
}
@@ -17,6 +17,17 @@ internal string GetDisplayName()
1717
return _cachedDisplayName;
1818
}
1919

20+
// Check for data source display name (from TestDataRow or ArgumentsAttribute.DisplayName)
21+
if (!string.IsNullOrEmpty(DataSourceDisplayName))
22+
{
23+
_cachedDisplayName = DisplayNameSubstitutor.Substitute(
24+
DataSourceDisplayName!,
25+
TestDetails.MethodMetadata.Parameters,
26+
TestDetails.TestMethodArguments,
27+
ArgumentDisplayFormatters);
28+
return _cachedDisplayName;
29+
}
30+
2031
if (TestDetails.TestMethodArguments.Length == 0)
2132
{
2233
_cachedDisplayName = TestDetails.TestName;

TUnit.Core/TestContext.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ public static string WorkingDirectory
118118

119119
internal string? CustomDisplayName { get; set; }
120120

121+
/// <summary>
122+
/// Display name provided by the data source (from TestDataRow or ArgumentsAttribute.DisplayName).
123+
/// This takes precedence over the default generated display name but can be overridden by CustomDisplayName.
124+
/// Supports $paramName substitution.
125+
/// </summary>
126+
internal string? DataSourceDisplayName { get; private set; }
127+
128+
/// <summary>
129+
/// Sets the display name from the data source (TestDataRow or ArgumentsAttribute.DisplayName).
130+
/// </summary>
131+
internal void SetDataSourceDisplayName(string displayName)
132+
{
133+
DataSourceDisplayName = displayName;
134+
}
135+
121136

122137
internal TestDetails TestDetails { get; set; } = null!;
123138

TUnit.Core/TestDataCombination.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ public class TestDataCombination
3535

3636
public string? DisplayName { get; init; }
3737

38+
/// <summary>
39+
/// Skip reason from the data source. When set, this test combination will be skipped.
40+
/// </summary>
41+
public string? Skip { get; init; }
42+
43+
/// <summary>
44+
/// Categories from the data source to apply to this specific test combination.
45+
/// </summary>
46+
public string[]? Categories { get; init; }
47+
3848
public int RepeatIndex { get; init; } = 0;
3949

4050
public Dictionary<string, Type>? ResolvedGenericTypes { get; init; }

TUnit.Core/TestDataRow.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
namespace TUnit.Core;
2+
3+
/// <summary>
4+
/// Internal interface for accessing TestDataRow properties without reflection.
5+
/// This enables AOT compatibility by avoiding dynamic property access.
6+
/// </summary>
7+
internal interface ITestDataRow
8+
{
9+
object? GetData();
10+
string? DisplayName { get; }
11+
string? Skip { get; }
12+
string[]? Categories { get; }
13+
}
14+
15+
/// <summary>
16+
/// Wraps test data with optional metadata for customizing test execution.
17+
/// Use this when returning data from method/class data sources to specify
18+
/// per-row display names, skip reasons, or categories.
19+
/// </summary>
20+
/// <typeparam name="T">The type of the test data.</typeparam>
21+
/// <param name="Data">The actual test data to be passed to the test method.</param>
22+
/// <param name="DisplayName">
23+
/// Optional custom display name for the test case.
24+
/// Supports parameter substitution using $paramName or $arg1, $arg2, etc.
25+
/// </param>
26+
/// <param name="Skip">
27+
/// Optional skip reason. When set, the test case will be skipped with this message.
28+
/// </param>
29+
/// <param name="Categories">
30+
/// Optional categories to apply to this specific test case.
31+
/// </param>
32+
/// <example>
33+
/// <code>
34+
/// public static IEnumerable&lt;TestDataRow&lt;(string Username, string Password)&gt;&gt; GetLoginData()
35+
/// {
36+
/// yield return new(("admin", "secret123"), DisplayName: "Admin login");
37+
/// yield return new(("guest", "guest"), DisplayName: "Guest login");
38+
/// yield return new(("", ""), DisplayName: "Empty credentials", Skip: "Not implemented yet");
39+
/// }
40+
/// </code>
41+
/// </example>
42+
public record TestDataRow<T>(
43+
T Data,
44+
string? DisplayName = null,
45+
string? Skip = null,
46+
string[]? Categories = null
47+
) : ITestDataRow
48+
{
49+
object? ITestDataRow.GetData() => Data;
50+
}

TUnit.Core/TestDataRowMetadata.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace TUnit.Core;
2+
3+
/// <summary>
4+
/// Metadata extracted from a <see cref="TestDataRow{T}"/> wrapper or data source attributes.
5+
/// </summary>
6+
/// <param name="DisplayName">Custom display name for the test case.</param>
7+
/// <param name="Skip">Skip reason - test will be skipped if set.</param>
8+
/// <param name="Categories">Categories to apply to the test case.</param>
9+
internal record TestDataRowMetadata(
10+
string? DisplayName,
11+
string? Skip,
12+
string[]? Categories
13+
)
14+
{
15+
/// <summary>
16+
/// Returns true if any metadata property is set.
17+
/// </summary>
18+
public bool HasMetadata => DisplayName is not null || Skip is not null || Categories is { Length: > 0 };
19+
20+
/// <summary>
21+
/// Merges this metadata with another, preferring non-null values from this instance.
22+
/// </summary>
23+
public TestDataRowMetadata MergeWith(TestDataRowMetadata? other)
24+
{
25+
if (other is null)
26+
{
27+
return this;
28+
}
29+
30+
return new TestDataRowMetadata(
31+
DisplayName ?? other.DisplayName,
32+
Skip ?? other.Skip,
33+
Categories ?? other.Categories
34+
);
35+
}
36+
}

0 commit comments

Comments
 (0)