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 @@ -57,7 +57,13 @@ internal static void ForceCleanup(TypeCache typeCache, IDictionary<string, objec
IEnumerable<TestClassInfo> 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)
{
Expand All @@ -68,7 +74,12 @@ internal static void ForceCleanup(TypeCache typeCache, IDictionary<string, objec
IEnumerable<TestAssemblyInfo> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ internal set
/// </summary>
public TestFailedException? AssemblyInitializationException { get; internal set; }

/// <summary>
/// Gets a snapshot of <see cref="TestContext.Properties"/> captured after the
/// <c>AssemblyInitialize</c> method completes. Used to flow properties set during
/// <c>AssemblyInitialize</c> into subsequent contexts (class init, test execution,
/// class cleanup, assembly cleanup). <see langword="null"/> if no
/// <c>AssemblyInitialize</c> method was registered or it has not yet executed
/// successfully.
/// <para>
/// 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.
/// </para>
/// <para>
/// Class-init properties are intentionally NOT included by callers when seeding the
/// assembly-cleanup context, because <c>AssemblyCleanup</c> is assembly-scoped and runs
/// once across many classes; including a single class's snapshot would be arbitrary.
/// </para>
/// </summary>
internal IReadOnlyDictionary<string, object?>? PostAssemblyInitProperties { get; private set; }

/// <summary>
/// Gets the assembly cleanup exception.
/// </summary>
Expand Down Expand Up @@ -160,6 +180,25 @@ public async Task<TestResult> 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();
Comment on lines +193 to +200
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the follow-up PR #8396: PostAssemblyInitProperties (and the matching PostClassInitProperties on TestClassInfo) now use Volatile.Read / Volatile.Write, replacing the temporary TODO left in the merged commit. The publishing thread does the Volatile.Write before the IsAssemblyInitializeExecuted flag flip; consumers Volatile-read the snapshot directly (the call site does not gate on the executed flag), so the snapshot field is the only thing that needs an acquire/release pair to be safely observed on the bypass-the-semaphore fast path.

}
},
testContext.CancellationTokenSource,
AssemblyInitializeMethodTimeoutMilliseconds,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,25 @@ internal set
/// </summary>
public Exception? ClassCleanupException { get; internal set; }

/// <summary>
/// Gets a snapshot of <see cref="TestContext.Properties"/> captured after the
/// <c>ClassInitialize</c> method completes. Used to flow properties set during
/// <c>ClassInitialize</c> into subsequent contexts (test execution, class cleanup).
/// <see langword="null"/> if no <c>ClassInitialize</c> method was registered or it has
/// not yet executed successfully.
/// <para>
/// When the test class inherits from a base class that has a <c>ClassInitialize</c>
/// method declared with <see cref="InheritanceBehavior.BeforeEachDerivedClass"/>, 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.
/// </para>
/// <para>
/// The snapshot is shallow: reference-type values stored in the bag are shared (aliased)
/// across every context the snapshot is merged into.
/// </para>
/// </summary>
internal IReadOnlyDictionary<string, object?>? PostClassInitProperties { get; private set; }

/// <summary>
/// Gets or sets the class cleanup method.
/// </summary>
Expand Down Expand Up @@ -441,6 +460,18 @@ async Task<TestResult> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ internal async Task<TestResult[]> 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)
Expand All @@ -167,6 +171,16 @@ internal async Task<TestResult[]> 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);
Expand All @@ -190,6 +204,13 @@ internal async Task<TestResult[]> 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)
{
Expand All @@ -216,6 +237,14 @@ internal async Task<TestResult[]> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -281,6 +286,64 @@ public bool TryGetPropertyValue(string propertyName, out object? propertyValue)
public void AddProperty(string propertyName, string propertyValue)
=> _properties.Add(propertyName, propertyValue);

/// <summary>
/// Merges the given properties into this context's property bag using indexer semantics
/// (existing keys are overwritten, except the per-context labels
/// <see cref="TestContext.FullyQualifiedTestClassNameLabel"/> and
/// <see cref="TestContext.TestNameLabel"/>, which are preserved).
/// Used to flow properties set during <c>AssemblyInitialize</c> / <c>ClassInitialize</c>
/// into subsequent contexts.
/// </summary>
/// <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)
{
return;
}

foreach (KeyValuePair<string, object?> kvp in propertiesToMerge)
{
// Never overwrite the per-context labels.
if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel)
{
continue;
}

_properties[kvp.Key] = kvp.Value;
}
}

/// <summary>
/// Captures a snapshot of the current property bag, excluding the per-context labels
/// (<see cref="TestContext.FullyQualifiedTestClassNameLabel"/> and
/// <see cref="TestContext.TestNameLabel"/>). The returned dictionary is intended to be
/// stored on a <c>TestAssemblyInfo</c> / <c>TestClassInfo</c> and later merged into other
/// contexts via <see cref="MergeProperties(IReadOnlyDictionary{string, object?}?)"/>.
/// <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
/// reference-type instances are visible everywhere.
/// </para>
/// </summary>
/// <returns>A read-only snapshot of the current properties.</returns>
internal IReadOnlyDictionary<string, object?> CaptureLifecycleProperties()
{
var snapshot = new Dictionary<string, object?>(_properties.Count);
foreach (KeyValuePair<string, object?> kvp in _properties)
{
if (kvp.Key == FullyQualifiedTestClassNameLabel || kvp.Key == TestNameLabel)
{
continue;
}

snapshot[kvp.Key] = kvp.Value;
}

return new ReadOnlyDictionary<string, object?>(snapshot);
Comment on lines +333 to +344
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the follow-up PR #8396: CaptureLifecycleProperties now enumerates _properties under a lock so two snapshot calls cannot trip over each other. The doc-comment is explicit that writes via the public TestContext.Properties indexer bypass this lock — a lifecycle method that spawns a background thread which keeps mutating Properties past method return is treated as user error and out of scope, consistent with the long-standing thread-affinity expectation of AssemblyInitialize / ClassInitialize.

}

/// <summary>
/// Result files attached.
/// </summary>
Expand Down
Loading
Loading