Skip to content

[fix] Fix TestCaseStart/Stop events dropped for data-driven tests with shared TestCase.Id#16033

Draft
nohwnd wants to merge 4 commits into
mainfrom
fix/issue-4997-f56862f62f7366d5
Draft

[fix] Fix TestCaseStart/Stop events dropped for data-driven tests with shared TestCase.Id#16033
nohwnd wants to merge 4 commits into
mainfrom
fix/issue-4997-f56862f62f7366d5

Conversation

@nohwnd
Copy link
Copy Markdown
Member

@nohwnd nohwnd commented May 18, 2026

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 sending TestCaseStart events when a test with the same TestCase.Id was already "in progress":

// Do not send TestCaseStart for a test in progress
if (!_testCaseInProgressMap.Contains(testCase.Id))
{
    _testCaseInProgressMap.Add(testCase.Id);
    _testCaseEventsHandler.SendTestCaseStart(testCase);
}

For data-driven tests where all rows share the same TestCase.Id (because their fully-qualified names are identical), this guard silently drops TestCaseStart events for row executions that overlap with the parent test's in-progress window. This causes data collectors to receive unmatched TestCaseEnd events and miss test execution data.

Fix

  • Changed _testCaseInProgressMap from HashSet<Guid> to Dictionary<Guid, int> (reference counting)
  • Removed the deduplication guard in RecordStart — every call now forwards a TestCaseStart event
  • SendTestCaseEnd uses the reference count to ensure each TestCaseStart has exactly one matching TestCaseEnd (i.e., TestCaseEnd is still not sent if there was no preceding TestCaseStart)

Tests

  • Updated RecordStartAndRecordEndShouldForwardAllTestCaseStartAndEndEvents to reflect new behavior (2 Starts and 2 Ends for 2 Start + 2 End calls)
  • Added RecordStartAndRecordEndShouldSendEventsForNestedDataDrivenTestsWithSameId which 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 forwarded

All 648 CrossPlatEngine unit tests pass.

🔍 Triaged by Issue Repro Triage & Auto-Fix 🔍

…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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 _testCaseInProgressMap from HashSet<Guid> to Dictionary<Guid, int> to reference-count in-progress starts per TestCase.Id.
  • Updated RecordStart to always forward SendTestCaseStart (no de-duplication by ID).
  • Updated unit tests to expect multiple Start/End forwards 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.

Comment on lines +132 to +147
// 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]
Copy link
Copy Markdown
Member Author

@nohwnd nohwnd left a comment

Choose a reason for hiding this comment

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

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.
  • SendTestCaseEnd correctly uses TryGetValue + count > 0 guard, preserving the invariant that every TestCaseEnd is preceded by a TestCaseStart.
  • Thread safety: both RecordStart and SendTestCaseEnd are 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)
{
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.

[Parallel Execution & Scheduling Safety] — Potential count under-run in nested + RecordResult scenario

SendTestCaseEnd is called from two places:

  1. RecordEnd (explicit notification from the adapter)
  2. RecordResult — as a safety net "in case RecordEnd was not called" (line ~99)

With the old HashSet semantics the safety net was harmless: once RecordEnd removed the ID the subsequent RecordResultSendTestCaseEnd 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).

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.

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 _testCaseEndCalledSet before sending the End event. Removes it when the count reaches zero (test fully complete).
  • RecordResult safety-net: now only fires if count > 0 and the ID is not in _testCaseEndCalledSet. Once any RecordEnd is called for an ID, subsequent RecordResult calls 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>
@nohwnd
Copy link
Copy Markdown
Member Author

nohwnd commented May 18, 2026

Commit pushed: 9a7b32d

🔧 Iterated by PR Iteration Agent 🔧

Copy link
Copy Markdown
Member Author

@nohwnd nohwnd left a comment

Choose a reason for hiding this comment

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

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

  • _testCaseEndCalledSet is cleaned up atomically with _testCaseInProgressMap when 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): ✓

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))
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.

[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.

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.

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>
@nohwnd
Copy link
Copy Markdown
Member Author

nohwnd commented May 18, 2026

Commit pushed: 83d3cc0

🔧 Iterated by PR Iteration Agent 🔧

Copilot AI review requested due to automatic review settings May 18, 2026 02:16
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +96 to +101
// 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);
Comment on lines +31 to +46
/// <summary>
/// Tracks test case IDs for which <see cref="RecordEnd"/> has been called at least once
/// while the entry is still in progress (count &gt; 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;
@nohwnd
Copy link
Copy Markdown
Member Author

nohwnd commented May 18, 2026

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.

🔧 Iterated by PR Iteration Agent 🔧

🔧 Iterated by PR Iteration Agent 🔧

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The InProcDataCollector's TestCaseStart/TestCaseStop methods are not always called.

2 participants