-
-
Notifications
You must be signed in to change notification settings - Fork 108
feat: add DisplayName, Skip, and Categories support for parameterized tests #4214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,27 @@ public sealed class ArgumentsAttribute : Attribute, IDataSourceAttribute, ITestR | |
|
|
||
| public string? Skip { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets a custom display name for this test case. | ||
| /// Supports parameter substitution using $paramName or $arg1, $arg2, etc. | ||
| /// </summary> | ||
| /// <example> | ||
| /// <code> | ||
| /// [Arguments("admin", "secret", DisplayName = "Login as $arg1")] | ||
| /// </code> | ||
| /// </example> | ||
| public string? DisplayName { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets categories to apply to this specific test case. | ||
| /// </summary> | ||
| /// <example> | ||
| /// <code> | ||
| /// [Arguments("value", Categories = ["smoke", "integration"])] | ||
| /// </code> | ||
| /// </example> | ||
| public string[]? Categories { get; set; } | ||
|
|
||
| /// <inheritdoc /> | ||
| 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>(T value) : TypedDataSourceAttribute<T> | |
| { | ||
| public string? Skip { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets a custom display name for this test case. | ||
| /// Supports parameter substitution using $paramName or $arg1, $arg2, etc. | ||
| /// </summary> | ||
| public string? DisplayName { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets categories to apply to this specific test case. | ||
| /// </summary> | ||
| public string[]? Categories { get; set; } | ||
|
Comment on lines
+120
to
+125
|
||
|
|
||
| /// <inheritdoc /> | ||
| 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; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| namespace TUnit.Core.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// Utility for substituting parameter placeholders in display names. | ||
| /// Supports $paramName and $arg1, $arg2, etc. syntax. | ||
| /// </summary> | ||
| internal static class DisplayNameSubstitutor | ||
| { | ||
| /// <summary> | ||
| /// Substitutes parameter placeholders with actual argument values. | ||
| /// </summary> | ||
| /// <param name="displayName">The display name template with placeholders.</param> | ||
| /// <param name="parameters">The parameter metadata.</param> | ||
| /// <param name="arguments">The actual argument values.</param> | ||
| /// <param name="formatters">Optional custom formatters for argument values.</param> | ||
| /// <returns>The display name with placeholders replaced by formatted argument values.</returns> | ||
| public static string Substitute( | ||
| string displayName, | ||
| ParameterMetadata[] parameters, | ||
| object?[] arguments, | ||
| List<Func<object?, string?>>? 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); | ||
| } | ||
| } | ||
|
Comment on lines
+31
to
+43
|
||
| } | ||
|
|
||
| // 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| using System.Diagnostics.CodeAnalysis; | ||
|
|
||
| namespace TUnit.Core.Helpers; | ||
|
|
||
| /// <summary> | ||
| /// Utility for detecting and unwrapping <see cref="TestDataRow{T}"/> instances. | ||
| /// </summary> | ||
| internal static class TestDataRowUnwrapper | ||
| { | ||
| private static readonly Type TestDataRowGenericType = typeof(TestDataRow<>); | ||
|
|
||
| /// <summary> | ||
| /// Checks if the value is a <see cref="TestDataRow{T}"/> and extracts metadata and data. | ||
| /// </summary> | ||
| /// <param name="value">The value to check.</param> | ||
| /// <param name="data">The extracted data if unwrapped, otherwise the original value.</param> | ||
| /// <param name="metadata">The extracted metadata if unwrapped, otherwise null.</param> | ||
| /// <returns>True if the value was a TestDataRow and was unwrapped.</returns> | ||
| 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; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Checks if a type is a <see cref="TestDataRow{T}"/>. | ||
| /// </summary> | ||
| public static bool IsTestDataRowType(Type? type) | ||
| { | ||
| return type is not null && type.IsGenericType && type.GetGenericTypeDefinition() == TestDataRowGenericType; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the inner data type from a <see cref="TestDataRow{T}"/> type. | ||
| /// </summary> | ||
| public static Type? GetInnerDataType(Type testDataRowType) | ||
| { | ||
| if (!IsTestDataRowType(testDataRowType)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| return testDataRowType.GetGenericArguments()[0]; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Unwraps an array of values, extracting TestDataRow metadata from single-element arrays. | ||
| /// </summary> | ||
| /// <param name="values">The array of values to unwrap.</param> | ||
| /// <returns>A tuple of the unwrapped data array and any extracted metadata.</returns> | ||
| public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object?[] values) | ||
| { | ||
| if (values.Length == 1 && TryUnwrap(values[0], out var data, out var metadata)) | ||
| { | ||
| // Single TestDataRow<T> - 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,16 @@ public class TestDataCombination | |
|
|
||
| public string? DisplayName { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// Skip reason from the data source. When set, this test combination will be skipped. | ||
| /// </summary> | ||
| public string? Skip { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// Categories from the data source to apply to this specific test combination. | ||
| /// </summary> | ||
| public string[]? Categories { get; init; } | ||
|
Comment on lines
+38
to
+46
|
||
|
|
||
| public int RepeatIndex { get; init; } = 0; | ||
|
|
||
| public Dictionary<string, Type>? ResolvedGenericTypes { get; init; } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| namespace TUnit.Core; | ||
|
|
||
| /// <summary> | ||
| /// Internal interface for accessing TestDataRow properties without reflection. | ||
| /// This enables AOT compatibility by avoiding dynamic property access. | ||
| /// </summary> | ||
| internal interface ITestDataRow | ||
| { | ||
| object? GetData(); | ||
| string? DisplayName { get; } | ||
| string? Skip { get; } | ||
| string[]? Categories { get; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| /// <typeparam name="T">The type of the test data.</typeparam> | ||
| /// <param name="Data">The actual test data to be passed to the test method.</param> | ||
| /// <param name="DisplayName"> | ||
| /// Optional custom display name for the test case. | ||
| /// Supports parameter substitution using $paramName or $arg1, $arg2, etc. | ||
| /// </param> | ||
| /// <param name="Skip"> | ||
| /// Optional skip reason. When set, the test case will be skipped with this message. | ||
| /// </param> | ||
| /// <param name="Categories"> | ||
| /// Optional categories to apply to this specific test case. | ||
| /// </param> | ||
| /// <example> | ||
| /// <code> | ||
| /// 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"); | ||
| /// } | ||
| /// </code> | ||
| /// </example> | ||
| public record TestDataRow<T>( | ||
| T Data, | ||
| string? DisplayName = null, | ||
| string? Skip = null, | ||
| string[]? Categories = null | ||
| ) : ITestDataRow | ||
| { | ||
| object? ITestDataRow.GetData() => Data; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| namespace TUnit.Core; | ||
|
|
||
| /// <summary> | ||
| /// Metadata extracted from a <see cref="TestDataRow{T}"/> wrapper or data source attributes. | ||
| /// </summary> | ||
| /// <param name="DisplayName">Custom display name for the test case.</param> | ||
| /// <param name="Skip">Skip reason - test will be skipped if set.</param> | ||
| /// <param name="Categories">Categories to apply to the test case.</param> | ||
| internal record TestDataRowMetadata( | ||
| string? DisplayName, | ||
| string? Skip, | ||
| string[]? Categories | ||
| ) | ||
| { | ||
| /// <summary> | ||
| /// Returns true if any metadata property is set. | ||
| /// </summary> | ||
| public bool HasMetadata => DisplayName is not null || Skip is not null || Categories is { Length: > 0 }; | ||
|
|
||
| /// <summary> | ||
| /// Merges this metadata with another, preferring non-null values from this instance. | ||
| /// </summary> | ||
| public TestDataRowMetadata MergeWith(TestDataRowMetadata? other) | ||
| { | ||
| if (other is null) | ||
| { | ||
| return this; | ||
| } | ||
|
|
||
| return new TestDataRowMetadata( | ||
| DisplayName ?? other.DisplayName, | ||
| Skip ?? other.Skip, | ||
| Categories ?? other.Categories | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new DisplayName and Categories properties are defined but not applied to the test context. The ArgumentsAttribute implements ITestRegisteredEventReceiver with an OnTestRegistered method (lines 81-90) that only handles the Skip property.
For dual-mode parity, the OnTestRegistered method needs to be updated to also apply DisplayName and Categories properties, similar to how the reflection mode handles this in TestBuilder.cs lines 874-897. Without this, the source-generated mode won't respect these properties.
The OnTestRegistered method should: