[perf-improver] perf: skip allocation in CaptureLifecycleProperties when no user properties set#9478
Conversation
…erties set When AssemblyInitialize/ClassInitialize runs but sets no TestContext.Properties, CaptureLifecycleProperties previously allocated a new Dictionary + ReadOnlyDictionary wrapper unconditionally. This was wasted work in the common case. Changes: - CaptureLifecycleProperties now returns null when there are no non-label properties to snapshot, avoiding two heap allocations per lifecycle method execution. - The dictionary is lazily allocated on first non-label entry (single-pass iteration). - MergeProperties gains a Count==0 early-exit guard (defensive, since callers with the null-return change above never pass an empty dict from lifecycle paths). - PostAssemblyInitProperties/PostClassInitProperties are already nullable, so callers need no changes: MergeProperties(null) already returns immediately. Performance impact: - Saves 2 allocations per assembly/class that has [AssemblyInitialize]/[ClassInitialize] but does not set TestContext.Properties (the common case). - Saves a lock acquisition per test for the MergeProperties calls on both PostAssemblyInitProperties and PostClassInitProperties in RunSingleTestAsync. Tests: add CaptureLifecyclePropertiesShouldReturnNullWhenNoNonLabelPropertiesExist, MergePropertiesShouldIgnoreEmptyDictionary, and matching higher-level tests in TestAssemblyInfoTests and TestClassInfoTests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Optimizes MSTestAdapter.PlatformServices’ TestContextImplementation lifecycle property flow by avoiding allocations and locking when no user properties are set during AssemblyInitialize / ClassInitialize.
Changes:
- Make
CaptureLifecycleProperties()lazily allocate and returnnullwhen there are no non-label properties to snapshot. - Make
MergeProperties()return early fornullor empty dictionaries to avoid unnecessary locking. - Add/adjust unit tests to validate
nullsnapshot behavior and empty-merge short-circuit behavior.
Show a summary per file
| File | Description |
|---|---|
| src/Adapter/MSTestAdapter.PlatformServices/Services/TestContextImplementation.cs | Implements lazy snapshot allocation + nullable return, and adds empty-dictionary fast path in MergeProperties. |
| test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Services/TestContextImplementationTests.cs | Updates snapshot tests for nullable return and adds a test for merging an empty dictionary. |
| test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestClassInfoTests.cs | Adds coverage that PostClassInitProperties stays null when class init sets no properties. |
| test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestAssemblyInfoTests.cs | Adds coverage that PostAssemblyInitProperties stays null when assembly init sets no properties. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 1
- Review effort level: Low
This comment has been minimized.
This comment has been minimized.
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.
Review summary
| Dimension | Verdict |
|---|---|
| Algorithmic Correctness | ✅ LGTM |
| Threading & Concurrency | ✅ LGTM |
| Security & IPC Contract Safety | ✅ N/A |
| Public API & Binary Compatibility | ✅ LGTM (CaptureLifecycleProperties is internal; both assignment sites already declare the field as nullable) |
| Performance & Allocations | ✅ LGTM — core goal of the PR |
| Cross-TFM Compatibility | ✅ LGTM (is null or { Count: 0 } is C# 9 OR-pattern; IReadOnlyDictionary<K,V> exposes Count via IReadOnlyCollection<T>) |
| Resource & IDisposable Management | ✅ N/A |
| Defensive Coding | ✅ LGTM |
| Localization & Resources | ✅ N/A |
| Test Isolation | ✅ LGTM (each test sets its own AssemblyInitializeMethodBody; TestClassInfoTests resets all statics in its constructor) |
| Assertion Quality | ✅ LGTM (AwesomeAssertions .Should() style — correct for MSTestAdapter.PlatformServices.UnitTests) |
| Flakiness Patterns | ✅ None detected |
| Test Completeness | ✅ LGTM |
| Data-Driven Coverage | ✅ N/A |
| Code Structure & Simplification | ✅ LGTM |
| Naming & Conventions | ✅ LGTM |
| Documentation Accuracy | |
| Analyzer & Code Fix Quality | ✅ N/A |
| IPC Wire Compatibility | ✅ N/A |
| Build Infrastructure | ✅ N/A |
| Scope & PR Discipline | ✅ Single concern, clean delta |
| PowerShell Scripting Hygiene | ✅ N/A |
Correctness walkthrough
CaptureLifecycleProperties lazy-init path
- The
snapshot ??= new Dictionary<string, object?>(_properties.Count)fires only on the first non-label entry, inside the existinglock (_propertiesLock). Thread-safe ✅. - The capacity hint
_properties.Countis the total count including labels, so it may over-allocate by up to 2 slots when labels are present. This is the same trade-off as the original code (which also used_properties.Countbefore the loop), and the#pragma disable IDE0028comment correctly documents the intent. ✅ - If no non-label entries are found,
snapshotstaysnulland the method returnsnull— zero allocations, as advertised. ✅
MergeProperties empty-dict short-circuit
IReadOnlyDictionary<K,V> extends IReadOnlyCollection<KeyValuePair<K,V>>, which defines int Count { get; }, so the { Count: 0 } property pattern resolves correctly. Merging an empty dictionary had no observable side effect before (just a lock-acquire and a no-op loop), so short-circuiting is safe. ✅
Callers of CaptureLifecycleProperties
Both callers assign to nullable-typed properties:
TestAssemblyInfo.PostAssemblyInitProperties:IReadOnlyDictionary<string, object?>?✅TestClassInfo.PostClassInitProperties:IReadOnlyDictionary<string, object?>?✅
MergeProperties(null) was already a no-op, so no call-site changes are required. ✅
Test discovery
All four new test files extend TestContainer (using TestFramework.ForTestingMSTest; public class Foo : TestContainer). The TestFramework.ForTestingMSTest framework discovers any public parameterless instance method as a test — no [TestMethod] attribute is needed. The new methods are all public and are correctly discoverable. ✅
One inline NIT on a misleading comment in CaptureLifecyclePropertiesShouldReturnNullWhenNoNonLabelPropertiesExist; no blocking issues found.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
🧪 Test quality grade — PR #94787 tests graded (4 new, 3 modified) across 3 files. All receive A (90–100): clear AAA structure, fluent AwesomeAssertions with equality, collection-membership, negative, and explicit null-expectation assertions, and tightly focused single-behavior coverage. The three modified tests add
This advisory comment was generated automatically. Grades are heuristic
|
Goal and rationale
CaptureLifecycleProperties()is called once per[AssemblyInitialize]/[ClassInitialize]execution to snapshot anyTestContext.Propertiesentries set during init, so they can be propagated to each test's context. In the common case — where neither init method sets any properties — the method unconditionally allocated two objects:new Dictionary<string, object?>(capacity)— the snapshot buffernew ReadOnlyDictionary<string, object?>(snapshot)— the immutable wrapperThose two objects are then stored in
PostAssemblyInitProperties/PostClassInitPropertiesand passed to every test viaMergeProperties.MergePropertiesalready returned early fornull, but not for an empty dictionary, so it also acquired a lock per call before doing nothing.Approach
CaptureLifecycleProperties: change to lazy allocation. TheDictionaryis allocated only on the first non-label entry. If the loop finds nothing to snapshot, the method returnsnull(return type changed fromIReadOnlyDictionary<string,object?>toIReadOnlyDictionary<string,object?>?). This is a single-pass implementation — no extra iteration.MergeProperties: extend the null guard tonull or { Count: 0 }, so an empty dict is also short-circuited cheaply (defensive, and matches the new null-return convention).PostAssemblyInitPropertiesandPostClassInitPropertiesare already declared as nullable;MergeProperties(null)already short-circuits.Performance evidence
Every test class that has
[AssemblyInitialize]or[ClassInitialize](but doesn't write toTestContext.Propertiesin it) benefits:CaptureLifecycleProperties(empty)MergeProperties(emptySnapshot)per testMergeProperties(null)per testFor a test suite with an
[AssemblyInitialize]/[ClassInitialize]that sets no properties:PostAssemblyInitProperties, one forPostClassInitPropertiesmerge inRunSingleTestAsync)In a 1000-test suite, that's ~2000 lock acquisitions avoided.
Trade-offs
None significant. The change is backward-compatible: callers that store the snapshot in nullable fields already handle
null;MergeProperties(null)was already a no-op.Reproducibility
Test status
No SDK is available in this CI environment, so local build/test could not be run. The change is confined to the
MSTestAdapter.PlatformServicesproject and its unit tests; all callers already handlenullsnapshots.Add this agentic workflows to your repo
To install this agentic workflow, run