From 8948c07b73e206226c31a1a45272cf5fc7143627 Mon Sep 17 00:00:00 2001 From: Evangelink <11340282+Evangelink@users.noreply.github.com> Date: Wed, 20 May 2026 14:53:01 +0200 Subject: [PATCH] Flow TestContext.Properties through Assembly/Class lifecycle Properties written to TestContext.Properties in [AssemblyInitialize] now flow to every [ClassInitialize], test method, [ClassCleanup] and [AssemblyCleanup]. Properties written in [ClassInitialize] flow to test methods and [ClassCleanup] of that class. Implementation: - TestContextImplementation: new internal CaptureLifecycleProperties() and MergeProperties(); defensive switch from Add to indexer assignment for the per-context label keys. - TestAssemblyInfo.PostAssemblyInitProperties: snapshot captured inside the existing _assemblyInfoExecuteSyncSemaphore after AssemblyInit body completes successfully. - TestClassInfo.PostClassInitProperties: snapshot captured inside the existing _testClassExecuteSyncSemaphore after ClassInit completes (includes base-chain class-init writes via InheritanceBehavior). - UnitTestRunner.RunSingleTestAsync: merges snapshots into class-init, test-execution, class-cleanup and assembly-cleanup contexts. The class-cleanup merge is gated on isLastTestInClass to avoid wasted copies on every test. - ClassCleanupManager.ForceCleanup: same merges on the fallback contexts. Per-context labels (FullyQualifiedTestClassName, TestName) are excluded from snapshots and preserved on merge so per-test identity stays intact. Snapshots are shallow (reference-type values are aliased across all flowed contexts) - documented in the new XML doc-comments. Class-init properties are intentionally NOT flowed to AssemblyCleanup because AssemblyCleanup is assembly-scoped and picking one class would be arbitrary. Tests: - 7 unit tests for MergeProperties/CaptureLifecycleProperties. - 4 unit tests for TestAssemblyInfo snapshot capture. - 4 unit tests for TestClassInfo snapshot capture (incl. base+derived chain). - New TestContextPropertyFlowTests acceptance suite covering AssemblyInit to tests, ClassInit to tests, override precedence, cross-class isolation, AssemblyCleanup excluding class-init props, no leakage between sibling tests, and [DataRow] shared bag. No public API changes. Fixes #5986 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Execution/ClassCleanupManager.cs | 15 +- .../Execution/TestAssemblyInfo.cs | 39 ++++ .../Execution/TestClassInfo.cs | 31 +++ .../Execution/UnitTestRunner.cs | 29 +++ .../Services/TestContextImplementation.cs | 67 +++++- .../TestContextPropertyFlowTests.cs | 203 ++++++++++++++++++ .../Execution/TestAssemblyInfoTests.cs | 62 ++++++ .../Execution/TestClassInfoTests.cs | 73 +++++++ .../TestContextImplementationTests.cs | 116 ++++++++++ 9 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 test/IntegrationTests/MSTest.Acceptance.IntegrationTests/TestContextPropertyFlowTests.cs diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/ClassCleanupManager.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/ClassCleanupManager.cs index b64888c573..83e2ced907 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/ClassCleanupManager.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/ClassCleanupManager.cs @@ -57,7 +57,13 @@ internal static void ForceCleanup(TypeCache typeCache, IDictionary classInfoCache = typeCache.ClassInfoListWithExecutableCleanupMethods; foreach (TestClassInfo classInfo in classInfoCache) { - TestContext testContext = new TestContextImplementation(null, classInfo.ClassType.FullName, sourceLevelParameters, logger, testRunCancellationToken: null); + var testContext = new TestContextImplementation(null, classInfo.ClassType.FullName, sourceLevelParameters, logger, testRunCancellationToken: null); + + // Flow properties set during AssemblyInitialize and ClassInitialize so the + // ClassCleanup method observes them when invoked via the fallback path. + testContext.MergeProperties(classInfo.Parent.PostAssemblyInitProperties); + testContext.MergeProperties(classInfo.PostClassInitProperties); + TestFailedException? ex = classInfo.ExecuteClassCleanupAsync(testContext).GetAwaiter().GetResult(); if (ex is not null) { @@ -68,7 +74,12 @@ internal static void ForceCleanup(TypeCache typeCache, IDictionary assemblyInfoCache = typeCache.AssemblyInfoListWithExecutableCleanupMethods; foreach (TestAssemblyInfo assemblyInfo in assemblyInfoCache) { - TestContext testContext = new TestContextImplementation(null, null, sourceLevelParameters, logger, testRunCancellationToken: null); + var testContext = new TestContextImplementation(null, null, sourceLevelParameters, logger, testRunCancellationToken: null); + + // Flow properties set during AssemblyInitialize so the AssemblyCleanup method observes + // them when invoked via the fallback path. + testContext.MergeProperties(assemblyInfo.PostAssemblyInitProperties); + TestFailedException? ex = assemblyInfo.ExecuteAssemblyCleanupAsync(testContext).GetAwaiter().GetResult(); if (ex is not null) { diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs index 0a58613b8d..4e8067a473 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestAssemblyInfo.cs @@ -93,6 +93,26 @@ internal set /// public TestFailedException? AssemblyInitializationException { get; internal set; } + /// + /// Gets a snapshot of captured after the + /// AssemblyInitialize method completes. Used to flow properties set during + /// AssemblyInitialize into subsequent contexts (class init, test execution, + /// class cleanup, assembly cleanup). if no + /// AssemblyInitialize method was registered or it has not yet executed + /// successfully. + /// + /// The snapshot is shallow: reference-type values stored in the bag are shared (aliased) + /// across every context the snapshot is merged into. Mutations of those reference-type + /// instances are visible everywhere. + /// + /// + /// Class-init properties are intentionally NOT included by callers when seeding the + /// assembly-cleanup context, because AssemblyCleanup is assembly-scoped and runs + /// once across many classes; including a single class's snapshot would be arbitrary. + /// + /// + internal IReadOnlyDictionary? PostAssemblyInitProperties { get; private set; } + /// /// Gets the assembly cleanup exception. /// @@ -160,6 +180,25 @@ public async Task RunAssemblyInitializeAsync(TestContext testContext // **After** we have executed the assembly initialize, we save the current context. // This context will contain async locals set by the assembly initialize method. ExecutionContext = ExecutionContext.Capture(); + + // The `is` check is defensive: this method is part of an internal + // but mockable surface, so unit tests can legitimately pass a + // mocked TestContext. Production callers always pass a + // TestContextImplementation. + if (testContext is TestContextImplementation testContextImpl) + { + // Capture a snapshot of TestContext.Properties so that values + // set during AssemblyInitialize flow to subsequent contexts + // (class init, test execution, class cleanup, assembly cleanup). + // TODO: PostAssemblyInitProperties is published outside the + // _assemblyInfoExecuteSyncSemaphore via the + // IsAssemblyInitializeExecuted fast path in this method. This + // is consistent with the existing pattern used by + // AssemblyInitializationException and ExecutionContext; + // revisit memory-barrier semantics for all three together + // if it becomes a problem. + PostAssemblyInitProperties = testContextImpl.CaptureLifecycleProperties(); + } }, testContext.CancellationTokenSource, AssemblyInitializeMethodTimeoutMilliseconds, diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.cs index 7f93edbea5..c9e0190a0b 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/TestClassInfo.cs @@ -143,6 +143,25 @@ internal set /// public Exception? ClassCleanupException { get; internal set; } + /// + /// Gets a snapshot of captured after the + /// ClassInitialize method completes. Used to flow properties set during + /// ClassInitialize into subsequent contexts (test execution, class cleanup). + /// if no ClassInitialize method was registered or it has + /// not yet executed successfully. + /// + /// When the test class inherits from a base class that has a ClassInitialize + /// method declared with , all + /// class-init bodies in the chain run against the same context, so the captured snapshot + /// includes properties set by both base and derived class-init methods. + /// + /// + /// The snapshot is shallow: reference-type values stored in the bag are shared (aliased) + /// across every context the snapshot is merged into. + /// + /// + internal IReadOnlyDictionary? PostClassInitProperties { get; private set; } + /// /// Gets or sets the class cleanup method. /// @@ -441,6 +460,18 @@ async Task DoRunAsync() { // This runs the ClassInitialize methods only once but saves the await RunClassInitializeAsync(testContext.Context).ConfigureAwait(false); + + // Capture a snapshot of TestContext.Properties so that values set during + // ClassInitialize (and any base-class ClassInitialize methods that ran in the + // same RunClassInitializeAsync call) flow to subsequent contexts + // (test execution, class cleanup). + // The `is` check is defensive: this method is part of an internal but mockable + // surface, so unit tests can legitimately pass an ITestContext wrapping a mock. + // Production callers always wrap a TestContextImplementation. + if (testContext.Context is TestContextImplementation classInitContextImpl) + { + PostClassInitProperties = classInitContextImpl.CaptureLifecycleProperties(); + } } catch (TestFailedException ex) { diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs b/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs index a1face0676..2c38ec4225 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs @@ -157,6 +157,10 @@ internal async Task RunSingleTestAsync(UnitTestElement unitTestEle testContextForClassInit = PlatformServiceProvider.Instance.GetTestContext(testMethod: null, testMethod.FullClassName, testContextProperties, messageLogger, testContextForAssemblyInit.Context.CurrentTestOutcome); + // Flow properties set during AssemblyInitialize into the class-init context so the + // ClassInitialize method observes them. + ((TestContextImplementation)testContextForClassInit.Context).MergeProperties(testMethodInfo.Parent.Parent.PostAssemblyInitProperties); + TestResult classInitializeResult = await testMethodInfo.Parent.GetResultOrRunClassInitializeAsync(testContextForClassInit, assemblyInitializeResult.LogOutput, assemblyInitializeResult.LogError, assemblyInitializeResult.DebugTrace, assemblyInitializeResult.TestContextMessages).ConfigureAwait(false); DebugEx.Assert(testMethodInfo.Parent.IsClassInitializeExecuted, "IsClassInitializeExecuted should be true after attempting to run it."); if (classInitializeResult.Outcome != UnitTestOutcome.Passed) @@ -167,6 +171,16 @@ internal async Task RunSingleTestAsync(UnitTestElement unitTestEle { // Run the test method testContextForTestExecution.SetOutcome(testContextForClassInit.Context.CurrentTestOutcome); + + // Flow properties set during AssemblyInitialize and ClassInitialize into the + // per-test execution context so the test class constructor, [TestInitialize], + // the test method itself and [TestCleanup] observe them. + // Note: when a test method has multiple data rows, the merge is applied once + // before all rows; data rows share the same execution context (and bag). + var testExecImpl = (TestContextImplementation)testContextForTestExecution.Context; + testExecImpl.MergeProperties(testMethodInfo.Parent.Parent.PostAssemblyInitProperties); + testExecImpl.MergeProperties(testMethodInfo.Parent.PostClassInitProperties); + RetryBaseAttribute? retryAttribute = testMethodInfo.RetryAttribute; var testMethodRunner = new TestMethodRunner(testMethodInfo, testMethod, testContextForTestExecution); result = await testMethodRunner.ExecuteAsync(classInitializeResult.LogOutput, classInitializeResult.LogError, classInitializeResult.DebugTrace, classInitializeResult.TestContextMessages).ConfigureAwait(false); @@ -190,6 +204,13 @@ internal async Task RunSingleTestAsync(UnitTestElement unitTestEle { if (testMethodInfo is not null) { + // Flow properties set during AssemblyInitialize and ClassInitialize so the + // ClassCleanup method observes them. Done here rather than on every test to + // avoid wasted dictionary copies for non-last tests. + var classCleanupImpl = (TestContextImplementation)testContextForClassCleanup.Context; + classCleanupImpl.MergeProperties(testMethodInfo.Parent.Parent.PostAssemblyInitProperties); + classCleanupImpl.MergeProperties(testMethodInfo.Parent.PostClassInitProperties); + TestResult? cleanupResult = await testMethodInfo.Parent.RunClassCleanupAsync(testContextForClassCleanup, result).ConfigureAwait(false); if (cleanupResult is not null) { @@ -216,6 +237,14 @@ internal async Task RunSingleTestAsync(UnitTestElement unitTestEle _classCleanupManager.ShouldRunEndOfAssemblyCleanup) { testContextForAssemblyCleanup = PlatformServiceProvider.Instance.GetTestContext(testMethod: null, null, testContextProperties, messageLogger, testContextForClassCleanup.Context.CurrentTestOutcome); + + // Flow properties set during AssemblyInitialize so the AssemblyCleanup method + // observes them. Class-init properties are intentionally NOT flowed here because + // AssemblyCleanup is assembly-scoped and runs once across many classes; picking + // a single class's snapshot would be arbitrary. + // testMethodInfo is non-null inside this block thanks to the guard above. + ((TestContextImplementation)testContextForAssemblyCleanup.Context).MergeProperties(testMethodInfo.Parent.Parent.PostAssemblyInitProperties); + TestResult? assemblyCleanupResult = await RunAssemblyCleanupAsync(testContextForAssemblyCleanup, _typeCache, result).ConfigureAwait(false); if (assemblyCleanupResult is not null) { diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs index c7460abf34..accc7b4219 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs @@ -6,6 +6,8 @@ using System.Data.Common; #endif +using System.Collections.ObjectModel; + using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter; using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface; using Microsoft.VisualStudio.TestPlatform.ObjectModel.Logging; @@ -125,12 +127,15 @@ internal TestContextImplementation(ITestMethod? testMethod, string? testClassFul if (testClassFullName is not null) { - _properties.Add(FullyQualifiedTestClassNameLabel, testClassFullName); + // Use indexer assignment instead of Add so that re-seeding from a parent property + // snapshot that may already contain this key does not throw. + _properties[FullyQualifiedTestClassNameLabel] = testClassFullName; } if (testMethod is not null) { - _properties.Add(TestNameLabel, testMethod.Name); + // Use indexer assignment instead of Add for the same reason. + _properties[TestNameLabel] = testMethod.Name; } } @@ -281,6 +286,64 @@ public bool TryGetPropertyValue(string propertyName, out object? propertyValue) public void AddProperty(string propertyName, string propertyValue) => _properties.Add(propertyName, propertyValue); + /// + /// Merges the given properties into this context's property bag using indexer semantics + /// (existing keys are overwritten, except the per-context labels + /// and + /// , which are preserved). + /// Used to flow properties set during AssemblyInitialize / ClassInitialize + /// into subsequent contexts. + /// + /// The properties to merge in. May be . + internal void MergeProperties(IReadOnlyDictionary? propertiesToMerge) + { + if (propertiesToMerge is null) + { + return; + } + + foreach (KeyValuePair kvp in propertiesToMerge) + { + // Never overwrite the per-context labels. + if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) + { + continue; + } + + _properties[kvp.Key] = kvp.Value; + } + } + + /// + /// Captures a snapshot of the current property bag, excluding the per-context labels + /// ( and + /// ). The returned dictionary is intended to be + /// stored on a TestAssemblyInfo / TestClassInfo and later merged into other + /// contexts via . + /// + /// The snapshot is shallow: keys and value references are copied as-is. Reference-type + /// values stored in the bag (e.g. a mocked file system, a connection pool, a list) are + /// shared across every context the snapshot is later merged into. Mutations of those + /// reference-type instances are visible everywhere. + /// + /// + /// A read-only snapshot of the current properties. + internal IReadOnlyDictionary CaptureLifecycleProperties() + { + var snapshot = new Dictionary(_properties.Count); + foreach (KeyValuePair kvp in _properties) + { + if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) + { + continue; + } + + snapshot[kvp.Key] = kvp.Value; + } + + return new ReadOnlyDictionary(snapshot); + } + /// /// Result files attached. /// diff --git a/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/TestContextPropertyFlowTests.cs b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/TestContextPropertyFlowTests.cs new file mode 100644 index 0000000000..fc4c9c4040 --- /dev/null +++ b/test/IntegrationTests/MSTest.Acceptance.IntegrationTests/TestContextPropertyFlowTests.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform.Acceptance.IntegrationTests; +using Microsoft.Testing.Platform.Acceptance.IntegrationTests.Helpers; + +namespace MSTest.Acceptance.IntegrationTests; + +/// +/// Acceptance tests for the flow of values across the +/// AssemblyInitialize / ClassInitialize / TestMethod / ClassCleanup / AssemblyCleanup lifecycle. +/// See https://github.com/microsoft/testfx/issues/5986. +/// +[TestClass] +public sealed class TestContextPropertyFlowTests : AcceptanceTestBase +{ + [TestMethod] + [DynamicData(nameof(TargetFrameworks.AllForDynamicData), typeof(TargetFrameworks))] + public async Task PropertiesSetInAssemblyInitAndClassInitAreVisibleEverywhere(string tfm) + { + var testHost = TestHost.LocateFrom(AssetFixture.ProjectPath, TestAssetFixture.ProjectName, tfm); + TestHostResult testHostResult = await testHost.ExecuteAsync(cancellationToken: TestContext.CancellationToken); + + testHostResult.AssertExitCodeIs(0); + // PropertyFlowTests: TestMethodOne + TestMethodTwo = 2 + // SecondClassTests: TestMethod = 1 + // DataRowFlowTests: two data rows = 2 + // Total = 5 + testHostResult.AssertOutputContainsSummary(failed: 0, passed: 5, skipped: 0); + } + + public sealed class TestAssetFixture() : TestAssetFixtureBase() + { + public const string ProjectName = "TestContextPropertyFlow"; + + public string ProjectPath => GetAssetPath(ProjectName); + + public override (string ID, string Name, string Code) GetAssetsToGenerate() => (ProjectName, ProjectName, + SourceCode + .PatchTargetFrameworks(TargetFrameworks.All) + .PatchCodeWithReplace("$MSTestVersion$", MSTestVersion)); + + private const string SourceCode = """ +#file TestContextPropertyFlow.csproj + + + + Exe + true + $TargetFrameworks$ + preview + true + + + + + + + + +#file UnitTest1.cs +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public sealed class PropertyFlowTests +{ + public TestContext TestContext { get; set; } = null!; + + [AssemblyInitialize] + public static void AssemblyInit(TestContext context) + { + context.Properties["AssemblyInitKey"] = "AssemblyInitValue"; + context.Properties["SharedKey"] = "FromAssemblyInit"; + } + + [ClassInitialize] + public static void ClassInit(TestContext context) + { + // AssemblyInit-set properties must be visible inside ClassInitialize. + Assert.AreEqual("AssemblyInitValue", context.Properties["AssemblyInitKey"]); + Assert.AreEqual("FromAssemblyInit", context.Properties["SharedKey"]); + + context.Properties["ClassInitKey"] = "ClassInitValue"; + // ClassInit overrides a value previously set by AssemblyInit; the override should win + // in subsequent contexts (tests, cleanups) of this class. + context.Properties["SharedKey"] = "FromClassInit"; + } + + [TestMethod] + public void TestMethodOne() + { + Assert.AreEqual("AssemblyInitValue", TestContext.Properties["AssemblyInitKey"]); + Assert.AreEqual("ClassInitValue", TestContext.Properties["ClassInitKey"]); + // ClassInit's override of SharedKey must win. + Assert.AreEqual("FromClassInit", TestContext.Properties["SharedKey"]); + + // Properties added by a sibling test method must NOT leak across tests. + Assert.IsFalse( + TestContext.Properties.ContainsKey("FromTestTwo"), + "Properties set by TestMethodTwo should not leak into TestMethodOne."); + + TestContext.Properties["FromTestOne"] = "ShouldNotLeak"; + } + + [TestMethod] + public void TestMethodTwo() + { + Assert.AreEqual("AssemblyInitValue", TestContext.Properties["AssemblyInitKey"]); + Assert.AreEqual("ClassInitValue", TestContext.Properties["ClassInitKey"]); + Assert.AreEqual("FromClassInit", TestContext.Properties["SharedKey"]); + + // Properties added by a sibling test method must NOT leak across tests. + Assert.IsFalse( + TestContext.Properties.ContainsKey("FromTestOne"), + "Properties set by TestMethodOne should not leak into TestMethodTwo."); + + TestContext.Properties["FromTestTwo"] = "ShouldNotLeak"; + } + + [ClassCleanup] + public static void ClassCleanup(TestContext context) + { + Assert.AreEqual("AssemblyInitValue", context.Properties["AssemblyInitKey"]); + Assert.AreEqual("ClassInitValue", context.Properties["ClassInitKey"]); + Assert.AreEqual("FromClassInit", context.Properties["SharedKey"]); + } + + [AssemblyCleanup] + public static void AssemblyCleanup(TestContext context) + { + // AssemblyCleanup must see AssemblyInit-set properties, including any override the + // AssemblyInit itself made. It must NOT see ClassInit-set properties (those are class-scoped). + Assert.AreEqual("AssemblyInitValue", context.Properties["AssemblyInitKey"]); + Assert.AreEqual("FromAssemblyInit", context.Properties["SharedKey"]); + Assert.IsFalse( + context.Properties.ContainsKey("ClassInitKey"), + "Properties set by ClassInitialize should not flow to AssemblyCleanup."); + // SecondClassTests.ClassInit must not leak into the AssemblyCleanup either. + Assert.IsFalse( + context.Properties.ContainsKey("SecondClassKey"), + "Properties set by SecondClassTests.ClassInitialize should not flow to AssemblyCleanup."); + } +} + +// Second class to verify that the assembly-init snapshot flows here too, AND that the +// FIRST class's class-init snapshot does NOT leak into THIS class's contexts. +[TestClass] +public sealed class SecondClassTests +{ + public TestContext TestContext { get; set; } = null!; + + [ClassInitialize] + public static void ClassInit(TestContext context) + { + // AssemblyInit-set properties must be visible here too. + Assert.AreEqual("AssemblyInitValue", context.Properties["AssemblyInitKey"]); + Assert.AreEqual("FromAssemblyInit", context.Properties["SharedKey"]); + // PropertyFlowTests's class-init properties must NOT leak across classes. + Assert.IsFalse( + context.Properties.ContainsKey("ClassInitKey"), + "Properties set by another class's ClassInitialize should not flow to this class's ClassInitialize."); + + context.Properties["SecondClassKey"] = "SecondClassValue"; + } + + [TestMethod] + public void TestMethod() + { + Assert.AreEqual("AssemblyInitValue", TestContext.Properties["AssemblyInitKey"]); + Assert.AreEqual("SecondClassValue", TestContext.Properties["SecondClassKey"]); + // First class's class-init properties must not leak. + Assert.IsFalse( + TestContext.Properties.ContainsKey("ClassInitKey"), + "Properties set by another class's ClassInitialize should not flow to this class's tests."); + } +} + +// Data-driven flow: a single TestContext is used for all data rows so the merged properties +// from AssemblyInit/ClassInit are visible to every row. +[TestClass] +public sealed class DataRowFlowTests +{ + public TestContext TestContext { get; set; } = null!; + + [ClassInitialize] + public static void ClassInit(TestContext context) + => context.Properties["DataRowClassInitKey"] = "DataRowClassInitValue"; + + [TestMethod] + [DataRow(1)] + [DataRow(2)] + public void DataRowSeesFlowedProperties(int rowNumber) + { + Assert.AreEqual("AssemblyInitValue", TestContext.Properties["AssemblyInitKey"]); + Assert.AreEqual("DataRowClassInitValue", TestContext.Properties["DataRowClassInitKey"]); + Assert.IsTrue(rowNumber > 0); + } +} +"""; + } + + public TestContext TestContext { get; set; } = null!; +} diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs index 2c300bcb84..ebd7b00209 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs @@ -229,6 +229,68 @@ public async Task RunAssemblyInitializeShouldPassOnTheTestContextToAssemblyInitM result.Outcome.Should().Be(UnitTestOutcome.Passed); } + public async Task RunAssemblyInitializeShouldCapturePostAssemblyInitPropertiesOnSuccess() + { + DummyTestClass.AssemblyInitializeMethodBody = tc => + { + var context = (TestContext)tc; + context.Properties["AssemblyInitKey"] = "AssemblyInitValue"; + context.Properties["AnotherKey"] = 42; + }; + _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!; + + TestContextImplementation testContext = GetTestContext(); + TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + _testAssemblyInfo.PostAssemblyInitProperties.Should().NotBeNull(); + _testAssemblyInfo.PostAssemblyInitProperties!.Should().ContainKey("AssemblyInitKey"); + _testAssemblyInfo.PostAssemblyInitProperties["AssemblyInitKey"].Should().Be("AssemblyInitValue"); + _testAssemblyInfo.PostAssemblyInitProperties["AnotherKey"].Should().Be(42); + } + + public async Task RunAssemblyInitializeShouldExcludePerContextLabelsFromPostAssemblyInitProperties() + { + DummyTestClass.AssemblyInitializeMethodBody = tc => ((TestContext)tc).Properties["UserKey"] = "UserValue"; + _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!; + + // Use a context with a class name so the FullyQualifiedTestClassName label is present. + TestContextImplementation testContext = new(null, "Dummy.ClassName", new Dictionary(), null, null); + TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + _testAssemblyInfo.PostAssemblyInitProperties.Should().NotBeNull(); + _testAssemblyInfo.PostAssemblyInitProperties!.Should().NotContainKey(TestContext.FullyQualifiedTestClassNameLabel); + _testAssemblyInfo.PostAssemblyInitProperties.Should().NotContainKey(TestContext.TestNameLabel); + _testAssemblyInfo.PostAssemblyInitProperties.Should().ContainKey("UserKey"); + } + + public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullWhenAssemblyInitMethodIsNull() + { + _testAssemblyInfo.AssemblyInitializeMethod = null; + + TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(null!); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + _testAssemblyInfo.PostAssemblyInitProperties.Should().BeNull(); + } + + public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullOnFailure() + { + DummyTestClass.AssemblyInitializeMethodBody = tc => + { + ((TestContext)tc).Properties["AssemblyInitKey"] = "AssemblyInitValue"; + throw new InvalidOperationException("boom"); + }; + _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!; + + TestContextImplementation testContext = GetTestContext(); + TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext); + + result.Outcome.Should().NotBe(UnitTestOutcome.Passed); + _testAssemblyInfo.PostAssemblyInitProperties.Should().BeNull(); + } + #endregion #region Run Assembly Cleanup tests diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs index 10ba7e293e..39824780e9 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs @@ -426,6 +426,79 @@ public void RunClassInitializeShouldThrowTheInnerMostExceptionWhenThereAreMultip exception.InnerException.Should().BeOfType(); } + public async Task GetResultOrRunClassInitializeAsyncShouldCapturePostClassInitPropertiesOnSuccess() + { + DummyTestClass.ClassInitializeMethodBody = tc => + { + var context = (TestContext)tc; + context.Properties["ClassInitKey"] = "ClassInitValue"; + context.Properties["AnotherKey"] = 123; + }; + _testClassInfo.ClassInitializeMethod = typeof(DummyTestClass).GetMethod(nameof(DummyTestClass.ClassInitializeMethod)); + + TestContextImplementation realTestContext = new(null, _testClassType.FullName, new Dictionary(), null, null); + TestResult result = await _testClassInfo.GetResultOrRunClassInitializeAsync(realTestContext, string.Empty, string.Empty, string.Empty, string.Empty); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + _testClassInfo.PostClassInitProperties.Should().NotBeNull(); + _testClassInfo.PostClassInitProperties!.Should().ContainKey("ClassInitKey"); + _testClassInfo.PostClassInitProperties["ClassInitKey"].Should().Be("ClassInitValue"); + _testClassInfo.PostClassInitProperties["AnotherKey"].Should().Be(123); + // The per-context label must never leak into the snapshot. + _testClassInfo.PostClassInitProperties.Should().NotContainKey(TestContext.FullyQualifiedTestClassNameLabel); + } + + public async Task GetResultOrRunClassInitializeAsyncShouldLeavePostClassInitPropertiesNullWhenClassInitMethodIsNull() + { + _testClassInfo.ClassInitializeMethod = null; + + TestContextImplementation realTestContext = new(null, _testClassType.FullName, new Dictionary(), null, null); + TestResult result = await _testClassInfo.GetResultOrRunClassInitializeAsync(realTestContext, string.Empty, string.Empty, string.Empty, string.Empty); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + _testClassInfo.PostClassInitProperties.Should().BeNull(); + } + + public async Task GetResultOrRunClassInitializeAsyncShouldLeavePostClassInitPropertiesNullOnFailure() + { + DummyTestClass.ClassInitializeMethodBody = tc => + { + ((TestContext)tc).Properties["ClassInitKey"] = "ClassInitValue"; + throw new InvalidOperationException("boom"); + }; + _testClassInfo.ClassInitializeMethod = typeof(DummyTestClass).GetMethod(nameof(DummyTestClass.ClassInitializeMethod)); + + TestContextImplementation realTestContext = new(null, _testClassType.FullName, new Dictionary(), null, null); + TestResult result = await _testClassInfo.GetResultOrRunClassInitializeAsync(realTestContext, string.Empty, string.Empty, string.Empty, string.Empty); + + result.Outcome.Should().NotBe(UnitTestOutcome.Passed); + _testClassInfo.PostClassInitProperties.Should().BeNull(); + } + + public async Task GetResultOrRunClassInitializeAsyncShouldCapturePropertiesFromBaseAndDerivedClassInitMethods() + { + // Base class init runs first (added via BaseClassInitMethods), then derived class init. + DummyBaseTestClass.ClassInitializeMethodBody = tc => ((TestContext)tc).Properties["BaseKey"] = "BaseValue"; + DummyTestClass.ClassInitializeMethodBody = tc => + { + var context = (TestContext)tc; + // Derived class init can see what base set. + context.Properties["BaseKey"].Should().Be("BaseValue"); + context.Properties["DerivedKey"] = "DerivedValue"; + }; + + _testClassInfo.BaseClassInitMethods.Add(typeof(DummyBaseTestClass).GetMethod(nameof(DummyBaseTestClass.InitBaseClassMethod))!); + _testClassInfo.ClassInitializeMethod = typeof(DummyTestClass).GetMethod(nameof(DummyTestClass.ClassInitializeMethod)); + + TestContextImplementation realTestContext = new(null, _testClassType.FullName, new Dictionary(), null, null); + TestResult result = await _testClassInfo.GetResultOrRunClassInitializeAsync(realTestContext, string.Empty, string.Empty, string.Empty, string.Empty); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + _testClassInfo.PostClassInitProperties.Should().NotBeNull(); + _testClassInfo.PostClassInitProperties!["BaseKey"].Should().Be("BaseValue"); + _testClassInfo.PostClassInitProperties["DerivedKey"].Should().Be("DerivedValue"); + } + private TestResult GetResultOrRunClassInitialize() => GetResultOrRunClassInitialize(_testContext); diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs index a0ad10507b..bea0e44248 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs @@ -401,4 +401,120 @@ public void WritesFromBackgroundThreadShouldNotThrow() _ = testContextImplementation.GetAndClearTrace(); t.Join(); } + + public void MergePropertiesShouldAddNewKeysIntoThePropertyBag() + { + _testContextImplementation = CreateTestContextImplementation(); + IReadOnlyDictionary snapshot = new Dictionary + { + ["NewKey"] = "NewValue", + ["AnotherKey"] = 42, + }; + + _testContextImplementation.MergeProperties(snapshot); + + _testContextImplementation.Properties["NewKey"].Should().Be("NewValue"); + _testContextImplementation.Properties["AnotherKey"].Should().Be(42); + } + + public void MergePropertiesShouldOverwriteExistingKeys() + { + _testContextImplementation = CreateTestContextImplementation(); + _testContextImplementation.Properties["Key"] = "Original"; + + _testContextImplementation.MergeProperties(new Dictionary { ["Key"] = "Overwritten" }); + + _testContextImplementation.Properties["Key"].Should().Be("Overwritten"); + } + + public void MergePropertiesShouldIgnoreNull() + { + _testContextImplementation = CreateTestContextImplementation(); + _testContextImplementation.Properties["Key"] = "Original"; + + _testContextImplementation.MergeProperties(null); + + _testContextImplementation.Properties["Key"].Should().Be("Original"); + } + + public void MergePropertiesShouldNotOverwritePerContextLabels() + { + _testMethod.Setup(tm => tm.FullClassName).Returns("A.C.M"); + _testMethod.Setup(tm => tm.Name).Returns("M"); + _testContextImplementation = CreateTestContextImplementation(); + + _testContextImplementation.MergeProperties(new Dictionary + { + ["FullyQualifiedTestClassName"] = "Hacked.Class", + ["TestName"] = "HackedTestName", + ["LegitKey"] = "LegitValue", + }); + + _testContextImplementation.Properties["FullyQualifiedTestClassName"].Should().Be("A.C.M"); + _testContextImplementation.Properties["TestName"].Should().Be("M"); + _testContextImplementation.Properties["LegitKey"].Should().Be("LegitValue"); + } + + public void CaptureLifecyclePropertiesShouldReturnAllPropertiesExceptPerContextLabels() + { + _testMethod.Setup(tm => tm.FullClassName).Returns("A.C.M"); + _testMethod.Setup(tm => tm.Name).Returns("M"); + _testContextImplementation = CreateTestContextImplementation(); + _testContextImplementation.Properties["UserKey"] = "UserValue"; + _testContextImplementation.Properties["AnotherKey"] = 7; + + IReadOnlyDictionary snapshot = _testContextImplementation.CaptureLifecycleProperties(); + + snapshot.Should().ContainKey("UserKey"); + snapshot["UserKey"].Should().Be("UserValue"); + snapshot.Should().ContainKey("AnotherKey"); + snapshot["AnotherKey"].Should().Be(7); + snapshot.Should().NotContainKey("FullyQualifiedTestClassName"); + snapshot.Should().NotContainKey("TestName"); + } + + public void CaptureLifecyclePropertiesShouldReturnSnapshotIndependentOfTheLiveBag() + { + _testContextImplementation = CreateTestContextImplementation(); + _testContextImplementation.Properties["Key"] = "OriginalValue"; + + IReadOnlyDictionary snapshot = _testContextImplementation.CaptureLifecycleProperties(); + + // Mutating the live bag must not affect the snapshot. + _testContextImplementation.Properties["Key"] = "ChangedValue"; + _testContextImplementation.Properties["NewKey"] = "NewValue"; + + snapshot["Key"].Should().Be("OriginalValue"); + snapshot.Should().NotContainKey("NewKey"); + } + + public void CaptureLifecyclePropertiesShouldAliasReferenceTypeValues() + { + _testContextImplementation = CreateTestContextImplementation(); + var bag = new List { 1 }; + _testContextImplementation.Properties["RefKey"] = bag; + + IReadOnlyDictionary snapshot = _testContextImplementation.CaptureLifecycleProperties(); + + // The snapshot is shallow: the snapshot's value and the live bag share the same instance. + // Mutating the instance must therefore be visible through both. This guards the documented + // contract on CaptureLifecycleProperties from accidentally regressing to a deep copy. + bag.Add(2); + ((List)snapshot["RefKey"]!).Should().BeEquivalentTo(new[] { 1, 2 }); + } + + public void ConstructorShouldNotThrowWhenSeededPropertiesAlreadyContainFullyQualifiedTestClassName() + { + _testMethod.Setup(tm => tm.FullClassName).Returns("A.C.M"); + var seeded = new Dictionary + { + ["FullyQualifiedTestClassName"] = "Old.Class.Name", + }; + + // Should not throw — the ctor now uses indexer assignment for labels. + var ctx = new TestContextImplementation(_testMethod.Object, null, seeded, null, null); + + // The per-context value wins. + ctx.Properties["FullyQualifiedTestClassName"].Should().Be("A.C.M"); + } }