Skip to content

Commit c0000c1

Browse files
thomhurstclaude
andauthored
fix: evaluate SkipAttribute before data source initialization (#4737) (#4743)
Derived SkipAttribute subclasses (e.g., skip-in-CI attributes) set SkipReason during OnTestRegistered, but the execution path called CreateInstanceAsync() — triggering expensive IAsyncInitializer calls — before checking SkipReason. If initialization threw (e.g., no database in CI), the test was marked as failed instead of skipped. Three layered fixes: 1. TestFilterService.RegisterTest: fire OnTestRegistered event receivers BEFORE RegisterTestArgumentsAsync, and skip argument registration entirely when SkipReason is set. This prevents ClassDataSource initialization for skipped tests during the registration phase. 2. TestCoordinator.ExecuteTestInternalAsync: check SkipReason before entering retry/timeout logic and instance creation. 3. TestCoordinator.ExecuteTestLifecycleAsync: check SkipReason before CreateInstanceAsync() to prevent data source initialization that would fail or waste resources for skipped tests. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a882cae commit c0000c1

4 files changed

Lines changed: 167 additions & 13 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using Shouldly;
2+
using TUnit.Engine.Tests.Enums;
3+
4+
namespace TUnit.Engine.Tests;
5+
6+
/// <summary>
7+
/// Regression tests for https://github.com/thomhurst/TUnit/issues/4737
8+
/// Verifies that derived SkipAttribute subclasses properly skip tests
9+
/// when combined with ClassDataSource, even when the data source's
10+
/// IAsyncInitializer would throw.
11+
/// </summary>
12+
public class DerivedSkipWithClassDataSourceTests(TestMode testMode) : InvokableTestBase(testMode)
13+
{
14+
[Test]
15+
public async Task DerivedSkipWithFailingDataSource_ShouldBeSkipped()
16+
{
17+
await RunTestsWithFilter(
18+
"/*/*/DerivedSkipWithFailingClassDataSourceTests/*",
19+
[
20+
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
21+
result => result.ResultSummary.Counters.Total.ShouldBe(1),
22+
result => result.ResultSummary.Counters.Passed.ShouldBe(0),
23+
result => result.ResultSummary.Counters.Failed.ShouldBe(0),
24+
result => result.ResultSummary.Counters.NotExecuted.ShouldBe(1)
25+
]);
26+
}
27+
28+
[Test]
29+
public async Task DerivedSkipWithSucceedingDataSource_ShouldBeSkipped()
30+
{
31+
await RunTestsWithFilter(
32+
"/*/*/DerivedSkipWithSucceedingClassDataSourceTests/*",
33+
[
34+
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
35+
result => result.ResultSummary.Counters.Total.ShouldBe(1),
36+
result => result.ResultSummary.Counters.Passed.ShouldBe(0),
37+
result => result.ResultSummary.Counters.Failed.ShouldBe(0),
38+
result => result.ResultSummary.Counters.NotExecuted.ShouldBe(1)
39+
]);
40+
}
41+
}

TUnit.Engine/Services/TestExecution/TestCoordinator.cs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ private async ValueTask ExecuteTestInternalAsync(AbstractExecutableTest test, Ca
6363

6464
_contextRestorer.RestoreContext(test);
6565

66+
// Check if test was already marked as skipped during registration
67+
// (e.g., by a derived SkipAttribute evaluated in OnTestRegistered).
68+
// This must be checked before any instance creation or retry/timeout logic.
69+
if (!string.IsNullOrEmpty(test.Context.SkipReason))
70+
{
71+
_stateManager.MarkSkipped(test, test.Context.SkipReason!);
72+
await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
73+
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
74+
return;
75+
}
76+
6677
// Check if test was already marked as failed during registration (e.g., property injection failure)
6778
// If so, skip execution and report the failure immediately
6879
var existingResult = test.Context.Execution.Result;
@@ -249,12 +260,29 @@ await _eventReceiverOrchestrator.InvokeLastTestInSessionEventReceiversAsync(
249260
/// </summary>
250261
private async ValueTask ExecuteTestLifecycleAsync(AbstractExecutableTest test, CancellationToken cancellationToken)
251262
{
263+
// Check if this test should be skipped before creating the class instance.
264+
// Derived SkipAttribute subclasses set SkipReason during OnTestRegistered (registration phase),
265+
// and creating the instance can trigger expensive data source initialization (e.g., starting a
266+
// WebApplicationFactory) that would fail or waste resources for tests that should be skipped.
267+
if (!string.IsNullOrEmpty(test.Context.SkipReason))
268+
{
269+
_stateManager.MarkSkipped(test, test.Context.SkipReason!);
270+
271+
await _eventReceiverOrchestrator.InvokeTestSkippedEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
272+
273+
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context, cancellationToken).ConfigureAwait(false);
274+
275+
return;
276+
}
277+
252278
test.Context.Metadata.TestDetails.ClassInstance = await test.CreateInstanceAsync().ConfigureAwait(false);
253279

254280
// Invalidate cached eligible event objects since ClassInstance changed
255281
test.Context.CachedEligibleEventObjects = null;
256282

257-
// Check if this test should be skipped (after creating instance)
283+
// Check if this test should be skipped (after creating instance).
284+
// This handles basic [Skip] attributes that use SkippedTestInstance as a sentinel,
285+
// and any SkipReason set during instance creation.
258286
if (test.Context.Metadata.TestDetails.ClassInstance is SkippedTestInstance ||
259287
!string.IsNullOrEmpty(test.Context.SkipReason))
260288
{

TUnit.Engine/Services/TestFilterService.cs

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,11 @@ private async Task RegisterTest(AbstractExecutableTest test)
7272

7373
test.Context.InternalDiscoveredTest = discoveredTest;
7474

75-
try
76-
{
77-
await testArgumentRegistrationService.RegisterTestArgumentsAsync(test.Context);
78-
}
79-
catch (Exception ex)
80-
{
81-
// Mark the test as failed and skip further event receiver processing
82-
test.SetResult(TestState.Failed, ex);
83-
return;
84-
}
85-
86-
// Use pre-computed receivers (already filtered, sorted, and scoped-attribute filtered)
75+
// Invoke event receivers BEFORE argument registration so that SkipAttribute
76+
// and other ITestRegisteredEventReceiver implementations can set SkipReason
77+
// before any potentially expensive data source initialization occurs.
78+
// This is critical for derived SkipAttribute subclasses (e.g., skip-in-CI attributes)
79+
// that need to prevent ClassDataSource initialization when the test should be skipped.
8780
foreach (var receiver in test.Context.GetTestRegisteredReceivers())
8881
{
8982
try
@@ -97,6 +90,25 @@ private async Task RegisterTest(AbstractExecutableTest test)
9790
}
9891
}
9992

93+
// If the test was marked as skipped by an event receiver, skip argument registration entirely.
94+
// This avoids initializing expensive shared data sources (e.g., WebApplicationFactory)
95+
// for tests that will never execute.
96+
if (!string.IsNullOrEmpty(test.Context.SkipReason))
97+
{
98+
test.Context.InvalidateDisplayNameCache();
99+
return;
100+
}
101+
102+
try
103+
{
104+
await testArgumentRegistrationService.RegisterTestArgumentsAsync(test.Context);
105+
}
106+
catch (Exception ex)
107+
{
108+
// Mark the test as failed - event receivers have already run above
109+
test.SetResult(TestState.Failed, ex);
110+
}
111+
100112
// Clear the cached display name after registration events
101113
// This ensures that ArgumentDisplayFormatterAttribute and similar attributes
102114
// have a chance to register their formatters before the display name is finalized
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using TUnit.Core.Interfaces;
2+
3+
namespace TUnit.TestProject.Bugs._4737;
4+
5+
/// <summary>
6+
/// Derived SkipAttribute that always skips - simulates a CI-environment skip attribute
7+
/// like the user's IntegrationTestAttribute in issue #4737.
8+
/// </summary>
9+
public class AlwaysSkipAttribute() : SkipAttribute("Skip in CI environment")
10+
{
11+
public override Task<bool> ShouldSkip(TestRegisteredContext context)
12+
{
13+
return Task.FromResult(true);
14+
}
15+
}
16+
17+
/// <summary>
18+
/// A data source that throws during InitializeAsync - simulates a WebApplicationFactory
19+
/// that fails to connect to a database that doesn't exist in CI.
20+
/// </summary>
21+
public class FailingDataSource : IAsyncInitializer
22+
{
23+
public Task InitializeAsync()
24+
{
25+
throw new InvalidOperationException("Simulated infrastructure failure: cannot connect to database");
26+
}
27+
}
28+
29+
/// <summary>
30+
/// Regression test for https://github.com/thomhurst/TUnit/issues/4737
31+
/// A class-level derived SkipAttribute combined with a ClassDataSource whose
32+
/// IAsyncInitializer would throw. The test must be reported as skipped, not failed.
33+
/// </summary>
34+
[ClassDataSource<FailingDataSource>(Shared = SharedType.PerTestSession)]
35+
[AlwaysSkip]
36+
public class DerivedSkipWithFailingClassDataSourceTests(FailingDataSource dataSource)
37+
{
38+
[Test]
39+
public void Test()
40+
{
41+
throw new Exception("This test should have been skipped, not executed!");
42+
}
43+
}
44+
45+
/// <summary>
46+
/// A data source that succeeds initialization.
47+
/// </summary>
48+
public class SucceedingDataSource : IAsyncInitializer
49+
{
50+
public bool IsInitialized { get; private set; }
51+
52+
public Task InitializeAsync()
53+
{
54+
IsInitialized = true;
55+
return Task.CompletedTask;
56+
}
57+
}
58+
59+
/// <summary>
60+
/// Verifies that a derived SkipAttribute at the class level correctly skips tests
61+
/// even when a ClassDataSource is present and would succeed. The test body would
62+
/// fail if executed, proving the skip is effective.
63+
/// </summary>
64+
[ClassDataSource<SucceedingDataSource>(Shared = SharedType.PerTestSession)]
65+
[AlwaysSkip]
66+
public class DerivedSkipWithSucceedingClassDataSourceTests(SucceedingDataSource dataSource)
67+
{
68+
[Test]
69+
public void Test()
70+
{
71+
throw new Exception("This test should have been skipped, not executed!");
72+
}
73+
}

0 commit comments

Comments
 (0)