Event Subscribing
Objects associated with your tests have the ability to subscribe to lifecycle events generated by TUnit.
Objects associated with your tests can mean:
- The test class itself
- A custom class constructor
- Injected class parameter arguments
- Injected method parameter arguments
- Injected properties
- Associated attributes
The interfaces they can implement are:
- ITestRegisteredEventReceiver
- ITestStartEventReceiver
- ITestEndEventReceiver
- ILastTestInClassEventReceiver
- ILastTestInAssemblyEventReceiver
- ILastTestInTestSessionEventReceiver
This can be useful especially when generating data that you need to track and maybe dispose later. By hooking into these events, we can do things like track and dispose our objects when we need.
Execution Stage Controlâ
Note: This feature is available on .NET 8.0+ only due to default interface member requirements.
For ITestStartEventReceiver and ITestEndEventReceiver, you can control when your event receivers execute relative to instance-level hooks ([Before(Test)] and [After(Test)]) by setting the Stage property.
EventReceiverStage Optionsâ
-
EventReceiverStage.Early: Executes before instance-level hooks- Test start receivers run before
[Before(Test)]hooks - Test end receivers run before
[After(Test)]hooks
- Test start receivers run before
-
EventReceiverStage.Late(default): Executes after instance-level hooks- Test start receivers run after
[Before(Test)]hooks - Test end receivers run after
[After(Test)]hooks
- Test start receivers run after
When to Use Early Stageâ
Use EventReceiverStage.Early when your event receiver needs to:
- Initialize resources that instance-level hooks depend on
- Set up test context or environment before any test-specific setup runs
- Capture test state before any modifications from hooks
When to Use Late Stageâ
Use EventReceiverStage.Late (the default) when your event receiver needs to:
- Access resources initialized by instance-level hooks
- Clean up after all test-specific teardown completes
- Log or report on final test state after all hooks have run
Example: Early Stage Event Receiverâ
public class DatabaseConnectionAttribute : Attribute, ITestStartEventReceiver
{
private IDbConnection? _connection;
// Execute before [Before(Test)] hooks so the connection is available to them
public EventReceiverStage Stage => EventReceiverStage.Early;
public async ValueTask OnTestStart(TestContext context)
{
_connection = new SqlConnection(connectionString);
await _connection.OpenAsync();
// Store connection in test context for use by hooks and test
context.StateBag.GetOrAdd("DbConnection", _ => _connection);
}
}
public class MyTests
{
[Test]
[DatabaseConnection] // Runs BEFORE BeforeTest hook
public async Task TestWithDatabase()
{
// Database connection is already open and available
TestContext.Current!.StateBag.TryGetValue<IDbConnection>("DbConnection", out var connection);
// ... test logic
}
[Before(Test)]
public void BeforeTest()
{
// Database connection is already available here
TestContext.Current!.StateBag.TryGetValue<IDbConnection>("DbConnection", out var connection);
// ... setup that needs the database
}
}
Example: Late Stage Event Receiver (Default)â
public class TestMetricsAttribute : Attribute, ITestEndEventReceiver
{
// Late stage is the default, so this property is optional
public EventReceiverStage Stage => EventReceiverStage.Late;
public async ValueTask OnTestEnd(TestContext context)
{
// This runs AFTER all [After(Test)] hooks have completed
// So we can capture the final test metrics including cleanup time
await LogMetrics(context);
}
}
.NET Framework / .NET Standard 2.0 Behaviorâ
On older frameworks that don't support default interface members (.NET Framework, .NET Standard 2.0), the Stage property is not available. All event receivers will execute at the Late stage (after instance-level hooks), which matches the historical behavior.
Each attribute will be new'd up for each test, so you are able to store state within the fields of your attribute class.
The [ClassDataSource<T>] uses these events to do the following:
- On Test Register > Increment Counts for Various Types (Global, Keyed, etc.)
- On Test Start > Initialise any objects if they have the
IAsyncInitializerinterface - On Test End > If the object isn't shared, dispose it. Otherwise, decrement the count for the type.
- On Last Test for Class > Dispose the object being used to inject into that specific class
- On Last Test for Assembly > Dispose the object being used to inject into that specific assembly
Here's a simple Dependency Injection Class Constructor class subscribing to the TestEnd event in order to dispose the service scope when the test is finished:
public class DependencyInjectionClassConstructor : IClassConstructor, ITestEndEventReceiver
{
private readonly IServiceProvider _serviceProvider = CreateServiceProvider();
private AsyncServiceScope _scope;
public Task<object> Create([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type type, ClassConstructorMetadata classConstructorMetadata)
{
_scope = _serviceProvider.CreateAsyncScope();
var instance = ActivatorUtilities.GetServiceOrCreateInstance(_scope.ServiceProvider, type);
return Task.FromResult(instance);
}
public ValueTask OnTestEnd(TestContext testContext)
{
return _scope.DisposeAsync();
}
private static IServiceProvider CreateServiceProvider()
{
return new ServiceCollection()
.AddTransient<Class1>()
.AddTransient<Class2>()
.AddTransient<Class3>()
...
.BuildServiceProvider();
}
}