diff --git a/docs/docs/writing-tests/lifecycle.md b/docs/docs/writing-tests/lifecycle.md
index 4176ab843cc..3f924c3983b 100644
--- a/docs/docs/writing-tests/lifecycle.md
+++ b/docs/docs/writing-tests/lifecycle.md
@@ -247,6 +247,10 @@ flowchart LR
| `PerTestSession` | After test session ends |
| `Keyed` | When all tests using that key complete |
+:::note Finished test instances are released
+Once a test has finished and its result has been reported, its `TestContext` drops its reference to the class instance and its per-test injected objects so they can be garbage collected. Reading `TestContext.Metadata.TestDetails.ClassInstance` for a finished test (for example from `ClassHookContext.Tests` in an `[After(Class)]` or `[After(TestSession)]` hook) no longer returns the test object. Keep your own reference if you need to inspect instances after their tests complete.
+:::
+
## Property Injection Lifecycle
```mermaid
diff --git a/src/TUnit.Core/PlaceholderInstance.cs b/src/TUnit.Core/PlaceholderInstance.cs
index 528dec26462..85e8c25fb75 100644
--- a/src/TUnit.Core/PlaceholderInstance.cs
+++ b/src/TUnit.Core/PlaceholderInstance.cs
@@ -12,4 +12,9 @@ private PlaceholderInstance()
{
// Private constructor to ensure singleton pattern
}
+
+ // Surfaces in debuggers and logs when user code reads ClassInstance before it is created or after
+ // a finished test has released it (#7260).
+ public override string ToString() =>
+ "Test class instance not available: it has not been created yet, or it was released after the test finished.";
}
\ No newline at end of file
diff --git a/src/TUnit.Core/TestContext.cs b/src/TUnit.Core/TestContext.cs
index 259b507e395..ae07a94a91f 100644
--- a/src/TUnit.Core/TestContext.cs
+++ b/src/TUnit.Core/TestContext.cs
@@ -226,6 +226,27 @@ internal void RemoveFromRegistry()
Volatile.Write(ref CachedReportingProperties, null);
}
+ ///
+ /// Drops this context's references to the disposed class instance and untracked objects once the
+ /// test has finished, so per-test state can be collected while the session keeps the context alive (#7260).
+ ///
+ internal void ReleaseFinishedTestState()
+ {
+ var details = Metadata.TestDetails;
+ if (details.ClassInstance is not (SkippedTestInstance or PlaceholderInstance))
+ {
+ details.ClassInstance = PlaceholderInstance.Instance;
+ }
+
+ // The cached receiver lists can hold the instance when it implements event receiver interfaces.
+ InvalidateEventReceiverCaches();
+
+ // Tracked objects were untracked (and disposed when no longer shared) during cleanup.
+ Volatile.Write(ref _trackedObjects, null);
+ DisposalOnlyTrackedObjects = null;
+ details.ReleaseInjectedPropertyArguments();
+ }
+
internal static void ClearReportingCaches()
{
// Discovery-only sessions and failures before execution can leave contexts
diff --git a/src/TUnit.Core/TestDetails.cs b/src/TUnit.Core/TestDetails.cs
index eabf9965c29..165c5dd3ee0 100644
--- a/src/TUnit.Core/TestDetails.cs
+++ b/src/TUnit.Core/TestDetails.cs
@@ -90,6 +90,10 @@ public partial class TestDetails : ITestIdentity, ITestClass, ITestMethod, ITest
return Interlocked.CompareExchange(ref _testClassInjectedPropertyArguments, created, null) ?? created;
}
+ // The cache is only read while the test registers and executes; dropping it afterwards lets
+ // per-test injected objects be collected while the session keeps the context alive (#7260).
+ internal void ReleaseInjectedPropertyArguments() => _testClassInjectedPropertyArguments = null;
+
private static class EmptyInjectedPropertyArguments
{
public static readonly IDictionary Instance =
diff --git a/src/TUnit.Engine/Services/ObjectLifecycleService.cs b/src/TUnit.Engine/Services/ObjectLifecycleService.cs
index fb39191b157..2d0ff7ce151 100644
--- a/src/TUnit.Engine/Services/ObjectLifecycleService.cs
+++ b/src/TUnit.Engine/Services/ObjectLifecycleService.cs
@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
using TUnit.Core;
using TUnit.Core.Discovery;
using TUnit.Core.Helpers;
@@ -8,7 +9,6 @@
using TUnit.Core.Tracking;
#if NET
using System.Diagnostics;
-using System.Runtime.CompilerServices;
using System.Threading;
#endif
@@ -33,10 +33,10 @@ internal sealed class ObjectLifecycleService : IObjectRegistry, IInitializationC
private readonly ObjectGraphDiscoveryService _objectGraphDiscoveryService;
private readonly ObjectTracker _objectTracker;
- // Track initialization state per object
- // Use ReferenceEqualityComparer to prevent objects with custom Equals from sharing initialization state
- private readonly ConcurrentDictionary