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: 19 additions & 12 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,17 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
// Run after hooks and event receivers in finally before re-throwing
try
{
// Dispose test instance before After(Class) hooks run
// Run After(Test) hooks first (before disposal)
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);

// Invoke test end event receivers
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);

// Then dispose test instance
await DisposeTestInstance(executableTest).ConfigureAwait(false);

// Always decrement counters and run After hooks if we're the last test
await ExecuteAfterHooksBasedOnLifecycle(executableTest, testClass, testAssembly, cancellationToken).ConfigureAwait(false);
// Finally run After(Class/Assembly/Session) hooks if we're the last test
await ExecuteAfterClassAssemblySessionHooks(executableTest, testClass, testAssembly, cancellationToken).ConfigureAwait(false);
}
catch
{
Expand All @@ -144,11 +150,17 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(
// This finally block now only runs for the success path
if (executableTest.State != TestState.Failed)
{
// Dispose test instance before After(Class) hooks run
// Run After(Test) hooks first (before disposal)
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);

// Invoke test end event receivers
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);

// Then dispose test instance
await DisposeTestInstance(executableTest).ConfigureAwait(false);

// Always decrement counters and run After hooks if we're the last test
await ExecuteAfterHooksBasedOnLifecycle(executableTest, testClass, testAssembly, cancellationToken).ConfigureAwait(false);
// Finally run After(Class/Assembly/Session) hooks if we're the last test
await ExecuteAfterClassAssemblySessionHooks(executableTest, testClass, testAssembly, cancellationToken).ConfigureAwait(false);
}
}
}
Expand Down Expand Up @@ -176,16 +188,11 @@ await testExecutor.ExecuteTest(executableTest.Context,
}
}

private async Task ExecuteAfterHooksBasedOnLifecycle(AbstractExecutableTest executableTest,
private async Task ExecuteAfterClassAssemblySessionHooks(AbstractExecutableTest executableTest,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties
| DynamicallyAccessedMemberTypes.PublicMethods)]
Type testClass, Assembly testAssembly, CancellationToken cancellationToken)
{
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);

// Invoke test end event receivers
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);

var flags = _lifecycleCoordinator.DecrementAndCheckAfterHooks(testClass, testAssembly);

if (executableTest.Context.Events.OnDispose != null)
Expand Down
126 changes: 126 additions & 0 deletions TUnit.TestProject/AfterTestDisposalOrderTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using TUnit.Core;
using TUnit.TestProject.Attributes;

namespace TUnit.TestProject;

/// <summary>
/// Regression test for https://github.com/thomhurst/TUnit/issues/3156
/// Ensures that After(Test) hooks are executed before the test instance is disposed.
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class AfterTestDisposalOrderTest : IAsyncDisposable
{
private bool _isDisposed;
private readonly string _testResource = "TestResource";

public ValueTask DisposeAsync()
{
_isDisposed = true;
return new ValueTask();
}

[Test]
public async Task Test_ShouldAccessResourcesInAfterTestHook()
{
// Test should be able to access resources
await Assert.That(_isDisposed).IsFalse();
await Assert.That(_testResource).IsNotNull();
await Assert.That(_testResource).IsEqualTo("TestResource");
}

[After(Test)]
public async Task AfterTest_ShouldAccessResourcesBeforeDisposal(TestContext context)
{
// After(Test) hook should be able to access instance resources before disposal
await Assert.That(_isDisposed).IsFalse().Because("Test instance should not be disposed before After(Test) hooks");
await Assert.That(_testResource).IsNotNull().Because("Should be able to access instance fields in After(Test) hook");
await Assert.That(_testResource).IsEqualTo("TestResource");

// Mark that we successfully accessed resources
context.ObjectBag.Add("AfterTestExecuted", true);
context.ObjectBag.Add("ResourceValue", _testResource);
}

[After(Class)]
public static async Task AfterClass_VerifyDisposalCompleted(ClassHookContext context)
{
// Verify that After(Test) hooks were executed
foreach (var test in context.Tests)
{
await Assert.That(test.ObjectBag.ContainsKey("AfterTestExecuted")).IsTrue().Because("After(Test) hook should have executed");
await Assert.That(test.ObjectBag["AfterTestExecuted"]).IsEqualTo(true);
await Assert.That(test.ObjectBag["ResourceValue"]).IsEqualTo("TestResource");
}

// By the time After(Class) runs, all test instances should be disposed
// We can't directly check _isDisposed here since this is a static method,
// but the fact that After(Test) ran successfully proves the order is correct
}
}

/// <summary>
/// Additional test to verify disposal order with async operations
/// </summary>
[EngineTest(ExpectedResult.Pass)]
public class AsyncAfterTestDisposalOrderTest : IAsyncDisposable
{
private MyAsyncResource? _resource;
private bool _isDisposed;

public AsyncAfterTestDisposalOrderTest()
{
_resource = new MyAsyncResource();
}

public async ValueTask DisposeAsync()
{
if (_resource != null)
{
await _resource.DisposeAsync();
_resource = null;
}
_isDisposed = true;
}

[Test]
public async Task Test_WithAsyncResource()
{
await Assert.That(_resource).IsNotNull();
await Assert.That(_resource!.IsDisposed).IsFalse();

var value = await _resource.GetValueAsync();
await Assert.That(value).IsEqualTo("AsyncValue");
}

[After(Test)]
public async Task AfterTest_ShouldAccessAsyncResourceBeforeDisposal()
{
await Assert.That(_isDisposed).IsFalse().Because("Test instance should not be disposed before After(Test) hooks");
await Assert.That(_resource).IsNotNull().Because("Resource should still be available in After(Test) hook");
await Assert.That(_resource!.IsDisposed).IsFalse().Because("Resource should not be disposed yet");

// Should be able to use the async resource
var value = await _resource.GetValueAsync();
await Assert.That(value).IsEqualTo("AsyncValue");
}

private class MyAsyncResource : IAsyncDisposable
{
public bool IsDisposed { get; private set; }

public Task<string> GetValueAsync()
{
if (IsDisposed)
{
throw new ObjectDisposedException(nameof(MyAsyncResource));
}
return Task.FromResult("AsyncValue");
}

public ValueTask DisposeAsync()
{
IsDisposed = true;
return new ValueTask();
}
}
}
Loading