From 8385ded20f96d7899a1e25175ebd9adb99994228 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 30 Oct 2026 15:02:03 +0000
Subject: [PATCH] fix(engine): invoke OnTestRegistered on property-injected
data sources
Registered receivers ran once, before argument registration resolved
injected property values, so a shared fixture that implements
ITestRegisteredEventReceiver was skipped when injected as a property.
Run a second receiver pass after argument registration, sharing the
identity set so receivers that already ran are not invoked twice.
Fixes #7290
---
docs/docs/writing-tests/event-subscribing.md | 6 +
.../ITestRegisteredEventReceiver.cs | 6 +
.../Services/TestFilterService.cs | 75 ++++++++----
...steredReceiverInjectedProperty7290Tests.cs | 27 +++++
tests/TUnit.TestProject/Bugs/_7290/Tests.cs | 110 ++++++++++++++++++
5 files changed, 201 insertions(+), 23 deletions(-)
create mode 100644 tests/TUnit.Engine.Tests/RegisteredReceiverInjectedProperty7290Tests.cs
create mode 100644 tests/TUnit.TestProject/Bugs/_7290/Tests.cs
diff --git a/docs/docs/writing-tests/event-subscribing.md b/docs/docs/writing-tests/event-subscribing.md
index d622884acbb..45866a2c594 100644
--- a/docs/docs/writing-tests/event-subscribing.md
+++ b/docs/docs/writing-tests/event-subscribing.md
@@ -21,6 +21,12 @@ The interfaces they can implement are:
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.
+## Registration callbacks for injected properties
+
+TUnit creates injected property values after the first `OnTestRegistered` pass, so that a receiver such as a `SkipAttribute` can skip the test before expensive data sources are created. An injected property value that implements `ITestRegisteredEventReceiver` receives its callback in a second pass, after the property values are resolved and before the test runs. The second pass uses the same `Order` sorting, but it always runs after every receiver from the first pass.
+
+A property value can still call `SetParallelLimiter`, `SetSkipped` and the other `TestRegisteredContext` methods. If it skips the test, its shared data source objects have already been created.
+
## Registration callbacks for executors
An executor that implements `ITestRegisteredEventReceiver` receives `OnTestRegistered` when a registration receiver installs it through `SetTestExecutor` or `SetHookExecutor`, unless it has already received the callback for that test. Its callback runs after the installing receiver returns. Dynamically installed executors are not globally sorted by their own `Order`.
diff --git a/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs b/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs
index b1af3c5fb6a..f7470b50a0b 100644
--- a/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs
+++ b/src/TUnit.Core/Interfaces/ITestRegisteredEventReceiver.cs
@@ -28,6 +28,12 @@ namespace TUnit.Core.Interfaces;
/// Each receiver instance is invoked at most once per test, even when it is both an eligible
/// event object and an installed executor. Distinct instances are compared by reference identity.
///
+///
+/// Injected property values are resolved after the first registration pass, so that receivers
+/// such as can skip a test before expensive data sources are created.
+/// An injected property value that implements this interface is invoked in a second pass, after
+/// every receiver from the first pass and before the test executes.
+///
///
public interface ITestRegisteredEventReceiver : IEventReceiver
{
diff --git a/src/TUnit.Engine/Services/TestFilterService.cs b/src/TUnit.Engine/Services/TestFilterService.cs
index 1ba1d8630a4..4bdab17af62 100644
--- a/src/TUnit.Engine/Services/TestFilterService.cs
+++ b/src/TUnit.Engine/Services/TestFilterService.cs
@@ -81,9 +81,42 @@ private async Task InvokeTestRegisteredReceiver(ITestRegisteredEventReceiver rec
}
}
+ private async Task InvokeTestRegisteredReceivers(ITestRegisteredEventReceiver[] receivers, TestRegisteredContext context,
+ HashSet invokedReceivers)
+ {
+ foreach (var receiver in receivers)
+ {
+ await InvokeTestRegisteredReceiver(receiver, context, invokedReceivers).ConfigureAwait(false);
+
+ // Dynamically installed executor callbacks run after their installer, regardless of their
+ // own Order. The shared identity set also covers executors in the original receiver list.
+ while (context.TryDequeueExecutorEventReceiver(out var executorReceiver))
+ {
+ await InvokeTestRegisteredReceiver(executorReceiver, context, invokedReceivers).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private static TestRegisteredContext CreateTestRegisteredContext(TestContext testContext)
+ {
+ var discoveredTest = new DiscoveredTest