From 0e3498bcecad3b759f4e48c23965048ad78d11a6 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:10:24 +0000 Subject: [PATCH 1/3] feat: add TestContext.Isolation interface for test resource isolation Extract isolation helpers (UniqueId, GetIsolatedName, GetIsolatedPrefix) from WebApplicationTest into a new ITestIsolation interface on TestContext, making them available to all tests without requiring a specific base class. WebApplicationTest now shares the same atomic counter as TestContext to ensure unique IDs across all test types. Co-Authored-By: Claude Opus 4.6 --- TUnit.AspNetCore/WebApplicationTest.cs | 4 +- TUnit.Core/Interfaces/ITestIsolation.cs | 46 +++++++++ TUnit.Core/TestContext.Isolation.cs | 19 ++++ TUnit.Core/TestContext.cs | 4 +- TUnit.UnitTests/TestIsolationTests.cs | 114 +++++++++++++++++++++++ docs/docs/examples/aspnet.md | 4 + docs/docs/test-lifecycle/test-context.md | 23 +++++ 7 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 TUnit.Core/Interfaces/ITestIsolation.cs create mode 100644 TUnit.Core/TestContext.Isolation.cs create mode 100644 TUnit.UnitTests/TestIsolationTests.cs diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index 00a4793fb9..e19812e613 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -10,8 +10,6 @@ namespace TUnit.AspNetCore; public abstract class WebApplicationTest { - internal static int _idCounter; - /// /// Gets a unique identifier for this test instance. /// Useful for creating isolated resources (tables, topics, keys) per test. @@ -20,7 +18,7 @@ public abstract class WebApplicationTest internal WebApplicationTest() { - UniqueId = Interlocked.Increment(ref _idCounter); + UniqueId = Interlocked.Increment(ref TestContext._isolationIdCounter); } /// diff --git a/TUnit.Core/Interfaces/ITestIsolation.cs b/TUnit.Core/Interfaces/ITestIsolation.cs new file mode 100644 index 0000000000..5ed764f038 --- /dev/null +++ b/TUnit.Core/Interfaces/ITestIsolation.cs @@ -0,0 +1,46 @@ +namespace TUnit.Core.Interfaces; + +/// +/// Provides helpers for creating isolated resource names per test instance. +/// Useful for database tables, queue names, cache keys, and other resources +/// that need to be unique across parallel test execution. +/// Accessed via . +/// +public interface ITestIsolation +{ + /// + /// Gets a unique identifier for this test instance. + /// This value is assigned atomically and is guaranteed to be unique across all test instances in the process. + /// + int UniqueId { get; } + + /// + /// Creates an isolated name by combining a base name with the test's unique identifier. + /// Use for database tables, Redis keys, Kafka topics, etc. + /// + /// The base name for the resource. + /// A unique name in the format "Test_{UniqueId}_{baseName}". + /// + /// + /// // In a test with UniqueId = 42: + /// var tableName = TestContext.Current!.Isolation.GetIsolatedName("todos"); // Returns "Test_42_todos" + /// var topicName = TestContext.Current!.Isolation.GetIsolatedName("orders"); // Returns "Test_42_orders" + /// + /// + string GetIsolatedName(string baseName); + + /// + /// Creates an isolated prefix using the test's unique identifier. + /// Use for key prefixes in Redis, Kafka topic prefixes, etc. + /// + /// The separator character. Defaults to "_". + /// A unique prefix in the format "test{separator}{UniqueId}{separator}". + /// + /// + /// // In a test with UniqueId = 42: + /// var prefix = TestContext.Current!.Isolation.GetIsolatedPrefix(); // Returns "test_42_" + /// var dotPrefix = TestContext.Current!.Isolation.GetIsolatedPrefix("."); // Returns "test.42." + /// + /// + string GetIsolatedPrefix(string separator = "_"); +} diff --git a/TUnit.Core/TestContext.Isolation.cs b/TUnit.Core/TestContext.Isolation.cs new file mode 100644 index 0000000000..06845df5d5 --- /dev/null +++ b/TUnit.Core/TestContext.Isolation.cs @@ -0,0 +1,19 @@ +using TUnit.Core.Interfaces; + +namespace TUnit.Core; + +public partial class TestContext +{ + internal static int _isolationIdCounter; + + internal int IsolationUniqueId { get; } + + /// + int ITestIsolation.UniqueId => IsolationUniqueId; + + /// + string ITestIsolation.GetIsolatedName(string baseName) => $"Test_{IsolationUniqueId}_{baseName}"; + + /// + string ITestIsolation.GetIsolatedPrefix(string separator) => $"test{separator}{IsolationUniqueId}{separator}"; +} diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index e38cd07699..37641c0f38 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -15,7 +15,7 @@ namespace TUnit.Core; /// [DebuggerDisplay("{TestDetails.ClassType.Name}.{GetDisplayName(),nq}")] public partial class TestContext : Context, - ITestExecution, ITestParallelization, ITestOutput, ITestMetadata, ITestDependencies, ITestStateBag, ITestEvents + ITestExecution, ITestParallelization, ITestOutput, ITestMetadata, ITestDependencies, ITestStateBag, ITestEvents, ITestIsolation { private static readonly ConcurrentDictionary _testContextsById = new(); private readonly TestBuilderContext _testBuilderContext; @@ -30,6 +30,7 @@ public TestContext(string testName, IServiceProvider serviceProvider, ClassHookC // Generate unique ID for this test instance Id = Guid.NewGuid().ToString(); + IsolationUniqueId = Interlocked.Increment(ref _isolationIdCounter); _testContextsById[Id] = this; } @@ -44,6 +45,7 @@ public TestContext(string testName, IServiceProvider serviceProvider, ClassHookC public ITestDependencies Dependencies => this; public ITestStateBag StateBag => this; public ITestEvents Events => this; + public ITestIsolation Isolation => this; internal IServiceProvider Services => ServiceProvider; diff --git a/TUnit.UnitTests/TestIsolationTests.cs b/TUnit.UnitTests/TestIsolationTests.cs new file mode 100644 index 0000000000..2f979ea3ac --- /dev/null +++ b/TUnit.UnitTests/TestIsolationTests.cs @@ -0,0 +1,114 @@ +using TUnit.Assertions.Extensions; +using TUnit.Core.Interfaces; + +namespace TUnit.UnitTests; + +public class TestIsolationTests +{ + [Test] + public async Task UniqueId_IsPositive() + { + var isolation = TestContext.Current!.Isolation; + + await Assert.That(isolation.UniqueId).IsGreaterThan(0); + } + + [Test] + public async Task UniqueId_IsDifferentAcrossTests_1() + { + // Store uniqueId in state bag so a parallel test can verify it's different + var isolation = TestContext.Current!.Isolation; + TestContext.Current.StateBag["isolation_test_id_1"] = isolation.UniqueId; + + await Assert.That(isolation.UniqueId).IsGreaterThan(0); + } + + [Test] + public async Task UniqueId_IsDifferentAcrossTests_2() + { + var isolation = TestContext.Current!.Isolation; + TestContext.Current.StateBag["isolation_test_id_2"] = isolation.UniqueId; + + await Assert.That(isolation.UniqueId).IsGreaterThan(0); + } + + [Test] + public async Task GetIsolatedName_ReturnsExpectedFormat() + { + var isolation = TestContext.Current!.Isolation; + var id = isolation.UniqueId; + + var name = isolation.GetIsolatedName("foo"); + + await Assert.That(name).IsEqualTo($"Test_{id}_foo"); + } + + [Test] + public async Task GetIsolatedName_WithDifferentBaseNames_ReturnsDifferentNames() + { + var isolation = TestContext.Current!.Isolation; + + var name1 = isolation.GetIsolatedName("todos"); + var name2 = isolation.GetIsolatedName("orders"); + + await Assert.That(name1).IsNotEqualTo(name2); + await Assert.That(name1).Contains("todos"); + await Assert.That(name2).Contains("orders"); + } + + [Test] + public async Task GetIsolatedPrefix_WithDefaultSeparator_ReturnsExpectedFormat() + { + var isolation = TestContext.Current!.Isolation; + var id = isolation.UniqueId; + + var prefix = isolation.GetIsolatedPrefix(); + + await Assert.That(prefix).IsEqualTo($"test_{id}_"); + } + + [Test] + public async Task GetIsolatedPrefix_WithDotSeparator_ReturnsExpectedFormat() + { + var isolation = TestContext.Current!.Isolation; + var id = isolation.UniqueId; + + var prefix = isolation.GetIsolatedPrefix("."); + + await Assert.That(prefix).IsEqualTo($"test.{id}."); + } + + [Test] + public async Task GetIsolatedPrefix_WithCustomSeparator_ReturnsExpectedFormat() + { + var isolation = TestContext.Current!.Isolation; + var id = isolation.UniqueId; + + var prefix = isolation.GetIsolatedPrefix("-"); + + await Assert.That(prefix).IsEqualTo($"test-{id}-"); + } + + [Test] + public async Task Isolation_IsAccessibleViaInterface() + { + ITestIsolation isolation = TestContext.Current!.Isolation; + + await Assert.That(isolation).IsNotNull(); + await Assert.That(isolation.UniqueId).IsGreaterThan(0); + } + + [Test] + public async Task Isolation_SameContextReturnsSameValues() + { + var context = TestContext.Current!; + + var id1 = context.Isolation.UniqueId; + var id2 = context.Isolation.UniqueId; + var name1 = context.Isolation.GetIsolatedName("test"); + var name2 = context.Isolation.GetIsolatedName("test"); + + await Assert.That(id1).IsEqualTo(id2); + await Assert.That(name1).IsEqualTo(name2); + } +} diff --git a/docs/docs/examples/aspnet.md b/docs/docs/examples/aspnet.md index 96483e41b8..b06b42a6ce 100644 --- a/docs/docs/examples/aspnet.md +++ b/docs/docs/examples/aspnet.md @@ -241,6 +241,10 @@ protected override void ConfigureWebHostBuilder(IWebHostBuilder builder) ## Test Isolation Helpers +:::tip Available on All Tests +The isolation helpers (`UniqueId`, `GetIsolatedName`, `GetIsolatedPrefix`) are also available on `TestContext.Current!.Isolation` for any test — not just ASP.NET Core tests. Use `TestContext.Current!.Isolation.GetIsolatedName("resource")` when you don't inherit from `WebApplicationTest`. Both share the same counter, so IDs are unique across all test types. +::: + ### GetIsolatedName Creates a unique name for resources like database tables: diff --git a/docs/docs/test-lifecycle/test-context.md b/docs/docs/test-lifecycle/test-context.md index d73b8b7681..825c0ef298 100644 --- a/docs/docs/test-lifecycle/test-context.md +++ b/docs/docs/test-lifecycle/test-context.md @@ -57,6 +57,29 @@ Artifacts are particularly useful for debugging test failures, especially in int For complete information about working with test artifacts, including session-level artifacts, best practices, and common use cases, see the [Test Artifacts](./artifacts.md) guide. +## Test Isolation + +The `TestContext` provides built-in helpers for creating isolated resource names, ensuring parallel tests don't interfere with each other. Access them via `TestContext.Current!.Isolation`: + +```csharp +// Get a unique ID for this test instance +var id = TestContext.Current!.Isolation.UniqueId; // e.g. 42 + +// Create isolated resource names +var tableName = TestContext.Current!.Isolation.GetIsolatedName("todos"); // "Test_42_todos" +var topicName = TestContext.Current!.Isolation.GetIsolatedName("orders"); // "Test_42_orders" + +// Create isolated key prefixes +var prefix = TestContext.Current!.Isolation.GetIsolatedPrefix(); // "test_42_" +var dotPrefix = TestContext.Current!.Isolation.GetIsolatedPrefix("."); // "test.42." +``` + +These are useful for any test that needs unique resource names — database tables, message queue topics, cache keys, blob storage paths, etc. — without requiring a specific base class. + +:::tip ASP.NET Core Tests +If you're using `TUnit.AspNetCore`, the `WebApplicationTest` base class provides the same helpers as `protected` methods (`GetIsolatedName`, `GetIsolatedPrefix`). Both share the same underlying counter, so IDs are unique across all test types. +::: + ## Dependency Injection **Note**: `TestContext` does NOT provide direct access to dependency injection services. The internal service provider in `TestContext` is exclusively for TUnit framework services and is not meant for user-provided dependencies. From 94a1f97ba0c677b45ccedd71016470bbbb6942c5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 14 Feb 2026 21:54:15 +0000 Subject: [PATCH 2/3] chore: update public API snapshots for ITestIsolation Add ITestIsolation interface and TestContext.Isolation property to all TFM snapshot files (net8.0, net9.0, net10.0, net4.7). Co-Authored-By: Claude Opus 4.6 --- ...re_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 9 ++++++++- ...ore_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 9 ++++++++- ...ore_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 9 ++++++++- ...s.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 9 ++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 76bad876d9..2b14a5f4d8 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1327,7 +1327,7 @@ namespace public TestConstructorAttribute() { } } [.DebuggerDisplay("{.Name}.{GetDisplayName(),nq}")] - public class TestContext : .Context, ., ., ., ., ., ., . + public class TestContext : .Context, ., ., ., ., ., ., ., . { public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } @@ -1335,6 +1335,7 @@ namespace public . Events { get; } public . Execution { get; } public string Id { get; } + public . Isolation { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } @@ -2477,6 +2478,12 @@ namespace .Interfaces string TestId { get; } string TestName { get; } } + public interface ITestIsolation + { + int UniqueId { get; } + string GetIsolatedName(string baseName); + string GetIsolatedPrefix(string separator = "_"); + } public interface ITestLocation { string TestFilePath { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 72c3b943a5..d9c1b339dd 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1327,7 +1327,7 @@ namespace public TestConstructorAttribute() { } } [.DebuggerDisplay("{.Name}.{GetDisplayName(),nq}")] - public class TestContext : .Context, ., ., ., ., ., ., . + public class TestContext : .Context, ., ., ., ., ., ., ., . { public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } @@ -1335,6 +1335,7 @@ namespace public . Events { get; } public . Execution { get; } public string Id { get; } + public . Isolation { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } @@ -2477,6 +2478,12 @@ namespace .Interfaces string TestId { get; } string TestName { get; } } + public interface ITestIsolation + { + int UniqueId { get; } + string GetIsolatedName(string baseName); + string GetIsolatedPrefix(string separator = "_"); + } public interface ITestLocation { string TestFilePath { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 1caf4068ab..1a03a28673 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1327,7 +1327,7 @@ namespace public TestConstructorAttribute() { } } [.DebuggerDisplay("{.Name}.{GetDisplayName(),nq}")] - public class TestContext : .Context, ., ., ., ., ., ., . + public class TestContext : .Context, ., ., ., ., ., ., ., . { public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } @@ -1335,6 +1335,7 @@ namespace public . Events { get; } public . Execution { get; } public string Id { get; } + public . Isolation { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } @@ -2477,6 +2478,12 @@ namespace .Interfaces string TestId { get; } string TestName { get; } } + public interface ITestIsolation + { + int UniqueId { get; } + string GetIsolatedName(string baseName); + string GetIsolatedPrefix(string separator = "_"); + } public interface ITestLocation { string TestFilePath { get; } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 319cb11fed..6d36c41ec7 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1281,7 +1281,7 @@ namespace public TestConstructorAttribute() { } } [.DebuggerDisplay("{.Name}.{GetDisplayName(),nq}")] - public class TestContext : .Context, ., ., ., ., ., ., . + public class TestContext : .Context, ., ., ., ., ., ., ., . { public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } @@ -1289,6 +1289,7 @@ namespace public . Events { get; } public . Execution { get; } public string Id { get; } + public . Isolation { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } @@ -2427,6 +2428,12 @@ namespace .Interfaces string TestId { get; } string TestName { get; } } + public interface ITestIsolation + { + int UniqueId { get; } + string GetIsolatedName(string baseName); + string GetIsolatedPrefix(string separator = "_"); + } public interface ITestLocation { string TestFilePath { get; } From c136bb8c45149ee3f9b5a7082fd4d33f129f5fc1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 14 Feb 2026 23:36:28 +0000 Subject: [PATCH 3/3] fix: delegate WebApplicationTest isolation to TestContext.Isolation Address code review feedback: WebApplicationTest now delegates UniqueId, GetIsolatedName, and GetIsolatedPrefix to TestContext.Current!.Isolation instead of independently incrementing the shared counter. This ensures consistent values regardless of the access path. Co-Authored-By: Claude Opus 4.6 --- TUnit.AspNetCore/WebApplicationTest.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/TUnit.AspNetCore/WebApplicationTest.cs b/TUnit.AspNetCore/WebApplicationTest.cs index e19812e613..15b43fa317 100644 --- a/TUnit.AspNetCore/WebApplicationTest.cs +++ b/TUnit.AspNetCore/WebApplicationTest.cs @@ -12,13 +12,13 @@ public abstract class WebApplicationTest { /// /// Gets a unique identifier for this test instance. - /// Useful for creating isolated resources (tables, topics, keys) per test. + /// Delegates to to ensure consistency + /// regardless of whether accessed via this property or the TestContext API. /// - public int UniqueId { get; } + public int UniqueId => TestContext.Current!.Isolation.UniqueId; internal WebApplicationTest() { - UniqueId = Interlocked.Increment(ref TestContext._isolationIdCounter); } /// @@ -34,7 +34,7 @@ internal WebApplicationTest() /// var topicName = GetIsolatedName("orders"); // Returns "Test_42_orders" /// /// - protected string GetIsolatedName(string baseName) => $"Test_{UniqueId}_{baseName}"; + protected string GetIsolatedName(string baseName) => TestContext.Current!.Isolation.GetIsolatedName(baseName); /// /// Creates an isolated prefix using the test's unique identifier. @@ -49,7 +49,7 @@ internal WebApplicationTest() /// var dotPrefix = GetIsolatedPrefix("."); // Returns "test.42." /// /// - protected string GetIsolatedPrefix(string separator = "_") => $"test{separator}{UniqueId}{separator}"; + protected string GetIsolatedPrefix(string separator = "_") => TestContext.Current!.Isolation.GetIsolatedPrefix(separator); } ///