[perf-improver] perf: defer class-cleanup TestContextImplementation allocation to last test in class#9461
Conversation
…t test in class For every test except the last test in a class, the class-cleanup TestContextImplementation was allocated unconditionally at the start of RunSingleTestAsync (line 240), but then never used because isLastTestInClass was false. The context ctor copies the testContextProperties dictionary and registers a CancellationTokenRegistration, making it a non-trivial allocation. Move the allocation inside the `if (isLastTestInClass)` block. Safety invariant: the assembly-cleanup branch at line 279 reads executes only when ShouldRunEndOfAssemblyCleanup is true, which requires MarkClassComplete to have been called — and MarkClassComplete is called exclusively inside the isLastTestInClass block above, so testContextForClassCleanup is guaranteed non-null when line 279 is reached. Impact: for a test suite with C classes averaging K tests each, this saves C*(K-1) TestContextImplementation allocations — one dictionary copy + one CancellationTokenRegistration per non-last test per class. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR improves MSTest adapter execution performance by avoiding unnecessary TestContextImplementation allocations for class cleanup when the current test is not the last test in its class, aligning the cleanup path with earlier fast-path optimizations for init contexts.
Changes:
- Move class-cleanup
GetTestContext(...)allocation behind theisLastTestInClasscheck inUnitTestRunner.RunSingleTestAsync. - Add a unit test that compares
GetTestContextallocation behavior for a middle test vs. the last test in a class (where cleanup contexts are expected).
Show a summary per file
| File | Description |
|---|---|
| src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs | Defers class-cleanup TestContextImplementation allocation to the last test in each class and documents the non-null invariant used for assembly cleanup. |
| test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/UnitTestRunnerTests.cs | Adds a unit test intended to validate the deferred class-cleanup context allocation behavior. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Low
…up test Address PR review feedback: replace the loose BeGreaterThan assertion with an exact delta check (allocationsForThird == allocationsForSecond + 2). This catches the regression the test guards against, where a non-last test mistakenly allocates the deferred class-cleanup context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Evangelink
left a comment
There was a problem hiding this comment.
Note
🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.
| # | Dimension | Verdict |
|---|---|---|
| 15 | Code Structure & Simplification | 🟢 1 NIT |
| 16 | Naming & Conventions | 🟢 1 NIT |
✅ 20/22 dimensions clean.
- Code Structure —
testContextForClassCleanup!uses the null-forgiving!operator;DebugEx.Assertis the codebase's established idiom for invariant documentation (matches line 152 pattern) - Naming & Conventions — new test bypasses
CreateUnitTestRunner(...)helper without justification; structurally identical sibling test uses the helper correctly
Correctness ✅ — The invariant that testContextForClassCleanup is non-null whenever ShouldRunEndOfAssemblyCleanup is true holds under the production sequential execution model (TestExecutionManager.Runner.cs drives tests one at a time via a sequential foreach/await loop). Tracing the flow: ShouldRunEndOfAssemblyCleanup becomes true only after the dictionary is empty; the dictionary is emptied only via MarkClassComplete; MarkClassComplete is called exclusively inside the if (isLastTestInClass) block where testContextForClassCleanup is allocated. No code path exists where ShouldRunEndOfAssemblyCleanup == true with testContextForClassCleanup == null. The ! operator is safe.
Test ✅ — The allocation-delta assertion allocationsForThird.Should().Be(allocationsForSecond + 2) correctly guards the regression: if the optimization regresses (non-last test mistakenly allocates the class-cleanup context), allocationsForSecond increases by 1 while allocationsForThird stays the same, making the assertion fail. The exact-delta approach is intentional and well-explained in the comment.
Disposal ✅ — The finally block's (testContextForClassCleanup as IDisposable)?.Dispose() is a no-op when testContextForClassCleanup is null (non-last tests), which is correct.
Threading ✅ — testContextForClassCleanup is a method-local variable; there is no shared-state concern between concurrent invocations.
This comment has been minimized.
This comment has been minimized.
- Replace the null-forgiving operator on testContextForClassCleanup with a DebugEx.Assert, matching the codebase idiom for protocol invariants and keeping compiler nullability tracking intact. - Use the CreateUnitTestRunner helper in the new test instead of constructing UnitTestRunner directly, following the established pattern in sibling tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🧪 Test quality grade — PR #9461
This advisory comment was generated automatically. Grades are heuristic
|
Goal and Rationale
In
RunSingleTestAsync, aTestContextImplementationfor class cleanup was allocated unconditionally for every test — even whenisLastTestInClasswasfalseand the context was never used. TheTestContextImplementationconstructor copies thetestContextPropertiesdictionary and registers aCancellationTokenRegistration, so this is a non-trivial per-test allocation.The fix is the same pattern as PR #9433 (which deferred assembly-init and class-init contexts to the slow path): defer the class-cleanup context to only the last test in each class.
Approach
Move the
GetTestContext(...)call fortestContextForClassCleanupfrom an unconditional position beforeMarkTestCompleteinto theif (isLastTestInClass)block.Safety invariant: the assembly-cleanup branch reads
testContextForClassCleanup!.Context.CurrentTestOutcome. That branch only runs whenShouldRunEndOfAssemblyCleanupistrue, which requiresMarkClassCompleteto have been called — andMarkClassCompleteis called exclusively inside theisLastTestInClassblock. SotestContextForClassCleanupis guaranteed non-null when the assembly-cleanup branch is reached. A comment documents this reasoning.Performance Evidence
Allocation savings: For a test suite with C classes averaging K tests each:
C × Kclass-cleanupTestContextImplementationobjects per runC × 1(one per class, only for the last test)C × (K−1)allocations eliminated — each saving one dictionary copy + oneCancellationTokenRegistrationFor a typical suite with 50 test classes averaging 10 tests each:
TestContextImplementationallocations per runThis is the same pattern and magnitude as the assembly-init/class-init fast paths introduced in #9433.
Methodology: Code inspection + allocation counting via
TestablePlatformServiceProvider.GetTestContextCallCount(same mechanism used in the existing unit tests for #9433).Trade-offs
None. The context was allocated but never used; removing the allocation changes no observable behaviour.
Test Status
✅ Build/tests: local SDK not available in CI agent (pinned SDK 11.0.100-preview, not installed). Verification delegated to CI.
The new unit test
RunSingleTestShouldDeferClassCleanupContextAllocationToLastTestInClassverifies that:UnitTestOutcome.Passed.Reproducibility
Add this agentic workflows to your repo
To install this agentic workflow, run