-
-
Notifications
You must be signed in to change notification settings - Fork 135
Expand file tree
/
Copy pathTestExecutor.cs
More file actions
424 lines (364 loc) · 17.9 KB
/
Copy pathTestExecutor.cs
File metadata and controls
424 lines (364 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.ExceptionServices;
using TUnit.Core;
using TUnit.Core.Enums;
using TUnit.Core.Exceptions;
using TUnit.Core.Interfaces;
using TUnit.Core.Services;
using TUnit.Engine.Helpers;
using TUnit.Engine.Services;
#if NET
using System.Diagnostics;
#endif
namespace TUnit.Engine;
/// <summary>
/// Simple orchestrator that composes focused services to manage test execution flow.
/// Follows Single Responsibility Principle and SOLID principles.
/// </summary>
internal class TestExecutor
{
private readonly HookExecutor _hookExecutor;
private readonly TestLifecycleCoordinator _lifecycleCoordinator;
private readonly BeforeHookTaskCache _beforeHookTaskCache;
private readonly AfterHookPairTracker _afterHookPairTracker;
private readonly IContextProvider _contextProvider;
private readonly EventReceiverOrchestrator _eventReceiverOrchestrator;
public TestExecutor(
HookExecutor hookExecutor,
TestLifecycleCoordinator lifecycleCoordinator,
BeforeHookTaskCache beforeHookTaskCache,
AfterHookPairTracker afterHookPairTracker,
IContextProvider contextProvider,
EventReceiverOrchestrator eventReceiverOrchestrator)
{
_hookExecutor = hookExecutor;
_lifecycleCoordinator = lifecycleCoordinator;
_beforeHookTaskCache = beforeHookTaskCache;
_afterHookPairTracker = afterHookPairTracker;
_contextProvider = contextProvider;
_eventReceiverOrchestrator = eventReceiverOrchestrator;
}
/// <summary>
/// Ensures that Before(TestSession) hooks have been executed.
/// This is called before creating test instances to ensure resources are available.
/// Registers the corresponding After(TestSession) hook to run on cancellation.
/// </summary>
public async Task EnsureTestSessionHooksExecutedAsync(CancellationToken cancellationToken)
{
// Get or create and cache Before hooks - these run only once
await _beforeHookTaskCache.GetOrCreateBeforeTestSessionTask(
ct => _hookExecutor.ExecuteBeforeTestSessionHooksAsync(ct),
cancellationToken).ConfigureAwait(false);
// Register After Session hook to run on cancellation (guarantees cleanup)
_afterHookPairTracker.RegisterAfterTestSessionHook(
cancellationToken,
() => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(CancellationToken.None).AsTask()));
}
/// <summary>
/// Creates a test executor delegate that wraps the provided executor with hook orchestration.
/// Uses focused services that follow SRP to manage lifecycle and execution.
/// </summary>
public async ValueTask ExecuteAsync(AbstractExecutableTest executableTest, TestInitializer testInitializer, CancellationToken cancellationToken, TimeSpan? testTimeout = null)
{
var testClass = executableTest.Metadata.TestClassType;
var testAssembly = testClass.Assembly;
Exception? capturedException = null;
Exception? hookException = null;
try
{
await EnsureTestSessionHooksExecutedAsync(cancellationToken).ConfigureAwait(false);
await _eventReceiverOrchestrator.InvokeFirstTestInSessionEventReceiversAsync(
executableTest.Context,
executableTest.Context.ClassContext.AssemblyContext.TestSessionContext,
cancellationToken).ConfigureAwait(false);
executableTest.Context.ClassContext.AssemblyContext.TestSessionContext.RestoreExecutionContext();
await _beforeHookTaskCache.GetOrCreateBeforeAssemblyTask(
testAssembly,
(assembly, ct) => _hookExecutor.ExecuteBeforeAssemblyHooksAsync(assembly, ct),
cancellationToken).ConfigureAwait(false);
// Register After Assembly hook to run on cancellation (guarantees cleanup)
_afterHookPairTracker.RegisterAfterAssemblyHook(
testAssembly,
cancellationToken,
(assembly) => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, CancellationToken.None).AsTask()));
await _eventReceiverOrchestrator.InvokeFirstTestInAssemblyEventReceiversAsync(
executableTest.Context,
executableTest.Context.ClassContext.AssemblyContext,
cancellationToken).ConfigureAwait(false);
executableTest.Context.ClassContext.AssemblyContext.RestoreExecutionContext();
await _beforeHookTaskCache.GetOrCreateBeforeClassTask(testClass, _hookExecutor, cancellationToken).ConfigureAwait(false);
// Register After Class hook to run on cancellation (guarantees cleanup)
_afterHookPairTracker.RegisterAfterClassHook(testClass, _hookExecutor, cancellationToken);
await _eventReceiverOrchestrator.InvokeFirstTestInClassEventReceiversAsync(
executableTest.Context,
executableTest.Context.ClassContext,
cancellationToken).ConfigureAwait(false);
executableTest.Context.ClassContext.RestoreExecutionContext();
// Initialize test objects (IAsyncInitializer) AFTER BeforeClass hooks
// This ensures resources like Docker containers are not started until needed
await testInitializer.InitializeTestObjectsAsync(executableTest, cancellationToken).ConfigureAwait(false);
#if NET
if (TUnitActivitySource.Source.HasListeners())
{
var classActivity = executableTest.Context.ClassContext.Activity;
var testDetails = executableTest.Context.Metadata.TestDetails;
executableTest.Context.Activity = TUnitActivitySource.StartActivity(
"test case",
ActivityKind.Internal,
classActivity?.Context ?? default,
[
new("test.case.name", testDetails.TestName),
new("tunit.test.class", testDetails.ClassType.FullName),
new("tunit.test.method", testDetails.MethodName),
new("tunit.test.id", executableTest.Context.Id),
new("tunit.test.node_uid", testDetails.TestId),
new("tunit.test.categories", testDetails.Categories.ToArray())
]);
}
#endif
executableTest.Context.RestoreExecutionContext();
// Early stage test start receivers run before instance-level hooks
await _eventReceiverOrchestrator.InvokeTestStartEventReceiversAsync(executableTest.Context, cancellationToken, EventReceiverStage.Early).ConfigureAwait(false);
executableTest.Context.RestoreExecutionContext();
await _hookExecutor.ExecuteBeforeTestHooksAsync(executableTest, cancellationToken).ConfigureAwait(false);
// Late stage test start receivers run after instance-level hooks (default behavior)
await _eventReceiverOrchestrator.InvokeTestStartEventReceiversAsync(executableTest.Context, cancellationToken, EventReceiverStage.Late).ConfigureAwait(false);
executableTest.Context.RestoreExecutionContext();
// Only the test body is subject to the [Timeout] — hooks and data source
// initialization run outside the timeout scope (fixes #4772)
#if NET
Activity? testBodyActivity = null;
if (TUnitActivitySource.Source.HasListeners())
{
testBodyActivity = TUnitActivitySource.StartActivity(
"test body",
ActivityKind.Internal,
executableTest.Context.Activity?.Context ?? default);
}
#endif
try
{
var timeoutMessage = testTimeout.HasValue
? $"Test '{executableTest.Context.Metadata.TestDetails.TestName}' timed out after {testTimeout.Value}"
: null;
await TimeoutHelper.ExecuteWithTimeoutAsync(
ct => ExecuteTestAsync(executableTest, ct),
testTimeout,
cancellationToken,
timeoutMessage).ConfigureAwait(false);
}
catch
#if NET
(Exception ex)
#endif
{
#if NET
TUnitActivitySource.RecordException(testBodyActivity, ex);
#endif
throw;
}
finally
{
#if NET
TUnitActivitySource.StopActivity(testBodyActivity);
#endif
executableTest.Context.Execution.TestEnd ??= DateTimeOffset.UtcNow;
}
executableTest.SetResult(TestState.Passed);
}
catch (SkipTestException ex)
{
executableTest.SetResult(TestState.Skipped);
capturedException = ex;
}
catch (Exception ex)
{
executableTest.SetResult(TestState.Failed, ex);
capturedException = ex;
}
finally
{
// After hooks must use CancellationToken.None to ensure cleanup runs even when cancelled
// This matches the pattern used for After Class/Assembly hooks in TestCoordinator
// Early stage test end receivers run before instance-level hooks
var earlyStageExceptions = await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, CancellationToken.None, EventReceiverStage.Early).ConfigureAwait(false);
var hookExceptions = await _hookExecutor.ExecuteAfterTestHooksAsync(executableTest, CancellationToken.None).ConfigureAwait(false);
// Late stage test end receivers run after instance-level hooks (default behavior)
var lateStageExceptions = await _eventReceiverOrchestrator.InvokeTestEndEventReceiversAsync(executableTest.Context, CancellationToken.None, EventReceiverStage.Late).ConfigureAwait(false);
// Combine all exceptions from event receivers
var eventReceiverExceptions = new List<Exception>(earlyStageExceptions.Count + lateStageExceptions.Count);
eventReceiverExceptions.AddRange(earlyStageExceptions);
eventReceiverExceptions.AddRange(lateStageExceptions);
if (hookExceptions.Count > 0 || eventReceiverExceptions.Count > 0)
{
hookException = new TestExecutionException(null, hookExceptions, eventReceiverExceptions);
}
#if NET
FinishTestActivity(executableTest, capturedException);
#endif
}
if (capturedException is SkipTestException)
{
ExceptionDispatchInfo.Capture(capturedException).Throw();
}
else if (executableTest.Context.Execution.Result?.IsOverridden == true)
{
return;
}
else if (capturedException != null && hookException != null)
{
var combinedException = new TestExecutionException(capturedException,
(hookException as TestExecutionException)?.HookExceptions ?? [],
(hookException as TestExecutionException)?.EventReceiverExceptions ?? []);
ExceptionDispatchInfo.Capture(combinedException).Throw();
}
else if (capturedException != null)
{
ExceptionDispatchInfo.Capture(capturedException).Throw();
}
else if (hookException != null)
{
ExceptionDispatchInfo.Capture(hookException).Throw();
}
}
#if NET
private static void FinishTestActivity(AbstractExecutableTest executableTest, Exception? capturedException)
{
var activity = executableTest.Context.Activity;
if (activity is null)
{
return;
}
var result = executableTest.Context.Execution.Result;
// Use OTel test semantic convention values: pass, fail, skipped
var statusValue = result?.State switch
{
TestState.Passed => "pass",
TestState.Failed => "fail",
TestState.Skipped => "skipped",
_ => "unknown"
};
activity.SetTag("test.case.result.status", statusValue);
if (executableTest.Context.CurrentRetryAttempt > 0)
{
activity.SetTag("tunit.test.retry_attempt", executableTest.Context.CurrentRetryAttempt);
}
if (capturedException is SkipTestException skipEx)
{
// Skipped tests are not errors — leave status as Unset
activity.SetTag("tunit.test.skip_reason", skipEx.Reason);
}
else if (capturedException is not null)
{
// RecordException sets Error status and error.type tag
TUnitActivitySource.RecordException(activity, capturedException);
}
// Success: leave status as Unset per OTel instrumentation library conventions
TUnitActivitySource.StopActivity(activity);
executableTest.Context.Activity = null;
}
#endif
private static async ValueTask ExecuteTestAsync(AbstractExecutableTest executableTest, CancellationToken cancellationToken)
{
// Skip the actual test invocation for skipped tests
if (executableTest.Context.Metadata.TestDetails.ClassInstance is SkippedTestInstance ||
!string.IsNullOrEmpty(executableTest.Context.SkipReason))
{
return;
}
// Set the test start time when we actually begin executing the test
executableTest.Context.TestStart = DateTimeOffset.UtcNow;
// Set the cancellation token on the context so source-generated tests can access it
executableTest.Context.CancellationToken = cancellationToken;
if (executableTest.Context.InternalDiscoveredTest?.TestExecutor is { } testExecutor)
{
await testExecutor.ExecuteTest(executableTest.Context,
() => new ValueTask(executableTest.InvokeTestAsync(executableTest.Context.Metadata.TestDetails.ClassInstance, cancellationToken))).ConfigureAwait(false);
}
else
{
await executableTest.InvokeTestAsync(executableTest.Context.Metadata.TestDetails.ClassInstance, cancellationToken).ConfigureAwait(false);
}
}
internal async Task<List<Exception>> ExecuteAfterClassAssemblyHooks(AbstractExecutableTest executableTest,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties
| DynamicallyAccessedMemberTypes.PublicMethods)]
Type testClass, Assembly testAssembly, CancellationToken cancellationToken)
{
var exceptions = new List<Exception>();
var flags = _lifecycleCoordinator.DecrementAndCheckAfterHooks(testClass, testAssembly);
if (flags.ShouldExecuteAfterClass)
{
// Use AfterHookPairTracker to prevent double execution if already triggered by cancellation
var classExceptions = await _afterHookPairTracker.GetOrCreateAfterClassTask(testClass, _hookExecutor, cancellationToken).ConfigureAwait(false);
exceptions.AddRange(classExceptions);
}
if (flags.ShouldExecuteAfterAssembly)
{
// Use AfterHookPairTracker to prevent double execution if already triggered by cancellation
var assemblyExceptions = await _afterHookPairTracker.GetOrCreateAfterAssemblyTask(
testAssembly,
(assembly) => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterAssemblyHooksAsync(assembly, cancellationToken).AsTask())).ConfigureAwait(false);
exceptions.AddRange(assemblyExceptions);
}
return exceptions;
}
/// <summary>
/// Execute session-level after hooks once at the end of test execution.
/// Returns any exceptions that occurred during hook execution.
/// Uses AfterHookPairTracker to prevent double execution if already triggered by cancellation.
/// </summary>
public async Task<List<Exception>> ExecuteAfterTestSessionHooksAsync(CancellationToken cancellationToken)
{
// Use AfterHookPairTracker to prevent double execution if already triggered by cancellation
var exceptions = await _afterHookPairTracker.GetOrCreateAfterTestSessionTask(
() => new ValueTask<List<Exception>>(_hookExecutor.ExecuteAfterTestSessionHooksAsync(cancellationToken).AsTask())).ConfigureAwait(false);
return exceptions;
}
/// <summary>
/// Execute discovery-level before hooks.
/// </summary>
public Task ExecuteBeforeTestDiscoveryHooksAsync(CancellationToken cancellationToken)
{
return _hookExecutor.ExecuteBeforeTestDiscoveryHooksAsync(cancellationToken).AsTask();
}
/// <summary>
/// Execute discovery-level after hooks.
/// </summary>
public Task ExecuteAfterTestDiscoveryHooksAsync(CancellationToken cancellationToken)
{
return _hookExecutor.ExecuteAfterTestDiscoveryHooksAsync(cancellationToken).AsTask();
}
/// <summary>
/// Get the context provider for accessing test contexts.
/// </summary>
public IContextProvider GetContextProvider()
{
return _contextProvider;
}
internal static async Task DisposeTestInstance(AbstractExecutableTest test)
{
// Dispose the test instance if it's disposable
if (test.Context.Metadata.TestDetails.ClassInstance is not SkippedTestInstance)
{
try
{
var instance = test.Context.Metadata.TestDetails.ClassInstance;
switch (instance)
{
case IAsyncDisposable asyncDisposable:
await asyncDisposable.DisposeAsync().ConfigureAwait(false);
break;
case IDisposable disposable:
disposable.Dispose();
break;
}
}
catch
{
// Swallow disposal errors - they shouldn't fail the test
}
}
}
}