Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ public void AddProperty(string propertyName, string propertyValue)
/// <param name="propertiesToMerge">The properties to merge in. May be <see langword="null"/>.</param>
internal void MergeProperties(IReadOnlyDictionary<string, object?>? propertiesToMerge)
{
if (propertiesToMerge is null)
if (propertiesToMerge is null or { Count: 0 })
{
return;
}
Expand Down Expand Up @@ -343,6 +343,12 @@ internal void MergeProperties(IReadOnlyDictionary<string, object?>? propertiesTo
/// stored on a <c>TestAssemblyInfo</c> / <c>TestClassInfo</c> and later merged into other
/// contexts via <see cref="MergeProperties(IReadOnlyDictionary{string, object?}?)"/>.
/// <para>
/// Returns <see langword="null"/> when there are no non-label properties to capture
/// (the common case when <c>AssemblyInitialize</c> / <c>ClassInitialize</c> do not set
/// properties on <c>TestContext</c>). <see cref="MergeProperties"/> already handles a
/// <see langword="null"/> argument as a no-op, so callers need not special-case this.
/// </para>
/// <para>
/// 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
Expand All @@ -358,27 +364,30 @@ internal void MergeProperties(IReadOnlyDictionary<string, object?>? propertiesTo
/// thread-affinity expectation of <c>AssemblyInitialize</c> / <c>ClassInitialize</c>.
/// </para>
/// </summary>
/// <returns>A read-only snapshot of the current properties.</returns>
internal IReadOnlyDictionary<string, object?> CaptureLifecycleProperties()
/// <returns>
/// A read-only snapshot of the current properties (excluding per-context labels), or
/// <see langword="null"/> if there are no such properties to snapshot.
/// </returns>
internal IReadOnlyDictionary<string, object?>? CaptureLifecycleProperties()
{
Dictionary<string, object?> snapshot;
Dictionary<string, object?>? snapshot = null;
lock (_propertiesLock)
{
#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional.
snapshot = new Dictionary<string, object?>(_properties.Count);
#pragma warning restore IDE0028
foreach (KeyValuePair<string, object?> kvp in _properties)
{
if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel)
{
continue;
}

#pragma warning disable IDE0028 // Collection initialization can be simplified - capacity hint is intentional.
snapshot ??= new Dictionary<string, object?>(_properties.Count);
#pragma warning restore IDE0028
snapshot[kvp.Key] = kvp.Value;
}
}

return new ReadOnlyDictionary<string, object?>(snapshot);
return snapshot is null ? null : new ReadOnlyDictionary<string, object?>(snapshot);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>(), 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object?>());

_testContextImplementation.Properties["Key"].Should().Be("Original");
}

public void MergePropertiesShouldNotOverwritePerContextLabels()
{
_testMethod.Setup(tm => tm.FullClassName).Returns("A.C.M");
Expand All @@ -497,28 +507,41 @@ public void CaptureLifecyclePropertiesShouldReturnAllPropertiesExceptPerContextL
_testContextImplementation.Properties["UserKey"] = "UserValue";
_testContextImplementation.Properties["AnotherKey"] = 7;

IReadOnlyDictionary<string, object?> snapshot = _testContextImplementation.CaptureLifecycleProperties();
IReadOnlyDictionary<string, object?>? 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");
}

Comment thread
Evangelink marked this conversation as resolved.
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<string, object?>? snapshot = _testContextImplementation.CaptureLifecycleProperties();

snapshot.Should().BeNull();
}

public void CaptureLifecyclePropertiesShouldReturnSnapshotIndependentOfTheLiveBag()
{
_testContextImplementation = CreateTestContextImplementation();
_testContextImplementation.Properties["Key"] = "OriginalValue";

IReadOnlyDictionary<string, object?> snapshot = _testContextImplementation.CaptureLifecycleProperties();
IReadOnlyDictionary<string, object?>? 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");
}

Expand All @@ -528,13 +551,14 @@ public void CaptureLifecyclePropertiesShouldAliasReferenceTypeValues()
var bag = new List<int> { 1 };
_testContextImplementation.Properties["RefKey"] = bag;

IReadOnlyDictionary<string, object?> snapshot = _testContextImplementation.CaptureLifecycleProperties();
IReadOnlyDictionary<string, object?>? 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<int>)snapshot["RefKey"]!).Should().BeEquivalentTo(new[] { 1, 2 });
((List<int>)snapshot!["RefKey"]!).Should().BeEquivalentTo(new[] { 1, 2 });
}

public void CaptureLifecyclePropertiesAndMergePropertiesShouldNotLockOnExposedPropertyBag()
Expand Down
Loading