From 1efd15f94097ca04fcc31cc9acc266a6ca5ee584 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 28 Mar 2026 10:59:19 +0000 Subject: [PATCH 1/4] fix: preserve StateBag when creating per-test TestBuilderContext (#5267) Data added to TestBuilderContext.StateBag during data source generation was lost because the per-test context created at each data combination did not copy the StateBag from the discovery context. --- TUnit.Engine/Building/TestBuilder.cs | 1 + TUnit.TestProject/Bugs/Issue5267/Tests.cs | 29 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 TUnit.TestProject/Bugs/Issue5267/Tests.cs diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index f763860f2d..211f4c4092 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -283,6 +283,7 @@ await _objectLifecycleService.RegisterObjectAsync( { TestMetadata = metadata.MethodMetadata, DataSourceAttribute = methodDataSource, + StateBag = new ConcurrentDictionary(testBuilderContext.StateBag), // Preserve StateBag from discovery context InitializedAttributes = testBuilderContext.InitializedAttributes, // Preserve attributes from parent context ClassConstructor = testBuilderContext.ClassConstructor // Preserve ClassConstructor for instance creation }; diff --git a/TUnit.TestProject/Bugs/Issue5267/Tests.cs b/TUnit.TestProject/Bugs/Issue5267/Tests.cs new file mode 100644 index 0000000000..839584a2bf --- /dev/null +++ b/TUnit.TestProject/Bugs/Issue5267/Tests.cs @@ -0,0 +1,29 @@ +using TUnit.TestProject.Attributes; + +namespace TUnit.TestProject.Bugs.Issue5267; + +[EngineTest(ExpectedResult.Pass)] +public class StateBagDataSourcePropagationTests +{ + public static IEnumerable TestData() + { + var builderContext = TestBuilderContext.Current; + if (builderContext != null) + { + builderContext.StateBag["DataGeneratedAt"] = "2025-01-01"; + builderContext.StateBag["GeneratorVersion"] = "1.0"; + } + + yield return "test1"; + yield return "test2"; + } + + [Test] + [MethodDataSource(nameof(TestData))] + public async Task StateBag_Data_From_DataSource_Should_Be_Available_In_TestContext(string value) + { + await Assert.That(TestContext.Current!.StateBag.ContainsKey("DataGeneratedAt")).IsTrue(); + await Assert.That(TestContext.Current!.StateBag["DataGeneratedAt"]).IsEqualTo("2025-01-01"); + await Assert.That(TestContext.Current!.StateBag["GeneratorVersion"]).IsEqualTo("1.0"); + } +} From 704d153d4af468fe8e5f0a76099a5c26f8b297d1 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:02:54 +0000 Subject: [PATCH 2/4] refactor: avoid unnecessary allocations and propagate StateBag consistently - Use RawStateBag internal accessor to skip ConcurrentDictionary copy when StateBag was never populated (common case avoids two allocations per test) - Propagate StateBag at skip-if-empty code paths (lines 481, 540) for consistency with the main path - Remove redundant test assertion and comments --- TUnit.Core/TestBuilderContext.cs | 2 ++ TUnit.Engine/Building/TestBuilder.cs | 22 +++++++++++++++++----- TUnit.TestProject/Bugs/Issue5267/Tests.cs | 1 - 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs index fb802b2bcb..659d4f8093 100644 --- a/TUnit.Core/TestBuilderContext.cs +++ b/TUnit.Core/TestBuilderContext.cs @@ -40,6 +40,8 @@ public static TestBuilderContext? Current set => _stateBag = value; } + internal ConcurrentDictionary? RawStateBag => _stateBag; + public TestContextEvents Events { get => _events ??= new TestContextEvents(); diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 211f4c4092..1573073edd 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -283,11 +283,15 @@ await _objectLifecycleService.RegisterObjectAsync( { TestMetadata = metadata.MethodMetadata, DataSourceAttribute = methodDataSource, - StateBag = new ConcurrentDictionary(testBuilderContext.StateBag), // Preserve StateBag from discovery context - InitializedAttributes = testBuilderContext.InitializedAttributes, // Preserve attributes from parent context - ClassConstructor = testBuilderContext.ClassConstructor // Preserve ClassConstructor for instance creation + InitializedAttributes = testBuilderContext.InitializedAttributes, + ClassConstructor = testBuilderContext.ClassConstructor }; + if (testBuilderContext.RawStateBag is { IsEmpty: false } parentStateBag) + { + contextAccessor.Current.StateBag = new ConcurrentDictionary(parentStateBag); + } + var (classDataUnwrapped, classRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); classData = classDataUnwrapped; var (methodData, methodRowMetadata) = DataUnwrapper.UnwrapWithTypesAndMetadata(await methodDataFactory() ?? [], metadata.MethodMetadata.Parameters); @@ -477,7 +481,6 @@ await _objectLifecycleService.RegisterObjectAsync( ResolvedMethodGenericArguments = Type.EmptyTypes }; - // StateBag and Events are lazy-initialized var testSpecificContext = new TestBuilderContext { TestMetadata = metadata.MethodMetadata, @@ -486,6 +489,11 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; + if (testBuilderContext.RawStateBag is { IsEmpty: false } skipParentStateBag) + { + testSpecificContext.StateBag = new ConcurrentDictionary(skipParentStateBag); + } + var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; tests.Add(test); @@ -536,7 +544,6 @@ await _objectLifecycleService.RegisterObjectAsync( ResolvedMethodGenericArguments = Type.EmptyTypes }; - // StateBag and Events are lazy-initialized var testSpecificContext = new TestBuilderContext { TestMetadata = metadata.MethodMetadata, @@ -545,6 +552,11 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; + if (testBuilderContext.RawStateBag is { IsEmpty: false } skipClassParentStateBag) + { + testSpecificContext.StateBag = new ConcurrentDictionary(skipClassParentStateBag); + } + var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; tests.Add(test); diff --git a/TUnit.TestProject/Bugs/Issue5267/Tests.cs b/TUnit.TestProject/Bugs/Issue5267/Tests.cs index 839584a2bf..850a3608a0 100644 --- a/TUnit.TestProject/Bugs/Issue5267/Tests.cs +++ b/TUnit.TestProject/Bugs/Issue5267/Tests.cs @@ -22,7 +22,6 @@ public static IEnumerable TestData() [MethodDataSource(nameof(TestData))] public async Task StateBag_Data_From_DataSource_Should_Be_Available_In_TestContext(string value) { - await Assert.That(TestContext.Current!.StateBag.ContainsKey("DataGeneratedAt")).IsTrue(); await Assert.That(TestContext.Current!.StateBag["DataGeneratedAt"]).IsEqualTo("2025-01-01"); await Assert.That(TestContext.Current!.StateBag["GeneratorVersion"]).IsEqualTo("1.0"); } From e5d0eab0c3a75f8df3e731ed2fda353952a13ed7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:10:03 +0000 Subject: [PATCH 3/4] refactor: extract PropagateStateBag helper to deduplicate pattern --- TUnit.Engine/Building/TestBuilder.cs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 1573073edd..91468049ad 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -287,10 +287,7 @@ await _objectLifecycleService.RegisterObjectAsync( ClassConstructor = testBuilderContext.ClassConstructor }; - if (testBuilderContext.RawStateBag is { IsEmpty: false } parentStateBag) - { - contextAccessor.Current.StateBag = new ConcurrentDictionary(parentStateBag); - } + PropagateStateBag(testBuilderContext, contextAccessor.Current); var (classDataUnwrapped, classRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); classData = classDataUnwrapped; @@ -489,10 +486,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - if (testBuilderContext.RawStateBag is { IsEmpty: false } skipParentStateBag) - { - testSpecificContext.StateBag = new ConcurrentDictionary(skipParentStateBag); - } + PropagateStateBag(testBuilderContext, testSpecificContext); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; @@ -552,10 +546,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - if (testBuilderContext.RawStateBag is { IsEmpty: false } skipClassParentStateBag) - { - testSpecificContext.StateBag = new ConcurrentDictionary(skipClassParentStateBag); - } + PropagateStateBag(testBuilderContext, testSpecificContext); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; @@ -1895,4 +1886,12 @@ internal bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata met { return _filterMatcher.CouldMatchFilter(metadata, filter); } + + private static void PropagateStateBag(TestBuilderContext source, TestBuilderContext target) + { + if (source.RawStateBag is { IsEmpty: false } bag) + { + target.StateBag = new ConcurrentDictionary(bag); + } + } } From c293c4970efc3b213a639fd3919ed4e0e8bceb06 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 28 Mar 2026 11:13:18 +0000 Subject: [PATCH 4/4] refactor: move StateBag propagation onto TestBuilderContext itself CopyStateBagTo accesses _stateBag directly, eliminating the need for the RawStateBag internal property and keeping the logic closer to the data it operates on. --- TUnit.Core/TestBuilderContext.cs | 8 +++++++- TUnit.Engine/Building/TestBuilder.cs | 13 +++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs index 659d4f8093..6d4798dcbc 100644 --- a/TUnit.Core/TestBuilderContext.cs +++ b/TUnit.Core/TestBuilderContext.cs @@ -40,7 +40,13 @@ public static TestBuilderContext? Current set => _stateBag = value; } - internal ConcurrentDictionary? RawStateBag => _stateBag; + internal void CopyStateBagTo(TestBuilderContext target) + { + if (_stateBag is { IsEmpty: false } bag) + { + target.StateBag = new ConcurrentDictionary(bag); + } + } public TestContextEvents Events { diff --git a/TUnit.Engine/Building/TestBuilder.cs b/TUnit.Engine/Building/TestBuilder.cs index 91468049ad..f6f0a859fb 100644 --- a/TUnit.Engine/Building/TestBuilder.cs +++ b/TUnit.Engine/Building/TestBuilder.cs @@ -287,7 +287,7 @@ await _objectLifecycleService.RegisterObjectAsync( ClassConstructor = testBuilderContext.ClassConstructor }; - PropagateStateBag(testBuilderContext, contextAccessor.Current); + testBuilderContext.CopyStateBagTo(contextAccessor.Current); var (classDataUnwrapped, classRowMetadata) = DataUnwrapper.UnwrapWithMetadata(await classDataFactory() ?? []); classData = classDataUnwrapped; @@ -486,7 +486,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - PropagateStateBag(testBuilderContext, testSpecificContext); + testBuilderContext.CopyStateBagTo(testSpecificContext); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; @@ -546,7 +546,7 @@ await _objectLifecycleService.RegisterObjectAsync( InitializedAttributes = attributes }; - PropagateStateBag(testBuilderContext, testSpecificContext); + testBuilderContext.CopyStateBagTo(testSpecificContext); var test = await BuildTestAsync(metadata, testData, testSpecificContext, cancellationToken: cancellationToken); test.Context.SkipReason = skipReason; @@ -1887,11 +1887,4 @@ internal bool CouldTestMatchFilter(ITestExecutionFilter filter, TestMetadata met return _filterMatcher.CouldMatchFilter(metadata, filter); } - private static void PropagateStateBag(TestBuilderContext source, TestBuilderContext target) - { - if (source.RawStateBag is { IsEmpty: false } bag) - { - target.StateBag = new ConcurrentDictionary(bag); - } - } }