-
-
Notifications
You must be signed in to change notification settings - Fork 111
Description
Problem
When a [ClassDataSource<T>(Shared = SharedType.PerTestSession)] fixture implements IAsyncInitializer, its InitializeAsync() is subject to the cancellation token of whichever individual test triggers it first. If that test has a short [Timeout], the fixture initialization gets cancelled — even though the fixture is shared across the entire session.
This causes all tests that depend on the fixture to fail, not just the one with the short timeout.
Reproduction
public class SlowFixture : IAsyncInitializer, IAsyncDisposable
{
public async Task InitializeAsync()
{
// Takes 30+ seconds (e.g. starting Docker containers via Aspire)
await StartDistributedApp();
}
}
public class MyTests
{
[ClassDataSource<SlowFixture>(Shared = SharedType.PerTestSession)]
public required SlowFixture Fixture { get; init; }
[Test, Timeout(5_000)] // 5s timeout — too short for fixture init
public async Task Quick_Test(CancellationToken cancellationToken)
{
// If this test triggers the fixture initialization first,
// the 5s timeout cancels the fixture init for ALL tests
var response = await Fixture.Client.GetAsync("/health");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
[Test, Timeout(60_000)] // Even this test fails because the fixture is already cancelled
public async Task Longer_Test(CancellationToken cancellationToken)
{
// ...
}
}Root Cause
In ObjectInitializer.InitializeCoreAsync():
var lazyTask = InitializationTasks.GetOrAdd(obj,
static (_, asyncInitializer) => new Lazy<Task>(
asyncInitializer.InitializeAsync, // No cancellation token passed
LazyThreadSafetyMode.ExecutionAndPublication)
, asyncInitializer);
await lazyTask.Value.WaitAsync(cancellationToken); // Test's timeout tokenThe InitializeAsync() is called without a cancellation token (line 110-111), but the WaitAsync on line 117 uses the test's cancellation token. So:
- First test triggers initialization →
InitializeAsync()starts running - Test timeout fires →
WaitAsyncthrowsOperationCanceledException InitializeAsync()continues running in the background (no token to cancel it)- But the
Lazy<Task>stores the still-running task, and subsequent tests alsoWaitAsyncwith their own tokens - If the initialization eventually completes, later tests might succeed — but the first test and any that timed out are already failed
Expected Behavior
PerTestSession fixture initialization should NOT be constrained by individual test timeouts. Options:
- Use a separate, longer timeout for fixture initialization (configurable)
- Don't pass the test's cancellation token to
WaitAsyncfor shared fixtures - Use
CancellationToken.NoneforPerTestSessionfixtures and let them manage their own timeouts internally
Context
Discovered while building the CloudShop Aspire + TUnit example (#4761). The DistributedAppFixture starts an Aspire distributed app with Docker containers (PostgreSQL, Redis, RabbitMQ), which takes 30+ seconds. Tests with [Timeout(5_000)] would cancel the fixture initialization, causing all 207 tests to fail.