From b666e748c9dbc166f0d8564b7069543f5052ce3c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:04:27 +0000 Subject: [PATCH 1/2] feat: add IKeyedDataSource interface for keyed shared fixtures Allow ClassDataSource fixtures using SharedType.Keyed to discover their sharing key at runtime. The Key property is set after construction but before IAsyncInitializer.InitializeAsync(), eliminating the need for per-key boilerplate subclasses. Closes #4764 Co-Authored-By: Claude Opus 4.6 --- .../Attributes/TestData/ClassDataSources.cs | 17 +++++- TUnit.Core/Interfaces/IKeyedDataSource.cs | 27 +++++++++ TUnit.Core/SharedDataSources.cs | 25 +++++++- TUnit.Engine.Tests/KeyedDataSourceTests.cs | 21 +++++++ TUnit.TestProject/KeyedDataSourceTests.cs | 57 +++++++++++++++++++ 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 TUnit.Core/Interfaces/IKeyedDataSource.cs create mode 100644 TUnit.Engine.Tests/KeyedDataSourceTests.cs create mode 100644 TUnit.TestProject/KeyedDataSourceTests.cs diff --git a/TUnit.Core/Attributes/TestData/ClassDataSources.cs b/TUnit.Core/Attributes/TestData/ClassDataSources.cs index d6fc652455..b90b8a3cd4 100644 --- a/TUnit.Core/Attributes/TestData/ClassDataSources.cs +++ b/TUnit.Core/Attributes/TestData/ClassDataSources.cs @@ -2,6 +2,7 @@ using System.Reflection; using System.Runtime.ExceptionServices; using TUnit.Core.Data; +using TUnit.Core.Interfaces; namespace TUnit.Core; @@ -46,7 +47,7 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) SharedType.None => Create(), SharedType.PerTestSession => (T) TestDataContainer.GetGlobalInstance(typeof(T), _ => Create(typeof(T)))!, SharedType.PerClass => (T) TestDataContainer.GetInstanceForClass(testClassType, typeof(T), _ => Create(typeof(T)))!, - SharedType.Keyed => (T) TestDataContainer.GetInstanceForKey(key, typeof(T), _ => Create(typeof(T)))!, + SharedType.Keyed => (T) TestDataContainer.GetInstanceForKey(key, typeof(T), _ => CreateWithKey(typeof(T), key))!, SharedType.PerAssembly => (T) TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, typeof(T), _ => Create(typeof(T)))!, _ => throw new ArgumentOutOfRangeException() }; @@ -59,12 +60,24 @@ private string GetKey(int index, SharedType[] sharedTypes, string[] keys) SharedType.None => Create(type), SharedType.PerTestSession => TestDataContainer.GetGlobalInstance(type, _ => Create(type)), SharedType.PerClass => TestDataContainer.GetInstanceForClass(testClassType, type, _ => Create(type)), - SharedType.Keyed => TestDataContainer.GetInstanceForKey(key!, type, _ => Create(type)), + SharedType.Keyed => TestDataContainer.GetInstanceForKey(key!, type, _ => CreateWithKey(type, key!)), SharedType.PerAssembly => TestDataContainer.GetInstanceForAssembly(testClassType.Assembly, type, _ => Create(type)), _ => throw new ArgumentOutOfRangeException() }; } + private static object CreateWithKey([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] Type type, string key) + { + var instance = Create(type); + + if (instance is IKeyedDataSource keyed) + { + keyed.Key = key; + } + + return instance; + } + [return: NotNull] private static T Create<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.NonPublicConstructors)] T>() { diff --git a/TUnit.Core/Interfaces/IKeyedDataSource.cs b/TUnit.Core/Interfaces/IKeyedDataSource.cs new file mode 100644 index 0000000000..ce0c29d3c1 --- /dev/null +++ b/TUnit.Core/Interfaces/IKeyedDataSource.cs @@ -0,0 +1,27 @@ +namespace TUnit.Core.Interfaces; + +/// +/// Defines a contract for data source types that need to know the key they were created with +/// when using sharing. +/// +/// +/// +/// When a class is used as a [ClassDataSource] with , +/// implementing this interface allows the instance to receive its sharing key before +/// is called. +/// +/// +/// This is useful when a single fixture type needs to behave differently depending on which +/// key it was created for, without requiring separate subclasses per key variant. +/// +/// +public interface IKeyedDataSource +{ + /// + /// Gets or sets the sharing key that this instance was created with. + /// + /// + /// Set by the TUnit framework after construction but before is called. + /// + string Key { get; set; } +} diff --git a/TUnit.Core/SharedDataSources.cs b/TUnit.Core/SharedDataSources.cs index 27ec9236da..a6e6fa4aae 100644 --- a/TUnit.Core/SharedDataSources.cs +++ b/TUnit.Core/SharedDataSources.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using TUnit.Core.Helpers; +using TUnit.Core.Interfaces; namespace TUnit.Core; @@ -181,7 +182,17 @@ private static T GetForKey(string? key, Func factory) throw new ArgumentNullException(nameof(key), "key is required when SharedType is Keyed."); } - return (T)TestDataContainer.GetInstanceForKey(key!, typeof(T), _ => factory()!)!; + return (T)TestDataContainer.GetInstanceForKey(key!, typeof(T), _ => + { + var instance = factory()!; + + if (instance is IKeyedDataSource keyed) + { + keyed.Key = key!; + } + + return instance; + })!; } private static object? GetForKey(Type type, string? key, Func factory) @@ -191,7 +202,17 @@ private static T GetForKey(string? key, Func factory) throw new ArgumentNullException(nameof(key), "key is required when SharedType is Keyed."); } - return TestDataContainer.GetInstanceForKey(key!, type, _ => factory()!); + return TestDataContainer.GetInstanceForKey(key!, type, _ => + { + var instance = factory()!; + + if (instance is IKeyedDataSource keyed) + { + keyed.Key = key!; + } + + return instance; + }); } private static T GetForAssembly(Type? testClassType, Func factory) diff --git a/TUnit.Engine.Tests/KeyedDataSourceTests.cs b/TUnit.Engine.Tests/KeyedDataSourceTests.cs new file mode 100644 index 0000000000..6ade30a536 --- /dev/null +++ b/TUnit.Engine.Tests/KeyedDataSourceTests.cs @@ -0,0 +1,21 @@ +using Shouldly; +using TUnit.Engine.Tests.Enums; + +namespace TUnit.Engine.Tests; + +public class KeyedDataSourceTests(TestMode testMode) : InvokableTestBase(testMode) +{ + [Test] + public async Task KeyedDataSourceTests_ShouldPass() + { + await RunTestsWithFilter( + "/*/*/KeyedDataSourceTests/*", + [ + result => result.ResultSummary.Outcome.ShouldBe("Completed"), + result => result.ResultSummary.Counters.Total.ShouldBe(4), + result => result.ResultSummary.Counters.Passed.ShouldBe(4), + result => result.ResultSummary.Counters.Failed.ShouldBe(0), + result => result.ResultSummary.Counters.NotExecuted.ShouldBe(0) + ]); + } +} diff --git a/TUnit.TestProject/KeyedDataSourceTests.cs b/TUnit.TestProject/KeyedDataSourceTests.cs new file mode 100644 index 0000000000..d1528fa433 --- /dev/null +++ b/TUnit.TestProject/KeyedDataSourceTests.cs @@ -0,0 +1,57 @@ +using System.Diagnostics.CodeAnalysis; +using TUnit.Core.Interfaces; +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject; + +public class KeyAwareFixture : IKeyedDataSource, IAsyncInitializer +{ + public string Key { get; set; } = string.Empty; + + public string KeyDuringInit { get; private set; } = string.Empty; + + public Task InitializeAsync() + { + KeyDuringInit = Key; + return Task.CompletedTask; + } +} + +[EngineTest(ExpectedResult.Pass)] +[UnconditionalSuppressMessage("Usage", "TUnit0018:Test methods should not assign instance data")] +public class KeyedDataSourceTests +{ + private static readonly List AlphaInstances = []; + + [Test] + [ClassDataSource(Shared = SharedType.Keyed, Key = "alpha")] + public async Task Key_IsSetToAlpha(KeyAwareFixture fixture) + { + AlphaInstances.Add(fixture); + await Assert.That(fixture.Key).IsEqualTo("alpha"); + } + + [Test] + [ClassDataSource(Shared = SharedType.Keyed, Key = "beta")] + public async Task Key_IsSetToBeta(KeyAwareFixture fixture) + { + await Assert.That(fixture.Key).IsEqualTo("beta"); + } + + [Test] + [ClassDataSource(Shared = SharedType.Keyed, Key = "alpha")] + public async Task Key_IsAvailableDuringInitializeAsync(KeyAwareFixture fixture) + { + AlphaInstances.Add(fixture); + await Assert.That(fixture.KeyDuringInit).IsEqualTo("alpha"); + } + + [Test] + [DependsOn(nameof(Key_IsSetToAlpha))] + [DependsOn(nameof(Key_IsAvailableDuringInitializeAsync))] + public async Task SameKey_ReturnsSameInstance() + { + await Assert.That(AlphaInstances).HasCount().EqualTo(2); + await Assert.That(AlphaInstances[0]).IsSameReferenceAs(AlphaInstances[1]); + } +} From a7212d977aa2ceacf18e3f259ac686d9724d22b5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 14 Feb 2026 20:15:54 +0000 Subject: [PATCH 2/2] chore: update public API snapshots for IKeyedDataSource Co-Authored-By: Claude Opus 4.6 --- ...ts.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt | 4 ++++ ...sts.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt | 4 ++++ ...sts.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt | 4 ++++ .../Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt | 4 ++++ 4 files changed, 16 insertions(+) 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..4e2e58a552 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 @@ -2362,6 +2362,10 @@ namespace .Interfaces . OnHookRegistered(.HookRegisteredContext context); } public interface IInfersType { } + public interface IKeyedDataSource + { + string Key { get; set; } + } public interface ILastTestInAssemblyEventReceiver : . { . OnLastTestInAssembly(.AssemblyHookContext context, .TestContext testContext); 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..f6c2d6dab9 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 @@ -2362,6 +2362,10 @@ namespace .Interfaces . OnHookRegistered(.HookRegisteredContext context); } public interface IInfersType { } + public interface IKeyedDataSource + { + string Key { get; set; } + } public interface ILastTestInAssemblyEventReceiver : . { . OnLastTestInAssembly(.AssemblyHookContext context, .TestContext testContext); 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..c4e4afa88c 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 @@ -2362,6 +2362,10 @@ namespace .Interfaces . OnHookRegistered(.HookRegisteredContext context); } public interface IInfersType { } + public interface IKeyedDataSource + { + string Key { get; set; } + } public interface ILastTestInAssemblyEventReceiver : . { . OnLastTestInAssembly(.AssemblyHookContext context, .TestContext testContext); 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..a4644ef903 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 @@ -2314,6 +2314,10 @@ namespace .Interfaces . OnHookRegistered(.HookRegisteredContext context); } public interface IInfersType { } + public interface IKeyedDataSource + { + string Key { get; set; } + } public interface ILastTestInAssemblyEventReceiver : . { . OnLastTestInAssembly(.AssemblyHookContext context, .TestContext testContext);