Skip to content

Commit ee04fed

Browse files
committed
fix(defer-enumeration): report placeholder as a container with an aggregate result
The IDE run of a deferred test's placeholder node showed "Inconclusive: Test has not run" because the placeholder produced no result when its children were the real results. Reporting it always-Passed (the original behaviour) is wrong too — it would show green even when a child fails. Now the placeholder is reported as a container: InProgress when expansion starts, then a final result after the children run that aggregates them — passed only if every case passes, failed if any case fails, skipped if all were skipped. This resolves the IDE node without masking child failures. The placeholder therefore adds one entry to flat result counts (TRX/console) per deferred test; engine tests and docs updated to reflect this.
1 parent 52fd97f commit ee04fed

3 files changed

Lines changed: 84 additions & 22 deletions

File tree

TUnit.Engine.Tests/DeferEnumerationTests.cs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ namespace TUnit.Engine.Tests;
55

66
/// <summary>
77
/// Validates DeferEnumeration: a data source marked with it is NOT expanded during discovery (one
8-
/// placeholder node is shown) and is expanded into the real cases at runtime. The placeholder is a
9-
/// container and is not reported as its own result, so the run-time counts below are exactly the number
10-
/// of data cases. Discovery-time collapse is covered by manual verification / list-tests.
8+
/// placeholder node is shown) and is expanded into the real cases at runtime. The placeholder is reported
9+
/// as a container whose result aggregates its cases, so the run-time counts below are the number of data
10+
/// cases plus one for the placeholder (e.g. a 10-row source => 10 cases + 1 placeholder = 11). Discovery-time
11+
/// collapse is covered by manual verification / list-tests.
1112
/// </summary>
1213
public class DeferEnumerationTests(TestMode testMode) : InvokableTestBase(testMode)
1314
{
@@ -18,34 +19,35 @@ await RunTestsWithFilter(
1819
"/*/*/DeferEnumerationTests/Deferred",
1920
[
2021
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
21-
result => result.ResultSummary.Counters.Total.ShouldBe(10),
22-
result => result.ResultSummary.Counters.Passed.ShouldBe(10),
22+
result => result.ResultSummary.Counters.Total.ShouldBe(11),
23+
result => result.ResultSummary.Counters.Passed.ShouldBe(11),
2324
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
2425
]);
2526
}
2627

2728
[Test]
2829
public async Task Deferred_Test_Honours_Repeat()
2930
{
30-
// [Repeat(2)] => 3 runs per case; 10 cases => 30 results (the placeholder is not counted).
31+
// [Repeat(2)] => 3 runs per case; 10 cases => 30 cases + 1 placeholder container = 31.
3132
await RunTestsWithFilter(
3233
"/*/*/DeferEnumerationTests/DeferredWithRepeat",
3334
[
3435
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
35-
result => result.ResultSummary.Counters.Total.ShouldBe(30),
36-
result => result.ResultSummary.Counters.Passed.ShouldBe(30),
36+
result => result.ResultSummary.Counters.Total.ShouldBe(31),
37+
result => result.ResultSummary.Counters.Passed.ShouldBe(31),
3738
result => result.ResultSummary.Counters.Failed.ShouldBe(0)
3839
]);
3940
}
4041

4142
[Test]
4243
public async Task Deferred_Data_Source_Error_Surfaces_At_Runtime_Without_Crashing_Discovery()
4344
{
44-
// The throwing data source must not crash discovery; the error surfaces as a failed result.
45+
// The throwing data source must not crash discovery; the error surfaces as a failed case, and the
46+
// placeholder container aggregates to failed too (failed case + failed container = 2).
4547
await RunTestsWithFilter(
4648
"/*/*/DeferEnumerationErrorTests/*",
4749
[
48-
result => result.ResultSummary.Counters.Failed.ShouldBe(1)
50+
result => result.ResultSummary.Counters.Failed.ShouldBe(2)
4951
]);
5052
}
5153
}

TUnit.Engine/TestSessionCoordinator.cs

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,18 @@ public async Task ExecuteTests(
5959

6060
// Expand any deferred-enumeration placeholders into their real cases before counting/scheduling,
6161
// so the children flow through the normal pipeline (correct hooks + lifecycle counting).
62-
await ExpandDeferredPlaceholdersAsync(testList, cancellationToken);
62+
var expandedPlaceholders = await ExpandDeferredPlaceholdersAsync(testList, cancellationToken);
6363

6464
InitializeEventReceivers(testList, cancellationToken);
6565

6666
try
6767
{
6868
await PrepareTestOrchestrator(testList, cancellationToken);
6969
await ExecuteTestsCore(testList, cancellationToken);
70+
71+
// Children have now run: resolve each placeholder container to the aggregate of its cases so the
72+
// IDE node the user ran gets a result (rather than "not run") that reflects its children.
73+
await ReportDeferredPlaceholderResultsAsync(expandedPlaceholders);
7074
}
7175
finally
7276
{
@@ -101,18 +105,20 @@ private void InitializeEventReceivers(List<AbstractExecutableTest> testList, Can
101105

102106
/// <summary>
103107
/// Replaces every <see cref="DeferredEnumerationExecutableTest"/> in the list with the real test cases
104-
/// produced by enumerating its data source. The placeholder is a container, not a test: its real results
105-
/// are the children (added to the list, scheduled like any other test, and nested under the placeholder
106-
/// via their ParentTestId), so it is NOT reported as a passed result on success — that would inflate test
107-
/// counts and show the node green even when its children fail. A result is reported only if the expansion
108-
/// itself throws, so that failure stays visible on the placeholder node. (Per-row data errors surface as
109-
/// their own failed child via the standard data-generation-error path.)
108+
/// produced by enumerating its data source. The placeholder is reported as a running container now (so the
109+
/// IDE node the user ran shows as in-progress rather than "not run") and resolved to the aggregate of its
110+
/// children later by <see cref="ReportDeferredPlaceholderResultsAsync"/>; the children are added to the
111+
/// list, scheduled like any other test, and nested under the placeholder via their ParentTestId. If the
112+
/// expansion itself throws, the placeholder is reported failed immediately. (Per-row data errors surface
113+
/// as their own failed child via the standard data-generation-error path.) The returned list pairs each
114+
/// successfully-expanded placeholder with its children so its final result can be reported post-run.
110115
/// </summary>
111116
#if NET8_0_OR_GREATER
112117
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Reflection mode is not used in AOT/trimmed scenarios")]
113118
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Reflection mode is not used in AOT scenarios")]
114119
#endif
115-
private async Task ExpandDeferredPlaceholdersAsync(List<AbstractExecutableTest> testList, CancellationToken cancellationToken)
120+
private async Task<List<(AbstractExecutableTest Placeholder, IReadOnlyList<AbstractExecutableTest> Children)>> ExpandDeferredPlaceholdersAsync(
121+
List<AbstractExecutableTest> testList, CancellationToken cancellationToken)
116122
{
117123
List<DeferredEnumerationExecutableTest>? placeholders = null;
118124
foreach (var test in testList)
@@ -125,32 +131,86 @@ private async Task ExpandDeferredPlaceholdersAsync(List<AbstractExecutableTest>
125131

126132
if (placeholders is null)
127133
{
128-
return;
134+
return [];
129135
}
130136

131137
// Drop all placeholders in a single pass so none are scheduled as real tests (their Create/Invoke
132138
// throw); their children are added back below.
133139
testList.RemoveAll(static t => t is DeferredEnumerationExecutableTest);
134140

141+
var expanded = new List<(AbstractExecutableTest, IReadOnlyList<AbstractExecutableTest>)>(placeholders.Count);
142+
135143
foreach (var placeholder in placeholders)
136144
{
145+
placeholder.StartTime = DateTimeOffset.UtcNow;
146+
placeholder.State = TestState.Running;
147+
await _messageBus.InProgress(placeholder.Context);
148+
137149
try
138150
{
139151
var children = await _deferredTestExpander.ExpandAsync(placeholder, cancellationToken);
140152
testList.AddRange(children);
153+
expanded.Add((placeholder, children));
141154
}
142155
catch (Exception ex)
143156
{
144157
// Expansion itself failed (as opposed to a per-row data error, which becomes a failed
145158
// child). Surface it on the placeholder node so the failure is visible.
146159
await _logger.LogErrorAsync($"Failed to expand deferred test '{placeholder.TestId}': {ex}");
147-
placeholder.StartTime = DateTimeOffset.UtcNow;
148-
await _messageBus.InProgress(placeholder.Context);
149160
placeholder.EndTime = DateTimeOffset.UtcNow;
150161
placeholder.SetResult(TestState.Failed, ex);
151162
await _messageBus.Failed(placeholder.Context, ex, placeholder.StartTime.GetValueOrDefault());
152163
}
153164
}
165+
166+
return expanded;
167+
}
168+
169+
/// <summary>
170+
/// Reports the final result for each deferred placeholder once its children have executed: the placeholder
171+
/// is a container whose outcome is the aggregate of its cases — failed if any case failed, skipped if every
172+
/// case was skipped, otherwise passed. This resolves the IDE node the user ran without masking child
173+
/// failures (which a fixed "passed" would).
174+
/// </summary>
175+
private async Task ReportDeferredPlaceholderResultsAsync(
176+
List<(AbstractExecutableTest Placeholder, IReadOnlyList<AbstractExecutableTest> Children)> expandedPlaceholders)
177+
{
178+
foreach (var (placeholder, children) in expandedPlaceholders)
179+
{
180+
placeholder.EndTime = DateTimeOffset.UtcNow;
181+
182+
var failedCount = 0;
183+
var skippedCount = 0;
184+
foreach (var child in children)
185+
{
186+
switch (child.State)
187+
{
188+
case TestState.Failed or TestState.Timeout or TestState.Cancelled:
189+
failedCount++;
190+
break;
191+
case TestState.Skipped:
192+
skippedCount++;
193+
break;
194+
}
195+
}
196+
197+
if (failedCount > 0)
198+
{
199+
var exception = new Exception($"{failedCount} of {children.Count} deferred test case(s) failed.");
200+
placeholder.SetResult(TestState.Failed, exception);
201+
await _messageBus.Failed(placeholder.Context, exception, placeholder.StartTime.GetValueOrDefault());
202+
}
203+
else if (children.Count > 0 && skippedCount == children.Count)
204+
{
205+
placeholder.State = TestState.Skipped;
206+
await _messageBus.Skipped(placeholder.Context, "All deferred test cases were skipped");
207+
}
208+
else
209+
{
210+
placeholder.SetResult(TestState.Passed);
211+
await _messageBus.Passed(placeholder.Context, placeholder.StartTime.GetValueOrDefault());
212+
}
213+
}
154214
}
155215

156216
private async Task PrepareTestOrchestrator(List<AbstractExecutableTest> testList, CancellationToken cancellationToken)

docs/docs/writing-tests/defer-enumeration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ With the flag set:
2626
`DeferEnumeration` is available on any data source attribute (`[MethodDataSource]`, `[ClassDataSource]`, custom `DataSourceGenerator` attributes, etc.). If **any** data source on a test sets it, the entire test's case expansion is deferred. It has no effect on `[Arguments]` (a single inline row, so there is nothing to defer).
2727

2828
:::info
29-
The placeholder is a **container**, not a test: it is not reported as its own result, so it does not count toward your test totals, and the individual cases (nested under it) carry the real pass/fail results. If the data source itself throws while enumerating, the error surfaces as a failed result at run time (just as a non-deferred data source error would) instead of failing discovery for the whole assembly.
29+
The placeholder is reported as a **container**: the individual cases (nested under it) carry the real pass/fail results, and the placeholder's own result aggregates them — it passes only if every case passes, and fails if any case fails. Because it is reported, it adds one extra entry to flat result counts (TRX/console) per deferred test. If the data source itself throws while enumerating, the error surfaces as a failed result at run time (just as a non-deferred data source error would) instead of failing discovery for the whole assembly.
3030
:::
3131

3232
:::warning Trade-offs

0 commit comments

Comments
 (0)