Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions TUnit.Analyzers.Tests/MethodDataSourceAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,37 @@ public static IEnumerable<Func<TestDataRow<MyData>>> GetData()
);
}

[Test]
public async Task Method_Data_Source_With_TestDataRow_Func_Tuple_No_Error()
{
// TestDataRow<Func<(int,int,int)>> should unwrap the Func and spread the tuple across the
// three parameters (issue #6161). The Func is nested *inside* TestDataRow, so this exercises
// the opposite nesting order from Method_Data_Source_With_Func_TestDataRow_No_Error.
await Verifier
.VerifyAnalyzerAsync(
"""
using TUnit.Core;
using System;
using System.Collections.Generic;

public class MyClass
{
[MethodDataSource(nameof(GetData))]
[Test]
public void MyTest(int a, int b, int c)
{
}

public static IEnumerable<TestDataRow<Func<(int, int, int)>>> GetData()
{
yield return new(() => (1, 1, 2), DisplayName: "$arg1 + $arg2 = $arg3");
yield return new(() => (1, 2, 3), DisplayName: "$arg1 + $arg2 = $arg3");
}
}
"""
);
}

[Test]
public async Task No_Warning_For_Immutable_Record()
{
Expand Down
36 changes: 23 additions & 13 deletions TUnit.Analyzers/TestDataAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -770,22 +770,32 @@ private ImmutableArray<ITypeSymbol> UnwrapTypes(SymbolAnalysisContext context,
return ImmutableArray.Create(type);
}

if (type is INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1 } genericType
&& genericType.ToDisplayString().StartsWith("System.Func<"))
{
isFunc = true;
type = genericType.TypeArguments[0];
}

// Check for TestDataRow<T> wrapper - unwrap to get the inner data type
// This must come after Func<T> unwrapping so that Func<TestDataRow<T>> → TestDataRow<T> → T
// Unwrap Func<T> and TestDataRow<T> wrappers in any nesting order, so that Func<T>,
// TestDataRow<T>, Func<TestDataRow<T>>, and TestDataRow<Func<T>> all reduce to the inner T
// before the tuple check below. Looping handles either order without assuming which wraps which.
var testDataRowTypeSymbol = context.Compilation.GetTypeByMetadataName("TUnit.Core.TestDataRow`1");
if (testDataRowTypeSymbol != null
&& type is INamedTypeSymbol { IsGenericType: true } testDataRowType
&& SymbolEqualityComparer.Default.Equals(testDataRowType.OriginalDefinition, testDataRowTypeSymbol))
bool unwrappedLayer;
do
{
type = testDataRowType.TypeArguments[0];
unwrappedLayer = false;

if (type is INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1 } genericType
&& genericType.ToDisplayString().StartsWith("System.Func<"))
{
isFunc = true;
type = genericType.TypeArguments[0];
unwrappedLayer = true;
}

if (testDataRowTypeSymbol != null
&& type is INamedTypeSymbol { IsGenericType: true } testDataRowType
&& SymbolEqualityComparer.Default.Equals(testDataRowType.OriginalDefinition, testDataRowTypeSymbol))
{
type = testDataRowType.TypeArguments[0];
unwrappedLayer = true;
}
}
while (unwrappedLayer);

if (type is INamedTypeSymbol namedType && namedType.IsTupleType)
{
Expand Down
74 changes: 58 additions & 16 deletions TUnit.Core/Helpers/DisplayNameSubstitutor.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Text;

namespace TUnit.Core.Helpers;

/// <summary>
Expand Down Expand Up @@ -28,33 +30,73 @@ public static string Substitute(
var result = displayName;
var effectiveFormatters = formatters ?? [];

// Substitute by position ($arg1, $arg2, etc.) first. Positional placeholders are more
// specific than single-letter parameter names (e.g. a parameter named "a" would otherwise
// be matched inside "$arg1"), so resolving them up front avoids that collision.
for (var i = 0; i < arguments.Length; i++)
{
var placeholder = $"$arg{i + 1}";
if (!result.Contains(placeholder))
{
continue;
}

var parameterType = i < parameters.Length ? parameters[i].Type : null;
result = ReplacePlaceholder(result, placeholder, ArgumentFormatter.Format(arguments[i], parameterType, effectiveFormatters));
}

// 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))
if (string.IsNullOrEmpty(paramName))
{
var placeholder = $"${paramName}";
if (result.Contains(placeholder))
{
var formatted = ArgumentFormatter.Format(arguments[i], parameters[i].Type, effectiveFormatters);
result = result.Replace(placeholder, formatted);
}
continue;
}
}

// Substitute by position ($arg1, $arg2, etc.)
for (var i = 0; i < arguments.Length; i++)
{
var placeholder = $"$arg{i + 1}";
if (result.Contains(placeholder))
var placeholder = $"${paramName}";
if (!result.Contains(placeholder))
{
var parameterType = i < parameters.Length ? parameters[i].Type : null;
var formatted = ArgumentFormatter.Format(arguments[i], parameterType, effectiveFormatters);
result = result.Replace(placeholder, formatted);
continue;
}

result = ReplacePlaceholder(result, placeholder, ArgumentFormatter.Format(arguments[i], parameters[i].Type, effectiveFormatters));
}

return result;
}

/// <summary>
/// Replaces every occurrence of <paramref name="placeholder"/> that is followed by a
/// non-identifier character (or the end of the string) with <paramref name="value"/>, so that
/// "$a" is not matched inside "$arg1" or "$abc".
/// </summary>
private static string ReplacePlaceholder(string input, string placeholder, string? value)
{
var index = input.IndexOf(placeholder, StringComparison.Ordinal);
if (index < 0)
{
return input;
}

var builder = new StringBuilder(input.Length);
var searchStart = 0;

while (index >= 0)
{
var afterIndex = index + placeholder.Length;
var isBoundary = afterIndex >= input.Length || !IsIdentifierChar(input[afterIndex]);

builder.Append(input, searchStart, index - searchStart);
builder.Append(isBoundary ? value ?? string.Empty : placeholder);

searchStart = afterIndex;
index = input.IndexOf(placeholder, searchStart, StringComparison.Ordinal);
}

builder.Append(input, searchStart, input.Length - searchStart);
return builder.ToString();
}

private static bool IsIdentifierChar(char c) => char.IsLetterOrDigit(c) || c == '_';
}
8 changes: 7 additions & 1 deletion TUnit.Core/Helpers/TestDataRowUnwrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ public static (object?[] Data, TestDataRowMetadata? Metadata) UnwrapArray(object
{
if (values.Length == 1 && TryUnwrap(values[0], out var data, out var metadata))
{
// Single TestDataRow<T> - unwrap it
// Single TestDataRow<T> - unwrap it.
// If the inner data is a Func<T>, invoke it so TestDataRow<Func<T>> behaves the same
// as a bare Func<T> data source (the Func is invoked, then tuples are spread into
// individual parameters). Bare Func values are invoked in DataSourceHelpers.ToObjectArray,
// but a Func wrapped in a TestDataRow is opaque there and only surfaces here.
data = DataSourceHelpers.InvokeIfFunc(data);

// If the inner data is already an array, use it directly
if (data is object?[] dataArray)
{
Expand Down
55 changes: 55 additions & 0 deletions TUnit.TestProject/Bugs/6161/Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Collections.Concurrent;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject.Bugs._6161;

// Issue #6161: a [MethodDataSource] returning IEnumerable<TestDataRow<Func<...>>> must invoke the
// inner Func and spread the resulting tuple into the test method parameters - matching the behaviour
// of a bare IEnumerable<Func<...>> data source. Before the fix the Func was passed through as a
// single argument, so the tuple never bound to (int a, int b, int c) and the parameterized
// DisplayName placeholders could not be substituted (causing rows with the same template to collapse).
[EngineTest(ExpectedResult.Pass)]
public class Tests
{
private static readonly ConcurrentBag<(int, int, int)> ExecutedTuples = [];

[Test]
[MethodDataSource(nameof(TupleData))]
public async Task TupleFuncIsUnwrapped(int a, int b, int c)
{
ExecutedTuples.Add((a, b, c));
await Assert.That(a + b).IsEqualTo(c);
}

[Test]
[MethodDataSource(nameof(RecordData))]
public async Task ReferenceFuncIsUnwrapped(Calculation calculation)
{
await Assert.That(calculation.First + calculation.Second).IsEqualTo(calculation.Expected);
}

// Two rows deliberately share the same DisplayName template. Once the Func is invoked the
// placeholders resolve to distinct values, so both rows run as separate test cases.
public static IEnumerable<TestDataRow<Func<(int, int, int)>>> TupleData()
{
yield return new(static () => (1, 1, 2), DisplayName: "$arg1 + $arg2 = $arg3");
yield return new(static () => (1, 2, 3), DisplayName: "$arg1 + $arg2 = $arg3");
yield return new(static () => (2, 3, 5), DisplayName: "$arg1 + $arg2 = $arg3");
}

public static IEnumerable<TestDataRow<Func<Calculation>>> RecordData()
{
yield return new(static () => new Calculation(1, 1, 2), DisplayName: "Calc one");
yield return new(static () => new Calculation(4, 5, 9), DisplayName: "Calc two");
}

// Regression for the dedup symptom: if rows had collapsed, fewer than the three distinct tuples
// would have executed.
[After(Class)]
public static async Task AllDistinctRowsExecuted()
{
await Assert.That(ExecutedTuples.Distinct().Count()).IsEqualTo(3);
}

public record Calculation(int First, int Second, int Expected);
}
21 changes: 21 additions & 0 deletions docs/docs/writing-tests/test-data-row.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,27 @@ public static IEnumerable<TestDataRow<Func<HttpClient>>> GetHttpClients()
}
```

The inner `Func<T>` is invoked before the test runs, exactly like a bare `IEnumerable<Func<T>>`
data source. This means a `Func<T>` that returns a tuple is spread across the test method's
parameters, and the resolved values are available to the parameterized `DisplayName`:

```csharp
public static IEnumerable<TestDataRow<Func<(int, int, int)>>> GetSums()
{
yield return new(() => (1, 1, 2), DisplayName: "$arg1 + $arg2 = $arg3");
yield return new(() => (1, 2, 3), DisplayName: "$arg1 + $arg2 = $arg3");
}

[Test]
[MethodDataSource(nameof(GetSums))]
public async Task Adds(int a, int b, int expected)
{
await Assert.That(a + b).IsEqualTo(expected);
}
```

The two cases display as `1 + 1 = 2` and `1 + 2 = 3`.

## Skipping Individual Test Cases

Use the `Skip` property to skip specific test cases while keeping others active:
Expand Down
Loading