Skip to content
Merged
7 changes: 7 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"dotnet-template-authoring"
],
"rollForward": false
},
"verify.tool": {
"version": "0.6.0",
"commands": [
"dotnet-verify"
],
"rollForward": false
}
}
}
52 changes: 43 additions & 9 deletions TUnit.Core/Interfaces/ITestExecution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,18 +47,52 @@ public interface ITestExecution
/// </summary>
Func<TestContext, Exception, int, Task<bool>>? RetryFunc { get; }

/// <summary>
/// Overrides the test result with a passed state and custom reason.
/// Useful for marking tests as passed under special conditions.
/// </summary>
/// <param name="reason">The reason for overriding the result</param>
void OverrideResult(string reason);

/// <summary>
/// Overrides the test result with a specific state and custom reason.
/// </summary>
/// <param name="state">The desired test state (Passed, Failed, Skipped, etc.)</param>
/// <param name="reason">The reason for overriding the result</param>
/// <param name="state">The desired test state (Passed, Failed, Skipped, Timeout, or Cancelled)</param>
/// <param name="reason">The reason for overriding the result (cannot be empty)</param>
/// <exception cref="ArgumentException">Thrown when reason is empty, whitespace, or state is invalid (NotStarted, WaitingForDependencies, Queued, Running)</exception>
/// <exception cref="InvalidOperationException">Thrown when result has already been overridden</exception>
/// <remarks>
/// This method can only be called once per test. Subsequent calls will throw an exception.
/// Only final states are allowed: Passed, Failed, Skipped, Timeout, or Cancelled. Intermediate states like Running, Queued, NotStarted, or WaitingForDependencies are rejected.
/// The original exception (if any) is preserved in <see cref="TestResult.OriginalException"/>.
/// When overriding to Failed, the original exception is retained in <see cref="TestResult.Exception"/>.
/// When overriding to Passed or Skipped, the Exception property is cleared but preserved in OriginalException.
/// Best practice: Call this from <see cref="ITestEndEventReceiver.OnTestEnd"/> or After(Test) hooks.
/// </remarks>
/// <example>
/// <code>
/// // Override failed test to passed
/// public class RetryOnInfrastructureErrorAttribute : Attribute, ITestEndEventReceiver
/// {
/// public ValueTask OnTestEnd(TestContext context)
/// {
/// if (context.Result?.Exception is HttpRequestException)
/// {
/// context.Execution.OverrideResult(TestState.Passed, "Infrastructure error - not a test failure");
/// }
/// return default;
/// }
/// public int Order => 0;
/// }
///
/// // Override failed test to skipped
/// public class IgnoreOnWeekendAttribute : Attribute, ITestEndEventReceiver
/// {
/// public ValueTask OnTestEnd(TestContext context)
/// {
/// if (context.Result?.State == TestState.Failed &amp;&amp; DateTime.Now.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
/// {
/// context.Execution.OverrideResult(TestState.Skipped, "Failures ignored on weekends");
/// }
/// return default;
/// }
/// public int Order => 0;
/// }
/// </code>
/// </example>
void OverrideResult(TestState state, string reason);

/// <summary>
Expand Down
68 changes: 49 additions & 19 deletions TUnit.Core/TestContext.Execution.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,32 +62,62 @@ bool ITestExecution.ReportResult
set => ReportResult = value;
}

void ITestExecution.OverrideResult(string reason) => OverrideResult(reason);
void ITestExecution.OverrideResult(TestState state, string reason) => OverrideResult(state, reason);
void ITestExecution.AddLinkedCancellationToken(CancellationToken cancellationToken) => AddLinkedCancellationToken(cancellationToken);

// Internal implementation methods
internal void OverrideResult(string reason)
{
OverrideResult(TestState.Passed, reason);
}

internal void OverrideResult(TestState state, string reason)
{
Result = new TestResult
lock (Lock)
{
State = state,
OverrideReason = reason,
IsOverridden = true,
Start = TestStart ?? DateTimeOffset.UtcNow,
End = DateTimeOffset.UtcNow,
Duration = DateTimeOffset.UtcNow - (TestStart ?? DateTimeOffset.UtcNow),
Exception = null,
ComputerName = Environment.MachineName,
TestContext = this
};

InternalExecutableTest.State = state;
if (string.IsNullOrWhiteSpace(reason))
{
throw new ArgumentException("Override reason cannot be empty or whitespace.", nameof(reason));
}

if (Result?.IsOverridden == true)
{
throw new InvalidOperationException(
$"Result has already been overridden to {Result.State} with reason: '{Result.OverrideReason}'. " +
"Cannot override a result multiple times. Check Result.IsOverridden before calling OverrideResult().");
}

if (state is TestState.NotStarted or TestState.WaitingForDependencies or TestState.Queued or TestState.Running)
{
throw new ArgumentException(
$"Cannot override to intermediate state '{state}'. " +
"Only final states (Passed, Failed, Skipped, Timeout, Cancelled) are allowed.",
nameof(state));
}

var originalException = Result?.Exception;

Exception? exceptionForResult;
if (state == TestState.Failed)
{
exceptionForResult = originalException ?? new InvalidOperationException($"Test overridden to failed: {reason}");
}
else
{
exceptionForResult = null;
}

Result = new TestResult
{
State = state,
OverrideReason = reason,
IsOverridden = true,
OriginalException = originalException,
Start = TestStart ?? DateTimeOffset.UtcNow,
End = DateTimeOffset.UtcNow,
Duration = DateTimeOffset.UtcNow - (TestStart ?? DateTimeOffset.UtcNow),
Exception = exceptionForResult,
ComputerName = Environment.MachineName,
TestContext = this
};

InternalExecutableTest.State = state;
}
}

internal void AddLinkedCancellationToken(CancellationToken cancellationToken)
Expand Down
17 changes: 15 additions & 2 deletions TUnit.Core/TestResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ public record TestResult
[JsonIgnore]
internal TestContext? TestContext { get; init; }

public string? OverrideReason { get; set; }
public bool IsOverridden { get; set; }
/// <summary>
/// The reason provided when this result was overridden.
/// </summary>
public string? OverrideReason { get; init; }

/// <summary>
/// Indicates whether this result was explicitly overridden via <see cref="TestContext.Execution.OverrideResult"/>.
/// </summary>
public bool IsOverridden { get; init; }

/// <summary>
/// The original exception that occurred before the result was overridden.
/// Useful for debugging and audit trails when a test failure is overridden to pass or skip.
/// </summary>
public Exception? OriginalException { get; init; }
}
10 changes: 5 additions & 5 deletions TUnit.Engine.Tests/OverrideResultsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ public async Task Test()
await RunTestsWithFilter(
"/*/*/OverrideResultsTests/*",
[
result => result.ResultSummary.Outcome.ShouldBe("Completed"),
result => result.ResultSummary.Counters.Total.ShouldBe(1),
result => result.ResultSummary.Counters.Passed.ShouldBe(1),
result => result.ResultSummary.Counters.Failed.ShouldBe(0),
result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0)
result => result.ResultSummary.Outcome.ShouldBe("Failed"),
result => result.ResultSummary.Counters.Total.ShouldBe(4),
result => result.ResultSummary.Counters.Passed.ShouldBe(2),
result => result.ResultSummary.Counters.Failed.ShouldBe(1),
result => result.ResultSummary.Counters.NotExecuted.ShouldBe(1)
]);
}
}
55 changes: 22 additions & 33 deletions TUnit.Engine/TestExecutor.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.ExceptionServices;
using TUnit.Core;
using TUnit.Core.Exceptions;
using TUnit.Core.Interfaces;
Expand Down Expand Up @@ -57,11 +58,13 @@ public async Task ExecuteAsync(AbstractExecutableTest executableTest, Cancellati
var testClass = executableTest.Metadata.TestClassType;
var testAssembly = testClass.Assembly;

Exception? capturedException = null;
Exception? hookException = null;

try
{
await EnsureTestSessionHooksExecutedAsync().ConfigureAwait(false);

// Event receivers have their own internal coordination to run once
await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(
executableTest.Context,
executableTest.Context.ClassContext.AssemblyContext.TestSessionContext,
Expand All @@ -72,7 +75,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(
await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(testAssembly, assembly => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, CancellationToken.None))
.ConfigureAwait(false);

// Event receivers for first test in assembly
await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(
executableTest.Context,
executableTest.Context.ClassContext.AssemblyContext,
Expand All @@ -83,7 +85,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(
await _beforeHookTaskCache.GetOrCreateBeforeClassTask(testClass, _ => _hookExecutor.ExecuteBeforeClassHooksAsync(testClass, CancellationToken.None))
.ConfigureAwait(false);

// Event receivers for first test in class
await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
executableTest.Context,
executableTest.Context.ClassContext,
Expand All @@ -97,7 +98,6 @@ await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(

executableTest.Context.RestoreExecutionContext();

// Only wrap the actual test execution with timeout, not the hooks
var testTimeout = executableTest.Context.Metadata.TestDetails.Timeout;
var timeoutMessage = testTimeout.HasValue
? $"Test '{executableTest.Context.Metadata.TestDetails.TestName}' execution timed out after {testTimeout.Value}"
Expand All @@ -111,51 +111,40 @@ await TimeoutHelper.ExecuteWithTimeoutAsync(

executableTest.SetResult(TestState.Passed);
}
catch (SkipTestException)
catch (SkipTestException ex)
{
executableTest.SetResult(TestState.Skipped);
throw;
capturedException = ex;
}
catch (Exception ex)
{
executableTest.SetResult(TestState.Failed, ex);

// Run per-retry cleanup hooks before re-throwing
capturedException = ex;
}
finally
{
try
{
// Run After(Test) hooks
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);

await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);
}
catch
catch (Exception ex)
{
// Swallow any exceptions from hooks when we already have a test failure
hookException = ex;
}
}

// Check if the result was overridden - if so, don't re-throw
if (executableTest.Context.Execution.Result is { IsOverridden: true, State: TestState.Passed })
{
// Result was overridden to passed, don't re-throw the exception
executableTest.SetResult(TestState.Passed);
}
else
{
throw;
}
if (capturedException == null && hookException != null)
{
ExceptionDispatchInfo.Capture(hookException).Throw();
}
finally
else if (capturedException is SkipTestException)
{
// Per-retry cleanup - runs for success path only
if (executableTest.State != TestState.Failed)
{
// Run After(Test) hooks
await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);

await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, cancellationToken).ConfigureAwait(false);
}
// Note: Test instance disposal and After(Class/Assembly/Session) hooks
// are now handled in TestCoordinator after the retry loop completes
ExceptionDispatchInfo.Capture(capturedException).Throw();
}
else if (capturedException != null && executableTest.Context.Execution.Result?.IsOverridden != true)
{
ExceptionDispatchInfo.Capture(capturedException).Throw();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1460,9 +1460,10 @@ namespace
public required ? Duration { get; init; }
public required ? End { get; init; }
public required ? Exception { get; init; }
public bool IsOverridden { get; set; }
public bool IsOverridden { get; init; }
public ? OriginalException { get; init; }
public string? Output { get; }
public string? OverrideReason { get; set; }
public string? OverrideReason { get; init; }
public required ? Start { get; init; }
public required .TestState State { get; init; }
}
Expand Down Expand Up @@ -2329,7 +2330,6 @@ namespace .Interfaces
? TestEnd { get; }
? TestStart { get; }
void AddLinkedCancellationToken(.CancellationToken cancellationToken);
void OverrideResult(string reason);
void OverrideResult(.TestState state, string reason);
}
public interface ITestExecutor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1460,9 +1460,10 @@ namespace
public required ? Duration { get; init; }
public required ? End { get; init; }
public required ? Exception { get; init; }
public bool IsOverridden { get; set; }
public bool IsOverridden { get; init; }
public ? OriginalException { get; init; }
public string? Output { get; }
public string? OverrideReason { get; set; }
public string? OverrideReason { get; init; }
public required ? Start { get; init; }
public required .TestState State { get; init; }
}
Expand Down Expand Up @@ -2329,7 +2330,6 @@ namespace .Interfaces
? TestEnd { get; }
? TestStart { get; }
void AddLinkedCancellationToken(.CancellationToken cancellationToken);
void OverrideResult(string reason);
void OverrideResult(.TestState state, string reason);
}
public interface ITestExecutor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1460,9 +1460,10 @@ namespace
public required ? Duration { get; init; }
public required ? End { get; init; }
public required ? Exception { get; init; }
public bool IsOverridden { get; set; }
public bool IsOverridden { get; init; }
public ? OriginalException { get; init; }
public string? Output { get; }
public string? OverrideReason { get; set; }
public string? OverrideReason { get; init; }
public required ? Start { get; init; }
public required .TestState State { get; init; }
}
Expand Down Expand Up @@ -2329,7 +2330,6 @@ namespace .Interfaces
? TestEnd { get; }
? TestStart { get; }
void AddLinkedCancellationToken(.CancellationToken cancellationToken);
void OverrideResult(string reason);
void OverrideResult(.TestState state, string reason);
}
public interface ITestExecutor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1413,9 +1413,10 @@ namespace
public required ? Duration { get; init; }
public required ? End { get; init; }
public required ? Exception { get; init; }
public bool IsOverridden { get; set; }
public bool IsOverridden { get; init; }
public ? OriginalException { get; init; }
public string? Output { get; }
public string? OverrideReason { get; set; }
public string? OverrideReason { get; init; }
public required ? Start { get; init; }
public required .TestState State { get; init; }
}
Expand Down Expand Up @@ -2260,7 +2261,6 @@ namespace .Interfaces
? TestEnd { get; }
? TestStart { get; }
void AddLinkedCancellationToken(.CancellationToken cancellationToken);
void OverrideResult(string reason);
void OverrideResult(.TestState state, string reason);
}
public interface ITestExecutor
Expand Down
Loading
Loading