diff --git a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs index bcfce57967..5efc98a041 100644 --- a/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs +++ b/src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs @@ -312,7 +312,7 @@ public void AddProperty(string propertyName, string propertyValue) /// The properties to merge in. May be . internal void MergeProperties(IReadOnlyDictionary? propertiesToMerge) { - if (propertiesToMerge is null) + if (propertiesToMerge is null or { Count: 0 }) { return; } @@ -343,6 +343,12 @@ internal void MergeProperties(IReadOnlyDictionary? propertiesTo /// stored on a TestAssemblyInfo / TestClassInfo and later merged into other /// contexts via . /// + /// Returns when there are no non-label properties to capture + /// (the common case when AssemblyInitialize / ClassInitialize do not set + /// properties on TestContext). already handles a + /// argument as a no-op, so callers need not special-case this. + /// + /// /// 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 @@ -358,15 +364,15 @@ internal void MergeProperties(IReadOnlyDictionary? propertiesTo /// thread-affinity expectation of AssemblyInitialize / ClassInitialize. /// /// - /// A read-only snapshot of the current properties. - internal IReadOnlyDictionary CaptureLifecycleProperties() + /// + /// A read-only snapshot of the current properties (excluding per-context labels), or + /// if there are no such properties to snapshot. + /// + internal IReadOnlyDictionary? CaptureLifecycleProperties() { - Dictionary snapshot; + Dictionary? snapshot = null; lock (_propertiesLock) { -#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional. - snapshot = new Dictionary(_properties.Count); -#pragma warning restore IDE0028 foreach (KeyValuePair kvp in _properties) { if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel) @@ -374,11 +380,14 @@ internal void MergeProperties(IReadOnlyDictionary? propertiesTo continue; } +#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional. + snapshot ??= new Dictionary(_properties.Count); +#pragma warning restore IDE0028 snapshot[kvp.Key] = kvp.Value; } } - return new ReadOnlyDictionary(snapshot); + return snapshot is null ? null : new ReadOnlyDictionary(snapshot); } /// diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs index ebd7b00209..6f881dd76b 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs @@ -265,6 +265,22 @@ public async Task RunAssemblyInitializeShouldExcludePerContextLabelsFromPostAsse _testAssemblyInfo.PostAssemblyInitProperties.Should().ContainKey("UserKey"); } + public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullWhenAssemblyInitSetsNoProperties() + { + // AssemblyInitialize exists and succeeds but sets no TestContext.Properties. + DummyTestClass.AssemblyInitializeMethodBody = _ => { }; + _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!; + + TestContextImplementation testContext = GetTestContext(); + TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext); + + result.Outcome.Should().Be(UnitTestOutcome.Passed); + + // CaptureLifecycleProperties returns null when no non-label properties were set, + // so MergeProperties can short-circuit without acquiring a lock on each test's context. + _testAssemblyInfo.PostAssemblyInitProperties.Should().BeNull(); + } + public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullWhenAssemblyInitMethodIsNull() { _testAssemblyInfo.AssemblyInitializeMethod = null; diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs index 39824780e9..ccfa1a5182 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs @@ -448,6 +448,21 @@ public async Task GetResultOrRunClassInitializeAsyncShouldCapturePostClassInitPr _testClassInfo.PostClassInitProperties.Should().NotContainKey(TestContext.FullyQualifiedTestClassNameLabel); } + public async Task GetResultOrRunClassInitializeAsyncShouldLeavePostClassInitPropertiesNullWhenClassInitSetsNoProperties() + { + // ClassInitialize exists and succeeds but sets no TestContext.Properties. + DummyTestClass.ClassInitializeMethodBody = _ => { }; + _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); + + // CaptureLifecycleProperties returns null when no non-label properties were set. + _testClassInfo.PostClassInitProperties.Should().BeNull(); + } + public async Task GetResultOrRunClassInitializeAsyncShouldLeavePostClassInitPropertiesNullWhenClassInitMethodIsNull() { _testClassInfo.ClassInitializeMethod = null; diff --git a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs index 641197958c..6062851605 100644 --- a/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs +++ b/test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs @@ -471,6 +471,16 @@ public void MergePropertiesShouldIgnoreNull() _testContextImplementation.Properties["Key"].Should().Be("Original"); } + public void MergePropertiesShouldIgnoreEmptyDictionary() + { + _testContextImplementation = CreateTestContextImplementation(); + _testContextImplementation.Properties["Key"] = "Original"; + + _testContextImplementation.MergeProperties(new Dictionary()); + + _testContextImplementation.Properties["Key"].Should().Be("Original"); + } + public void MergePropertiesShouldNotOverwritePerContextLabels() { _testMethod.Setup(tm => tm.FullClassName).Returns("A.C.M"); @@ -497,28 +507,41 @@ public void CaptureLifecyclePropertiesShouldReturnAllPropertiesExceptPerContextL _testContextImplementation.Properties["UserKey"] = "UserValue"; _testContextImplementation.Properties["AnotherKey"] = 7; - IReadOnlyDictionary snapshot = _testContextImplementation.CaptureLifecycleProperties(); + IReadOnlyDictionary? snapshot = _testContextImplementation.CaptureLifecycleProperties(); + snapshot.Should().NotBeNull(); snapshot.Should().ContainKey("UserKey"); - snapshot["UserKey"].Should().Be("UserValue"); + snapshot!["UserKey"].Should().Be("UserValue"); snapshot.Should().ContainKey("AnotherKey"); - snapshot["AnotherKey"].Should().Be(7); + snapshot!["AnotherKey"].Should().Be(7); snapshot.Should().NotContainKey("FullyQualifiedTestClassName"); snapshot.Should().NotContainKey("TestName"); } + public void CaptureLifecyclePropertiesShouldReturnNullWhenNoNonLabelPropertiesExist() + { + _testContextImplementation = CreateTestContextImplementation(); + + // Context has no properties at all; no labels were seeded because the ITestMethod mock is + // unconfigured (FullClassName/Name return null) and testClassFullName is null. + IReadOnlyDictionary? snapshot = _testContextImplementation.CaptureLifecycleProperties(); + + snapshot.Should().BeNull(); + } + public void CaptureLifecyclePropertiesShouldReturnSnapshotIndependentOfTheLiveBag() { _testContextImplementation = CreateTestContextImplementation(); _testContextImplementation.Properties["Key"] = "OriginalValue"; - IReadOnlyDictionary snapshot = _testContextImplementation.CaptureLifecycleProperties(); + 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().NotBeNull(); + snapshot!["Key"].Should().Be("OriginalValue"); snapshot.Should().NotContainKey("NewKey"); } @@ -528,13 +551,14 @@ public void CaptureLifecyclePropertiesShouldAliasReferenceTypeValues() var bag = new List { 1 }; _testContextImplementation.Properties["RefKey"] = bag; - IReadOnlyDictionary snapshot = _testContextImplementation.CaptureLifecycleProperties(); + 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. + snapshot.Should().NotBeNull(); bag.Add(2); - ((List)snapshot["RefKey"]!).Should().BeEquivalentTo(new[] { 1, 2 }); + ((List)snapshot!["RefKey"]!).Should().BeEquivalentTo(new[] { 1, 2 }); } public void CaptureLifecyclePropertiesAndMergePropertiesShouldNotLockOnExposedPropertyBag()