Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: add cancellation result handling in test execution
  • Loading branch information
thomhurst committed Aug 8, 2025
commit 76ae4f6c8fd74a9157e29f37e5477b85d5f3fda0
1 change: 1 addition & 0 deletions TUnit.Engine/Services/ITestResultFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ internal interface ITestResultFactory
TestResult CreateFailedResult(DateTimeOffset startTime, Exception exception);
TestResult CreateSkippedResult(DateTimeOffset startTime, string reason);
TestResult CreateTimeoutResult(DateTimeOffset startTime, int timeoutMs);
TestResult? CreateCancelledResult(DateTimeOffset testStartTime);
}
154 changes: 86 additions & 68 deletions TUnit.Engine/Services/SingleTestExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,19 @@ internal class SingleTestExecutor : ISingleTestExecutor
private readonly ITestResultFactory _resultFactory;
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
private readonly IHookCollectionService _hookCollectionService;
private readonly EngineCancellationToken _engineCancellationToken;
private SessionUid _sessionUid;

public SingleTestExecutor(TUnitFrameworkLogger logger, EventReceiverOrchestrator eventReceiverOrchestrator, IHookCollectionService hookCollectionService, SessionUid sessionUid)
public SingleTestExecutor(TUnitFrameworkLogger logger,
EventReceiverOrchestrator eventReceiverOrchestrator,
IHookCollectionService hookCollectionService,
EngineCancellationToken engineCancellationToken,
SessionUid sessionUid)
{
_logger = logger;
_eventReceiverOrchestrator = eventReceiverOrchestrator;
_hookCollectionService = hookCollectionService;
_engineCancellationToken = engineCancellationToken;
_sessionUid = sessionUid;
_resultFactory = new TestResultFactory();
}
Expand Down Expand Up @@ -74,95 +80,102 @@ private async Task<TestResult> ExecuteTestInternalAsync(
test.StartTime = DateTimeOffset.Now;
test.State = TestState.Running;

if (!string.IsNullOrEmpty(test.Context.SkipReason))
{
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}
if (!string.IsNullOrEmpty(test.Context.SkipReason))
{
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}

if (test.Context.TestDetails.ClassInstance is SkippedTestInstance)
{
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}
if (test.Context.TestDetails.ClassInstance is SkippedTestInstance)
{
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}

if (test.Context.TestDetails.ClassInstance is PlaceholderInstance)
{
var createdInstance = await test.CreateInstanceAsync();
if (createdInstance == null)
if (test.Context.TestDetails.ClassInstance is PlaceholderInstance)
{
var createdInstance = await test.CreateInstanceAsync();
if (createdInstance == null)
{
throw new InvalidOperationException($"CreateInstanceAsync returned null for test {test.Context.GetDisplayName()}. This is likely a framework bug.");
}
test.Context.TestDetails.ClassInstance = createdInstance;
}

var instance = test.Context.TestDetails.ClassInstance;

if (instance == null)
{
throw new InvalidOperationException($"CreateInstanceAsync returned null for test {test.Context.GetDisplayName()}. This is likely a framework bug.");
throw new InvalidOperationException(
$"Test instance is null for test {test.Context.GetDisplayName()} after instance creation. ClassInstance type: {test.Context.TestDetails.ClassInstance?.GetType()?.Name ?? "null"}");
}
test.Context.TestDetails.ClassInstance = createdInstance;
}

var instance = test.Context.TestDetails.ClassInstance;

if (instance == null)
{
throw new InvalidOperationException($"Test instance is null for test {test.Context.GetDisplayName()} after instance creation. ClassInstance type: {test.Context.TestDetails.ClassInstance?.GetType()?.Name ?? "null"}");
}

if (instance is PlaceholderInstance)
{
throw new InvalidOperationException($"Test instance is still PlaceholderInstance for test {test.Context.GetDisplayName()}. This should have been replaced.");
}
if (instance is PlaceholderInstance)
{
throw new InvalidOperationException($"Test instance is still PlaceholderInstance for test {test.Context.GetDisplayName()}. This should have been replaced.");
}

await PropertyInjectionService.InjectPropertiesIntoArgumentsAsync(test.ClassArguments, test.Context.ObjectBag, test.Context.TestDetails.MethodMetadata, test.Context.Events);
await PropertyInjectionService.InjectPropertiesIntoArgumentsAsync(test.Arguments, test.Context.ObjectBag, test.Context.TestDetails.MethodMetadata, test.Context.Events);
await PropertyInjectionService.InjectPropertiesIntoArgumentsAsync(test.ClassArguments, test.Context.ObjectBag, test.Context.TestDetails.MethodMetadata,
test.Context.Events);
await PropertyInjectionService.InjectPropertiesIntoArgumentsAsync(test.Arguments, test.Context.ObjectBag, test.Context.TestDetails.MethodMetadata,
test.Context.Events);

await PropertyInjectionService.InjectPropertiesAsync(
test.Context,
instance,
test.Metadata.PropertyDataSources,
test.Metadata.PropertyInjections,
test.Metadata.MethodMetadata,
test.Context.TestDetails.TestId);
await PropertyInjectionService.InjectPropertiesAsync(
test.Context,
instance,
test.Metadata.PropertyDataSources,
test.Metadata.PropertyInjections,
test.Metadata.MethodMetadata,
test.Context.TestDetails.TestId);

await _eventReceiverOrchestrator.InitializeAllEligibleObjectsAsync(test.Context, cancellationToken);
await _eventReceiverOrchestrator.InitializeAllEligibleObjectsAsync(test.Context, cancellationToken);

CheckDependenciesAndThrowIfShouldSkip(test);
CheckDependenciesAndThrowIfShouldSkip(test);

var classContext = test.Context.ClassContext;
var assemblyContext = classContext.AssemblyContext;
var sessionContext = assemblyContext.TestSessionContext;
var classContext = test.Context.ClassContext;
var assemblyContext = classContext.AssemblyContext;
var sessionContext = assemblyContext.TestSessionContext;

await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(test.Context, sessionContext, cancellationToken);
await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(test.Context, sessionContext, cancellationToken);

await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(test.Context, assemblyContext, cancellationToken);
await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(test.Context, assemblyContext, cancellationToken);

await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(test.Context, classContext, cancellationToken);
await _eventReceiverOrchestrator.InvokeTestStartEventReceiversAsync(test.Context, cancellationToken);
await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(test.Context, classContext, cancellationToken);
await _eventReceiverOrchestrator.InvokeTestStartEventReceiversAsync(test.Context, cancellationToken);

try
{
if (!string.IsNullOrEmpty(test.Context.SkipReason))
try
{
if (!string.IsNullOrEmpty(test.Context.SkipReason))
{
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}

if (test.Context is { RetryFunc: not null, TestDetails.RetryLimit: > 0 })
{
await ExecuteTestWithRetries(() => ExecuteTestWithHooksAsync(test, instance, cancellationToken), test.Context, cancellationToken);
}
else
{
await ExecuteTestWithHooksAsync(test, instance, cancellationToken);
}
}
catch (TestDependencyException e)
{
test.Context.SkipReason = e.Message;
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}

if(test.Context is { RetryFunc: not null, TestDetails.RetryLimit: > 0 })
catch (Exception exception) when (_engineCancellationToken.Token.IsCancellationRequested && exception is OperationCanceledException or TaskCanceledException)
{
await ExecuteTestWithRetries(() => ExecuteTestWithHooksAsync(test, instance, cancellationToken), test.Context, cancellationToken);
HandleCancellation(test);
}
else
catch (Exception ex)
{
await ExecuteTestWithHooksAsync(test, instance, cancellationToken);
HandleTestFailure(test, ex);
}
}
catch (TestDependencyException e)
{
test.Context.SkipReason = e.Message;
return await HandleSkippedTestInternalAsync(test, cancellationToken);
}
catch (Exception ex)
{
HandleTestFailure(test, ex);
}
finally
{
test.EndTime = DateTimeOffset.Now;
finally
{
test.EndTime = DateTimeOffset.Now;

await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context!, cancellationToken);
}
await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(test.Context!, cancellationToken);
}

if (test.Result == null)
{
Expand Down Expand Up @@ -371,6 +384,11 @@ private void HandleTestFailure(AbstractExecutableTest test, Exception ex)
}
}

private void HandleCancellation(AbstractExecutableTest test)
{
test.State = TestState.Cancelled;
test.Result = _resultFactory.CreateCancelledResult(test.StartTime!.Value);
}

private TestNodeUpdateMessage CreateUpdateMessage(AbstractExecutableTest test)
{
Expand Down
15 changes: 15 additions & 0 deletions TUnit.Engine/Services/TestResultFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,19 @@ public TestResult CreateTimeoutResult(DateTimeOffset startTime, int timeoutMs)
OverrideReason = $"Test exceeded timeout of {timeoutMs}ms"
};
}

public TestResult? CreateCancelledResult(DateTimeOffset startTime)
{
var endTime = DateTimeOffset.Now;

return new TestResult
{
State = TestState.Cancelled,
Start = startTime,
End = endTime,
Duration = endTime - startTime,
Exception = null,
ComputerName = Environment.MachineName
};
}
}
Loading