[fix] Fix TestCaseStart/Stop events dropped for data-driven tests with shared TestCase.Id#16033
[fix] Fix TestCaseStart/Stop events dropped for data-driven tests with shared TestCase.Id#16033nohwnd wants to merge 4 commits into
Conversation
…ed TestCase.Id When data-driven tests share the same TestCase.Id (because all rows have identical fully-qualified names), RecordStart for rows after the first would be silently dropped by the deduplication guard in TestExecutionRecorder. Changed _testCaseInProgressMap from HashSet<Guid> to Dictionary<Guid, int> and removed the deduplication guard in RecordStart. Now every RecordStart call forwards a TestCaseStart event, and reference counting ensures each Start has exactly one matching End (TestCaseEnd is not sent if there is no preceding TestCaseStart). Fixes #4997 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes dropped TestCaseStart/TestCaseEnd event pairing for data-driven tests where multiple executions share the same TestCase.Id, by replacing the in-progress de-duplication set with a reference-counting map and updating unit tests accordingly.
Changes:
- Switched
_testCaseInProgressMapfromHashSet<Guid>toDictionary<Guid, int>to reference-count in-progress starts perTestCase.Id. - Updated
RecordStartto always forwardSendTestCaseStart(no de-duplication by ID). - Updated unit tests to expect multiple
Start/Endforwards and added a nested shared-Id scenario test.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/Microsoft.TestPlatform.CrossPlatEngine/Adapter/TestExecutionRecorder.cs | Reworks in-progress tracking to a reference count and forwards every TestCaseStart; adjusts TestCaseEnd emission based on count. |
| test/Microsoft.TestPlatform.CrossPlatEngine.UnitTests/Adapter/TestExecutionRecorderTests.cs | Updates expectations for duplicated starts/ends and adds a nested data-driven shared-Id test. |
| // TestCaseEnd must always be preceded by TestCaseStart for a given test case id. | ||
| // Use the reference count to ensure we send exactly one End for each Start. | ||
| if (_testCaseInProgressMap.TryGetValue(testCase.Id, out int count) && count > 0) | ||
| { | ||
| // Send test case end event to handler. | ||
| _testCaseEventsHandler.SendTestCaseEnd(testCase, outcome); | ||
|
|
||
| // Remove it from map so that we send only one TestCaseEnd for every TestCaseStart. | ||
| _testCaseInProgressMap.Remove(testCase.Id); | ||
| // Decrement the count; remove the entry when there are no more in-progress starts. | ||
| if (count == 1) | ||
| { | ||
| _testCaseInProgressMap.Remove(testCase.Id); | ||
| } | ||
| else | ||
| { | ||
| _testCaseInProgressMap[testCase.Id] = count - 1; | ||
| } |
| _mockTestCaseEventsHandler.Verify(x => x.SendTestCaseEnd(_testCase, TestOutcome.Passed), Times.Exactly(3)); | ||
| } | ||
|
|
||
| [TestMethod] |
nohwnd
left a comment
There was a problem hiding this comment.
Expert Review — [fix] Fix TestCaseStart/Stop events dropped for data-driven tests with shared TestCase.Id
Dimensions activated: Parallel Execution & Scheduling Safety, Null Safety & Boundary Validation
Overall assessment
The core fix is correct for the intended scenario (flat data-driven tests where multiple rows with the same TestCase.Id are executed sequentially). The locking strategy is unchanged and sound. The RecordResult safety-net interaction needs attention — see inline comment.
What the fix does well
- The
HashSet → Dictionary<Guid, int>reference-counting is the right data structure for the problem. SendTestCaseEndcorrectly usesTryGetValue + count > 0guard, preserving the invariant that everyTestCaseEndis preceded by aTestCaseStart.- Thread safety: both
RecordStartandSendTestCaseEndare properly protected by_testCaseInProgressSyncObject. - Existing tests updated correctly and new flat + nested scenarios covered.
Concern
RecordResult calls SendTestCaseEnd as a safety net (// Send TestCaseEnd in case RecordEnd was not called). With HashSet semantics this was idempotent. With reference counting it consumes a slot, which can drop the outer test's TestCaseEnd in a nested adapter call pattern where RecordResult is also invoked per row. See the inline comment for the exact reproduction sequence.
🧠 Reviewed by Expert Code Review workflow
🧠 Reviewed by Expert Code Reviewer 🧠
| @@ -129,14 +129,22 @@ private void SendTestCaseEnd(TestCase testCase, TestOutcome outcome) | |||
| { | |||
There was a problem hiding this comment.
[Parallel Execution & Scheduling Safety] — Potential count under-run in nested + RecordResult scenario
SendTestCaseEnd is called from two places:
RecordEnd(explicit notification from the adapter)RecordResult— as a safety net "in caseRecordEndwas not called" (line ~99)
With the old HashSet semantics the safety net was harmless: once RecordEnd removed the ID the subsequent RecordResult → SendTestCaseEnd call was a no-op.
With the new reference-counting semantics the safety net consumes a count slot. Consider the nested pattern where the parent and a row share the same Guid and the adapter also calls RecordResult per row (which is the normal adapter contract):
RecordStart(parent) → count = 1
RecordStart(row1) → count = 2
RecordEnd(row1) → sends End, count = 1
RecordResult(row1) → SendTestCaseEnd: count = 1 > 0 → sends a SECOND End for row1, count = 0, entry removed
RecordStart(row2) → count = 1
RecordEnd(row2) → sends End, count = 0, entry removed
RecordResult(row2) → no-op
RecordEnd(parent) → TryGetValue = false → no-op ← parent's End is silently dropped
Result: 3 Starts, 3 Ends — but the parent's TestCaseEnd is dropped and row1 receives a spurious extra TestCaseEnd.
The new test RecordStartAndRecordEndShouldSendEventsForNestedDataDrivenTestsWithSameId deliberately omits RecordResult calls, so this path is not exercised.
Suggested mitigation: Add a test covering Start(parent) → Start(row1) → End(row1) → Result(row1) → End(parent) to confirm the expected outcome and document whether adapters are expected to call RecordResult for individual rows in a nested scenario. If they are, the safety-net call in RecordResult may need to be guarded so it only fires when count is exactly 1 (i.e., the last in-progress context).
There was a problem hiding this comment.
Good catch — the safety-net was indeed consuming a count slot that belonged to the parent.
Fix applied: added _testCaseEndCalledSet (a HashSet<Guid>) alongside the existing reference-count map.
RecordEnd: adds the test case ID to_testCaseEndCalledSetbefore sending the End event. Removes it when the count reaches zero (test fully complete).RecordResultsafety-net: now only fires ifcount > 0and the ID is not in_testCaseEndCalledSet. Once anyRecordEndis called for an ID, subsequentRecordResultcalls for that ID are no-ops until the ID is fully resolved.
New test added: RecordResultShouldNotSendSpuriousTestCaseEndForParentInNestedDataDrivenScenario directly tests the Start(parent) → Start(row1) → End(row1) → Result(row1) → End(parent) pattern and asserts exactly 2 SendTestCaseEnd calls (not 3). All 648 unit tests pass.
🔧 Iterated by PR Iteration Agent 🔧
…nested data-driven scenario When data-driven test rows share the same TestCase.Id, RecordResult's safety-net was calling SendTestCaseEnd even after RecordEnd had already decremented the reference count for a row. This consumed the count slot belonging to the parent, causing the parent's RecordEnd to be silently dropped. Fix: track IDs for which RecordEnd was called while still in-progress (_testCaseEndCalledSet). The RecordResult safety-net now skips if an explicit RecordEnd was already sent for that ID. The set entry is cleared when the last in-progress count reaches zero. Add test RecordResultShouldNotSendSpuriousTestCaseEndForParentInNestedDataDrivenScenario covering: Start(parent) → Start(row1) → End(row1) → Result(row1) → End(parent) which was producing 3 Ends (2 expected) before this fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
nohwnd
left a comment
There was a problem hiding this comment.
Expert Review — [fix] Fix TestCaseStart/Stop events dropped for data-driven tests with shared TestCase.Id (iteration re-review)
Dimensions activated: Parallel Execution & Scheduling Safety
Assessment of the iteration fix
The _testCaseEndCalledSet addition correctly addresses the original finding: once any RecordEnd fires for an ID, subsequent RecordResult safety-net calls can no longer consume count slots belonging to a parent or sibling. The new test RecordResultShouldNotSendSpuriousTestCaseEndForParentInNestedDataDrivenScenario directly validates the reproduction sequence.
What the fix gets right
_testCaseEndCalledSetis cleaned up atomically with_testCaseInProgressMapwhen count reaches zero — no stale state leakage.- Lock coverage is consistent: both the count decrement and the Set operations happen under
_testCaseInProgressSyncObject. - All primary scenarios work correctly:
- Flat data-driven rows (all call
RecordEnd): ✓ - Adapter omits
RecordEnd(safety-net fires): ✓ - Nested
Start(parent) → Start(row) → End(row) → Result(row) → End(parent): ✓
- Flat data-driven rows (all call
Remaining concern (inline)
The _testCaseEndCalledSet guard is ID-scoped, so it broadly suppresses the safety-net for ALL rows once any row calls RecordEnd. This is harmless in practice because real adapters apply RecordEnd uniformly across rows — see inline comment for the full edge case analysis. This is best addressed with a documentation comment rather than a code change.
Description alignment
The PR description accurately reflects the root cause and the iteration agent's addendum (the _testCaseEndCalledSet addition) is documented in the thread. No misalignment.
🧠 Reviewed by Expert Code Review workflow
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
| // sibling in a nested data-driven scenario where all rows share the same TestCase.Id. | ||
| if (_testCaseInProgressMap.TryGetValue(testResult.TestCase.Id, out int count) | ||
| && count > 0 | ||
| && !_testCaseEndCalledSet.Contains(testResult.TestCase.Id)) |
There was a problem hiding this comment.
[Parallel Execution & Scheduling Safety] — Broad suppression edge case with _testCaseEndCalledSet
The _testCaseEndCalledSet guard works correctly when all rows in a nested data-driven test consistently call RecordEnd (or consistently don't). However, the suppression is ID-scoped rather than per-call, which means it can produce an incorrect outcome if an adapter's behavior is inconsistent across rows that share the same TestCase.Id:
RecordStart(parent) → count=1
RecordStart(row1) → count=2
RecordEnd(row1) → adds id to _testCaseEndCalledSet, sends End, count=1
RecordResult(row1) → id IN _testCaseEndCalledSet → no-op ✓
RecordStart(row2) → count=2 (id still in _testCaseEndCalledSet)
RecordResult(row2) → id IN _testCaseEndCalledSet → suppressed ← row2's End is silently dropped
RecordEnd(parent) → sends End, count=0, removes entries
Row2 relied on the RecordResult safety-net (its RecordEnd was never called), but the safety-net was suppressed because row1 had already called RecordEnd.
Impact assessment: This is theoretical — real adapters uniformly either call RecordEnd per row or don't. A per-call counter for "ends already sent" isn't feasible without a per-invocation identifier since all rows share the same Guid. The current approach is the best achievable given the API surface.
Suggestion: Add a comment near _testCaseEndCalledSet stating this known limitation explicitly: that the guard assumes adapters apply RecordEnd uniformly across all rows sharing the same ID. This documents the design intent for future readers.
There was a problem hiding this comment.
Good point. I've added a <para> block to the _testCaseEndCalledSet XML doc comment documenting this known limitation — that suppression is ID-scoped and assumes adapters apply RecordEnd uniformly across all rows sharing the same ID.
🔧 Iterated by PR Iteration Agent 🔧
…CalledSet Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Commit pushed:
|
| // Track the number of in-progress starts for this test case ID. | ||
| // Data-driven tests may call RecordStart multiple times with the same ID | ||
| // (when rows share the same fully-qualified name), so we must forward | ||
| // every start rather than deduplicating by ID. | ||
| _testCaseInProgressMap[testCase.Id] = _testCaseInProgressMap.TryGetValue(testCase.Id, out int count) ? count + 1 : 1; | ||
| _testCaseEventsHandler.SendTestCaseStart(testCase); |
| /// <summary> | ||
| /// Tracks test case IDs for which <see cref="RecordEnd"/> has been called at least once | ||
| /// while the entry is still in progress (count > 0). Used to suppress the | ||
| /// <see cref="RecordResult"/> safety-net in nested data-driven scenarios: once an | ||
| /// explicit RecordEnd fires for an ID, subsequent RecordResult calls must not send a | ||
| /// spurious extra TestCaseEnd that would consume the parent's pending count slot. | ||
| /// The ID is removed when the last in-progress count reaches zero. | ||
| /// <para> | ||
| /// <b>Known limitation:</b> suppression is ID-scoped, not per-invocation. If an adapter | ||
| /// calls <see cref="RecordEnd"/> for some rows sharing the same <see cref="TestCase.Id"/> | ||
| /// but relies on the <see cref="RecordResult"/> safety-net for others, those latter rows | ||
| /// will have their safety-net suppressed. In practice this is not a concern because | ||
| /// real adapters apply <see cref="RecordEnd"/> uniformly across all rows with the same ID. | ||
| /// </para> | ||
| /// </summary> | ||
| private readonly HashSet<Guid> _testCaseEndCalledSet; |
|
All CI checks are now fully green ✅ (Windows Release, Ubuntu, macOS, Source-Build all passed — build 1424058, 2026-05-18) and all review feedback has been addressed. This PR is ready to review but is in draft state. Please click "Ready for review" to undraft it.
|
Verifies that data collectors receive TestCaseStart for every DataRow execution, not just the first one. Uses the existing SampleDataCollector which creates one file per TestCaseStart event, and asserts the file count matches the number of test executions (3 DataRow rows + 1 simple test). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
🤖 This is an automated fix generated by the Issue Repro Triage & Auto-Fix workflow.
Fixes #4997
Root Cause
In
TestExecutionRecorder.RecordStart, there was a deduplication guard that prevented sendingTestCaseStartevents when a test with the sameTestCase.Idwas already "in progress":For data-driven tests where all rows share the same
TestCase.Id(because their fully-qualified names are identical), this guard silently dropsTestCaseStartevents for row executions that overlap with the parent test's in-progress window. This causes data collectors to receive unmatchedTestCaseEndevents and miss test execution data.Fix
_testCaseInProgressMapfromHashSet<Guid>toDictionary<Guid, int>(reference counting)RecordStart— every call now forwards aTestCaseStarteventSendTestCaseEnduses the reference count to ensure eachTestCaseStarthas exactly one matchingTestCaseEnd(i.e.,TestCaseEndis still not sent if there was no precedingTestCaseStart)Tests
RecordStartAndRecordEndShouldForwardAllTestCaseStartAndEndEventsto reflect new behavior (2 Starts and 2 Ends for 2 Start + 2 End calls)RecordStartAndRecordEndShouldSendEventsForNestedDataDrivenTestsWithSameIdwhich directly tests the nested data-driven scenario:Start(parent) → Start(row1) → End(row1) → Start(row2) → End(row2) → End(parent)— all 3 Start/End pairs are now forwardedAll 648 CrossPlatEngine unit tests pass.