Migrate xUnit v2 tests to xUnit v3 on Microsoft Testing Platform - #5762
Migrate xUnit v2 tests to xUnit v3 on Microsoft Testing Platform#5762Mikael Weaver (mikaelweave) wants to merge 15 commits into
Conversation
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… Retry sites - Swap all 28 test csprojs to xunit.v3 (drop Microsoft.NET.Test.Sdk, xunit.runner.visualstudio, Xunit.SkippableFact) - 438 SkippableFact sites -> Fact/Theory + Assert.SkipWhen/SkipUnless; 32 reason-less sites use shared SkipReasons.Unspecified - 26 RetryFact/RetryTheory -> Fact/Theory; 3 RetryAsync helper sites unwrapped Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…TestFramework attrs, and port CI filters to xunit.v3 query grammar Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rcing xunit.v3.core from framework, suppress xUnit1051 in test ruleset Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tform usings; suppress xUnit1031 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…s.props Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ility The xunit.v3 meta-package binds xunit.v3.mtp-v1 (MTP 1.9.1), which is incompatible with the MTP 2.0.2 extension stack (Retry/TrxReport) that the CI legs invoke via --filter-query/--retry-failed-tests/--report-trx. Reference xunit.v3.mtp-v2 directly (pulls MTP 2.0.2 transitively) and set UseMicrosoftTestingPlatformRunner=true so the standalone test exe is an MTP host, restoring the entrypoint behavior the deleted YTest.MTP.XUnit2 shim provided. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87a059be-5733-43cf-8cf4-7ab5cf57f26f
…t, drop dead xunit.v3 pin - tools/ABTestRunner/Invoke-ABTest.ps1: emit MTP --filter-query /[(...)] instead of VSTest --filter (the latter exits 5 'Unknown option' on an xunit.v3 MTP host); each predicate keeps its original polarity. - tools/MultiInstanceRunner/README.md: same --filter -> -- --filter-query conversion; net9.0 -> net10.0. - FixtureArgumentSetTestMethod.cs: correct the XML-doc claim that base serialization round-trips merged traits (it does not); explain why that is safe and name the one affected consumer. - Directory.Packages.props: remove the now-unreferenced xunit.v3 meta-package pin (all 28 projects use xunit.v3.mtp-v2; 0 consumers remain). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87a059be-5733-43cf-8cf4-7ab5cf57f26f
Fix A/B: rebuild fault cases from base XunitTestMethod and attach a conservative union of raw class/method flag traits, so the reporter no longer re-enters uniqueID/traits reflection (which re-threw out of FindTestsForType, silently dropping the class, exit 0) and so fault cases carry traits and are selected under positive filter-query legs (E2E/export), not only negative ones. Fix C: use IsDefined at the attribute-presence check (no instantiation) and retrieve each method's attribute inside its per-method try, so a throwing method attribute isolates as a loud error case instead of taking down the whole class or vanishing untraited. Fix D: correct the ComputeVariants comment to describe per-dimension REPLACE (method flag replaces class flag for that dimension), which matches v2 and is A/B-verified; drop the false 'never adds variants beyond the class cross product' claim. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87a059be-5733-43cf-8cf4-7ab5cf57f26f
When a method's FixtureArgumentSets attribute constructor itself throws, its flag values are unknowable, so CollectRawFlags catches and the fault case is emitted untraited - invisible to positive trait filters but still visible unfiltered and under negative legs. Comment the swallow so it is not "simplified" away by a future reader. Comment-only; no behaviour change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87a059be-5733-43cf-8cf4-7ab5cf57f26f
…itless store-named classes) The two negative integration legs used --filter-query "/[(DataStore!=X)]". In xunit.v3 a trait "!=" means "differs OR absent", so store-named but DataStore-traitless classes (e.g. SqlServerImporterIntegrationTests) that v2 excluded by FullyQualifiedName!~ survived and were double-scheduled onto the opposite store's leg (+43 cosmos, +7 sql). Those classes hard-fail at fixture construction without their backend, so this would have reddened CI post-merge. A query-filter equivalent is impossible in runner 3.2.2 (any explicit path segment zeroes the trait predicate; multiple --filter-query args OR). Switch the two negative legs to simple filters, which AND across filter types: --filter-not-trait "DataStore=X" --filter-not-class "*X*" Verified on the R4 Integration assembly: found 291 (cosmos) / 439 (sql), exact v2 membership (missing 0 / added 0). Both clauses load-bearing (not-class alone 596; not-trait alone 334/446). Positive E2E/export legs are unchanged. Comment added at each site to prevent harmonising back. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87a059be-5733-43cf-8cf4-7ab5cf57f26f
Removing the custom test-retry helper left three immediately-invoked
`await ((Func<Task>)(async () => { ... }))()` wrappers that allocate a
delegate solely to invoke it in place -- dead scaffolding with no effect
on control flow. Inline the three bodies (2 in BasicSearchTests, 1 in the
Skip'd QueueClientTests) so net complexity keeps going down. Body-only
change: test names and discovery membership are unchanged on all four CI
legs. Whole-test retry remains covered by the pipeline's
`--retry-failed-tests 3`.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 87a059be-5733-43cf-8cf4-7ab5cf57f26f
| JobInfo job = await _queueClient.DequeueAsync(queueType, "test-worker", 1, CancellationToken.None); | ||
| ValidateJobInfoState(job); | ||
|
|
||
| var cancel = new CancellationTokenSource(); |
| catch (Exception ex) | ||
| { | ||
| // Per-method isolation: one method's failure must not drop the rest of the class. Re-derive this | ||
| // method's variants (best effort) so the fault carries their traits and a trait-filtered CI leg still | ||
| // selects it. | ||
| SingleFlag[][] variants = TryComputeVariants(testClass, method, classAttribute, methodAttribute, classOpenSets, classClosedSets); | ||
| succeeded = await ReportFault(testClass, new[] { method }, variants, ex, callback); | ||
| } |
| foreach (SingleFlag[] variant in variants) | ||
| { | ||
| if (!await EmitFaultCase(testClass, method, variant, variant, ex, callback)) | ||
| { | ||
| return false; | ||
| } | ||
| } |
| catch | ||
| { | ||
| return Array.Empty<MethodInfo>(); | ||
| } |
| catch | ||
| { | ||
| return Array.Empty<SingleFlag[]>(); | ||
| } |
| /// <returns>The encoded flag strings.</returns> | ||
| public static string[] Encode(SingleFlag[] flags) => | ||
| (flags ?? Array.Empty<SingleFlag>()) | ||
| .Select(f => f.EnumValue.GetType().AssemblyQualifiedName + "=" + f.EnumValue.ToString()) |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #5762 +/- ##
=======================================
Coverage ? 78.65%
=======================================
Files ? 1020
Lines ? 37763
Branches ? 5749
=======================================
Hits ? 29703
Misses ? 6650
Partials ? 1410 🚀 New features to boost your workflow:
|
Status: full pipeline is green — one owner action leftADO build 51080 completed Every stage this change actually touches has now executed for real — all five E2E legs on both data The one thing I cannot do for you:
|
Restore bounded collection parallelism, simplify fixture context handling, and dispose test-owned service providers without including the separate xUnit regression projects. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e0e0f572-5e9c-4713-8034-59ffcfba63cd
Leave collection scheduling to xUnit's default assembly-runner fan-out. The custom runner still injects fixture arguments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e0e0f572-5e9c-4713-8034-59ffcfba63cd
Remove the dummy NotTest and xunit.v3.assert reference from the helper library. Revert agent model pins and the superpowers gitignore tweak from this migration PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e0e0f572-5e9c-4713-8034-59ffcfba63cd
Migrate the test suite from xunit v2 to xunit.v3 (3.2.2)
Summary
This moves the test suite from
xunitv2 to nativexunit.v33.2.2 and rewrites the customtest framework in
src/Microsoft.Health.Extensions.Xunitfrom scratch. The governing requirementis that net complexity goes down: the rewrite deletes custom infrastructure that xunit.v3 now
provides natively (assembly fixtures, retry) rather than porting it. The testing process is
functionally identical — same FHIR versions (STU3/R4/R4B/R5), same data stores (CosmosDb/SqlServer),
same formats (Json/Xml), same CI legs selecting the same tests. The bulk of the diff is mechanical
find/replace; the framework is the only part that needs careful review.
Framing: this is NOT a test-runner migration
mainalready runs on Microsoft Testing Platform (MTP) — via theYTest.MTP.XUnit2v2→MTPshim (version 1.0.3;
global.jsonsets"test": { "runner": "Microsoft.Testing.Platform" }). ThisPR swaps that shim for
xunit.v3, which speaks MTP directly. The runner does not change; only theframework underneath it does. Reviewers expecting a runner cutover will not find one.
One package swap is part of this and is named here so it does not look unexplained: all 28 test
projects move from the
xunit.v3meta-package toxunit.v3.mtp-v2, withUseMicrosoftTestingPlatformRunner=true. The CI e2e/export legs pass MTP options(
--filter-query,--retry-failed-tests,--report-trx) directly to the test executable;stock
xunit.v3defaults that executable to the native console runner, which rejects those options(exit 3).
mainonly worked because the now-deletedYTest.MTP.XUnit2shim made the executable anMTP host.
xunit.v3.mtp-v2resolves to MTP 2.0.2, matching the repo's existing Retry/TRXextension pins, whereas the plain
xunit.v3meta hard-binds MTP 1.9.1 and throwsMissingMethodExceptionat runtime. This is a host-package swap under the same runner, not a runnermigration.
How to review this
The diff falls into four buckets. Only the first needs real attention.
Genuinely novel code — the ~1,045-line framework rewrite. THIS IS THE ONLY PART THAT NEEDS
CAREFUL REVIEW. It lives entirely in
src/Microsoft.Health.Extensions.Xunit(13.csfiles):CustomXunitTestFrameworkDiscoverer.cs(417) — the most important file: flag expansion,per-dimension replacement, variant construction, the guarded v2-insert display name, trait merge,
and the fail-loud discovery-fault path.
CustomXunitTestFrameworkExecutor.cs(37) plus the runner cascade(
CustomXunitTestAssemblyRunner,…AssemblyRunnerContext,CustomXunitTestCollectionRunner,CustomXunitTestClassRunner) — framework registration and per-variant fixture-argumentinjection. Only
CustomXunitTestClassRunner(86) carries real logic; the rest is pass-throughv3 forces you to declare (see Complexity).
FixtureArgumentSetTestClass.cs/FixtureArgumentSetTestMethod.cs/FlagCodec.cs— theper-variant class/method types and the shared flag codec, including the
IXunitSerializablere-implementation (see Risks).
FixtureArgumentSetsAttribute.cs,SingleFlag.cs— the fixture-argument-set attribute and theflag helper.
Read these files closely; the design and its five reflection points are described under Risks
below.
Pure mechanical find/replace — safe to skim. These are pattern-verifiable in aggregate:
IAsyncLifetimedeclaration edits across 23 files / 22 types (Task→ValueTask).All 15 call sites are plain
await; none needs.AsTask().[RetryFact]/[RetryTheory]call sites converted to plain[Fact]/[Theory](thetests are otherwise unchanged — not rewritten to polling loops): 26 sites across 10 files
(14
[RetryFact]+ 12[RetryTheory]).[assembly: TestFramework]andusing Xunit.Abstractions;housekeeping.Deletions — see the deletion list.
Build / CI config —
Directory.Packages.props,Directory.Build.props, per-project.csprojpackage swaps, and the CI filter changes (below).SkippableFact rewrite — 438 sites → 4 mechanical mappings (no shim)
[SkippableFact][Fact][SkippableTheory][Theory]Skip.If(cond, reason)Assert.SkipWhen(cond, reason)Skip.IfNot(cond, reason)Assert.SkipUnless(cond, reason)The 32 reason-less
Skip.IfNotsites all take one shared neutral constant rather thanhand-written per-site messages, keeping the change verifiable by pattern.
Assert.SkipWhen/SkipUnlessrequire a reason argument, so any missed site is a compile error — a desirable loudfailure.
Deletions (and why they are safe)
Custom retry implementation. Deletes the 5 retry files (
RetryFactAttribute,RetryFactDiscoverer,RetryTestCase,RetryTheoryAttribute,RetryTheoryDiscoverer, 535lines) plus
IClassFixtureExtensions.cs(141 lines, a retry helper with zero other references) —676 lines total. All
[RetryFact]/[RetryTheory]call sites become plain[Fact]/[Theory].There are 26 such call sites across 10 files (14
[RetryFact]+ 12[RetryTheory]; heaviestis
ConditionalDeleteTestsat 11 — 6 Fact + 5 Theory), and the same grep on the migrated headreturns 0 (exit 1) — the removal is total. A reviewer's own
git grepshows 30 hits across11 files; the extra 4 are string literals in
DiagnosticMessage($"[RetryFact] …")insideRetryTestCase.cs— the retry framework's own source, which this PR deletes. So 26 is the truecall-site count and 30 is the grep artefact.
Safe because Microsoft Testing Platform's
--retry-failed-testsalready covers this at theplatform level and is already configured on every CI leg on
main. Deleting custom retryintroduces no new masking policy. The tests themselves are not rewritten.
Granularity caveat — the substitution is not like-for-like at every site, and here is where it
isn't. Two different retry mechanisms are being deleted, and only one of them maps cleanly onto
--retry-failed-tests:[RetryFact]/[RetryTheory]retried the whole test method.--retry-failed-tests 3(4attempts) also retries the whole test method. Same granularity, same unit of work — a clean
substitution.
IClassFixtureExtensions.RetryAsyncwas an inner, per-operation retry inside a test body,and is a genuine granularity change. Its actual behaviour, read off the deleted source rather than
assumed:
MaxNumberOfAttempts = 3(up to 4 executions), no backoff at all, and it caughtonly
SocketExceptionin the connection-reset family — everything else was rethrown on thefirst failure. All three call sites used the single-argument overload, so the optional
"additional retriable exceptions" set was always null. It therefore never protected an assertion:
an eventual-consistency miss surfaces as an xUnit
EqualException, which it rethrew immediately.For that one failure class it did catch, whole-test retry is a coverage superset — it re-runs the
body, including the socket operation. The honest difference is that it re-runs the whole body
rather than resuming mid-test; at the three affected sites the test data is keyed on a
Guid.NewGuid()tag regenerated per invocation, so a whole-test attempt re-seeds cleanly instead ofcolliding with the previous attempt's data. One of the three sites is inside a method already marked
[Fact(Skip = …)]and never executes at all.Removing the helper initially left three dead
await ((Func<Task>)(async () => { … }))()no-opwrappers where the retry call had been — real dead scaffolding, not a behaviour change. Those are
inlined in
d3e65a87b(−9 lines);git grepfor that shape returns 0 at head.The
Xunit.SkippableFactpackage dependency. Removed from the consuming.csprojfiles;the 438 sites move to the v3 built-ins above. Across the 28 consuming projects the package
references consolidate 4 → 1 at most and 3 → 1 at least:
xunit+xunit.runner.visualstudio+Microsoft.NET.Test.Sdk(+Xunit.SkippableFactwhere used)collapse into the single
xunit.v3.mtp-v2metapackage — a project sheds 4 references if it usedSkippableFact, 3 if it didn't. This is the −118 bucket in the decomposition above (29
.csprojchanged for −123 total; the framework's own
.csprojaccounts for −5, leaving −118 across the 28consumers). Three honest caveats so the 4→1 headline doesn't inflate:
xunit.assert→xunit.v3.assertandxunit.extensibility.core→xunit.v3.extensibility.coreare 1:1 renames,not consolidation, and the
Newtonsoft.Jsonremoval is incidental, unrelated to themigration (7 distinct packages removed, 3 added).
The
YTest.MTP.XUnit2shim. No longer needed —xunit.v3speaks MTP natively.Assembly fixtures move to native
Xunit.AssemblyFixtureAttribute. The 9[assembly: AssemblyFixture]declarations across 8 files / 2 fixture types are retargeted to thebuilt-in attribute, so no custom assembly-fixture code is carried forward.
Risks and honest disclosures
None of these are buried; read them before approving. Each collapsed item's headline states the
risk — expand for the full analysis.
Five private-reflection points into xunit.v3 internals, pinned to 3.2.2 — each carries a loud type-load guard
The delegate-then-patch model writes exactly five private fields of sealed-getter xunit.v3
types. There is no supported substitute for any of them on 3.2.2. Each is resolved once via a
static readonly FieldInfoand carries a loud type-load guard that names the field, itsdeclaring type, why it is written, and the 3.2.2 version pin — so a future xunit upgrade that
renames a field fails at type-load with an actionable message instead of silently mis-seeding.
XunitTestCase.testCaseDisplayName— per-variant display name.XunitTestClass.uniqueID— per-variant test-class identity (load-bearing; see below).XunitTestMethod.traits— additiveDataStore/Formattraits on the method.FixtureMappingManager.fixtureCache— seed the chosen(DataStore, Format)for the fixture ctor.FixtureMappingManager.parentMappingManager— reach the parent scope to place the seed.Fields 2 and 4 are load-bearing: without a distinct per-variant
uniqueID, all variants share oneclass run and one class-fixture instance, so only one
(DataStore, Format)is ever seeded — theother variants run against the wrong store while displaying the right name and passing green.
v3 silently swallows a discovery exception (fewer tests, still exit 0) — the reason the fail-loud fault path exists
This is the single most important thing to understand about the framework. If a throw escapes
test discovery (
FindTestsForType), v3 does not fail the run: the class simply vanishes, thesummary reports fewer tests, and the process still exits 0. A malformed test in a fixtured class
would remove real coverage invisibly. To defend against that, the framework contains a deliberate
fault path that catches a per-method discovery failure and emits a loud, trait-tagged
failing
ExecutionErrorTestCase. Do not read this as defensive polish — it is the reason a faultpath exists at all. (Its measured line cost is broken out under Complexity accounting.)
The fault case must survive trait filtering, or the defence is a no-op under the E2E leg. This
was found and fixed (head
66e78c9):/[(DataStore=CosmosDb)]the fault cases weresimply absent —
Test run summary: Passed!,total: 6,failed: 0, exit 0. A brokendiscovery run reported success. That is the defect.
Failed!,total: 19,failed: 11, exit 2,selecting e.g.
MethodThrowWithClassAttrTests(CosmosDb, Xml).Bad3a. The fault case now carries theunion of the raw class- and method-level flags as
DataStore/Formattraits, so it survivespositive trait filtering.
Why "positive" is load-bearing: the integration legs exclude a store rather than selecting one,
and an exclusion keeps tests whose
DataStoretrait is absent as well as different — so those legswould have caught the fault case regardless and never exposed the bug. On the E2E legs the
DataStoreselector is a positive trait equality, so an untraited fault case is dropped by itregardless of the category clause's polarity — the category default is itself negative
(
Category!=ExportLongRunning), but it is ANDed with theDataStoreclause and so cannot rescue anuntraited case. Because
DataStoreis positive on every E2E leg, the/[(DataStore=CosmosDb)]demonstration above is representative of all of them, not one arbitrary leg; a fix demonstrated only
under a negative filter would have proven nothing.
Named residual: if an attribute's constructor itself throws and the class carries no
class-level attribute, the variant flags are genuinely unknowable, so the error case is emitted
untraited — loud when unfiltered and under negative legs, but still invisible under a positive
filter. This was accepted deliberately rather than fabricating traits: it is strictly better than
v2, where that same input threw out of discovery and the entire class vanished silently, so it
cannot regress against v2.
Cross-process serialization regression — found and fixed; parity with v2 (VSTest bridge 27→1 green before, 29→29 after)
The custom per-variant types were not serializable, so any runner that transports test cases by
serialization (the VSTest bridge / Visual Studio Test Explorer) silently dropped every
variant-expanded test and reported green — the VSTest path discovered 27 tests and executed 1,
exit 0. The FHIR CI runs in-process MTP and was never affected, but this is exactly the
silent-coverage-hole class this migration must not ship.
Framing: this is parity with v2, not new scope. xunit v2 already implemented
TestClassWithFixtureArguments : IXunitSerializable; restoring serializability matches what v2 did.Implementation note:
XunitTestClass.Serialize/Deserializeare sealed (virtual final) in3.2.2 and cannot be overridden. The fix instead re-declares
IXunitSerializableon the subclasswith explicit implementations that chain
base.Serialize/base.Deserialize(a sealed method isstill callable via
base.) and add the variant flags. This looks odd — converting it tooverridewill not compile. Cost: +70 lines, no new reflection point. Verified: round-trip
OK=28 BAD=0;VSTest bridge discovery 29 → execution 29.
MTP-native Test Explorer path not exercised (residual, same-shape risk)
The serialization fix is verified through the VSTest bridge. The MTP-native Test Explorer path
(Visual Studio's direct MTP integration) requires Visual Studio and was not exercised in this
work. It is the same shape of cross-process test-case transport as the VSTest bridge, so the same
serialization contract should cover it — but that is unverified. Flagging it as a known residual
rather than a closed item.
Two v3 analyzers (xUnit1031 / xUnit1051) suppressed — test ruleset only, production untouched; disclosed deferral
xUnit1031(no blocking task operations in tests) andxUnit1051(preferTestContext.Current.CancellationToken) are set toNonein the test ruleset only — theproduction ruleset is untouched, with zero leakage verified. Both are new in v3 and fire as
errors across hundreds of existing test bodies; clearing them is a behavioural change to test
code and is out of scope for a mechanical migration. Recommended follow-up: address
xUnit1031specifically — it flags a real latent deadlock risk (~171 candidate sites).
xUnit1051isstylistic and lower priority.
Two executor subsystems deliberately dropped (assertion env-var copy ~80, parallelism semaphore ~66) — formatting/throughput only, never coverage
Two blocks from the old executor were left out on purpose — do not re-add them speculatively:
truncate at the default depth/length rather than a custom one. Affects message formatting only,
never which tests run or pass/fail.
MaxParallelThreadsis unenforced, socollections can all start at once. A throughput/resource difference, never a correctness or
coverage one.
Re-add either only if a team actually depends on the corresponding option.
Complexity accounting (honest)
Full complexity accounting — repo-wide −509 decomposition, framework 745 → 1,045 (+300) breakdown, discoverer +66 raw, and zero recoverable slack
The framework directory shrank, but all of that reduction is retry deletion; for equal
functionality the fixture-expansion machinery grew. Stating that ourselves, with the breakdown, is
the whole point of this section — a reviewer who checks will find the growth in about two minutes,
and this PR exists because a previous attempt was rejected for growing exactly this code.
Repo-wide this is a substantial net reduction — 144 files, +1,528 / −2,037, net −509 lines — but
the honest story is where the −509 comes from, not the aggregate. It deletes the retry subsystem
(676 lines), the
Xunit.SkippableFactpackage and its 438 call sites, theYTest.MTP.XUnit2shim,IClassFixtureExtensions, and 5 dead package pins. The auditor decomposed the diff by path bucket;it reconciles exactly (residual 0):
src/Microsoft.Health.Extensions.Xunit/).csproj, 4 packages → 1).csin thetest/directory (SkippableFact +IAsyncLifetime+ retry-residue inline, 68 files).csundersrc— 18 files, chieflySkipReasons.cs— +15, tools +8, misc +4, CI +3,Packages.props−3)−381 −118 −37 +27 = −509. Read that table before trusting the headline. The intuitive readingof "net −509" is "the tests got simpler" — that is false, and this body should not permit it.
Test
.csin thetest/directory moved only −37 across 68 files, essentially line-neutral:SkippableFact→Factis a one-for-one token swap and the
IAsyncLifetimeedits are signature changes, so those deletionsland in tokens, not lines. The reduction comes from deleting the retry subsystem (inside the
framework bucket) and consolidating package references (the −118
.csprojbucket — four packagescollapsing to one
xunit.v3.mtp-v2across 28 projects, 4× the test-code contribution) — not fromthe test code shrinking.
(The framework directory contributes −381 to the diff: its
.csblobs shrink −376 — the1,421 → 1,045 comparison below — and the directory's own
.csproja further −5. The −376 is ablob-state figure and must never be subtracted from a diff total; everything outside the framework
directory nets −119.)
The framework directory itself:
origin/main, 15.csfiles).csfiles)Framework directory: 1,421 → 1,045 (−376) (a blob-state
.cscomparison). Do not read that asthe story. That −376 is retry deletion (−676) net of the fixture machinery growing the other way:
v3 ships no retry, the 676-line retry subsystem is gone, and for equal functionality the
non-retry baseline against v3 is
745 → 1,045, +300 (+40.3%) raw, or +183 (+28.6%) once MIT headers and
///docs arestripped from both sides. We state that increase first and plainly: this PR exists because an
earlier attempt was rejected for growing exactly this code, and a reviewer who finds the +300
unaided will distrust everything else here. Where the +300 goes (each column sums exactly to its
total; every line is counted, blanks and braces included):
///doc//comments (incl. MIT header)usingThis table is measured — every cell confirmed by direct count, with per-file bucket sums
asserted against the raw totals; no cell was corrected from the earlier provisional pass. The braces
row is split deliberately: +54 is genuine one-type-per-file boilerplate (9 non-retry files became
13, each carrying namespace/class/method scaffolding), but +20 is real fault-path nesting inside
the discoverer (braces 107→127) — the brace-shadow of the new
EmitFaultCase/ApplyFlagTraits/TryGetRawFlags/CollectRawFlagsbodies and theirtry/catch/foreachblocks. Calling all +74"boilerplate" would understate substantive growth by 20.
So ~53% of the raw growth is XML
///docs plus//comments (the MIT header and inline notes),~21% is one-type-per-file scaffolding (braces
+54,using+10) the impl brief required, and therest is substantive — +69 code lines plus the +20 fault-path braces, not boilerplate. The fairest
single comparison — stripping MIT headers and
///docs from both sides — is the +183 (+28.6%)already quoted above.
Net growth ≠ review surface: one file dominates, and roughly nine you cannot skip
A reviewer who sees "+300 net" will assume one file grew and the rest is noise. That is false, and
the Executor is the counter-example that proves it:
CustomXunitTestFrameworkExecutor.csshrank251 → 37 (−214) while adding reviewer work — its logic moved into 7 new files (436 lines). Net
growth and review surface are different quantities; allocate attention by the second, not the first.
The 13
.csfiles at head split into four review buckets:CustomXunitTestFrameworkDiscoverer.cs, 144 → 417 (+273 of the+300 net). This is the fail-loud discovery path dissected above — the one block that needs a real,
slow read.
CustomXunitTestFrameworkExecutor.cs(251 → 37) and therewritten
CustomXunitTestFramework.cs(49 → 29), plus the 7 new files the Executor's logic movedinto:
AssemblyRunner(32),AssemblyRunnerContext(37),ClassRunner(86),CollectionRunner(30),
FixtureArgumentSetTestClass(80),FixtureArgumentSetTestMethod(113),FlagCodec(58).(The "4-file runner cascade = 185" cited above is
AssemblyRunner+AssemblyRunnerContext+ClassRunner+CollectionRunner; the other three — 80 + 113 + 58 = 251 — are the fixture/codecmachinery counted in the +300 table — 185 + 251 = 436, same files, two groupings.)
FixtureArgumentSetsAttribute.cs(+19, 0 deletions): glance only.NotTest.cs,SingleFlag.cs: skip entirely.The 9 deletions are not one bucket — they split 6 / 3, and the halves need different reviews:
RetryTestCase(316),IClassFixtureExtensions(141),RetryTheoryDiscoverer(91),RetryFactDiscoverer(54),RetryFactAttribute(37),RetryTheoryAttribute(37)TestClassWithFixtureArguments(91) →FixtureArgumentSetTestClass.cs;TestClassWithFixtureArgumentsTypeInfo(76) → v3 native metadata;AssemblyFixtureAttribute(27) → nativeXunit.AssemblyFixtureAttributeDo not flatten that 6/3 into one list of nine: confirming a retry file is gone is a two-second check,
but confirming
TestClassWithFixtureArguments's serialization behaviour survived intoFixtureArgumentSetTestClassis the cross-process parity fix disclosed under Risks — the singledeletion that most rewards a careful read. (Census reconciles: 15 − 9 + 7 = 13; main column 1,421,
head column 1,045.)
Two points that pull the other way and should not be omitted:
plus the 4-file runner cascade (185) totals 222 — a net −29 for the same job. The cascade
replaces the executor rather than adding to it, and 99 of its 185 lines are pure pass-through
that v3 requires you to declare in order to inject a custom class runner. Presenting "+185
architecture tax" in isolation would be misleading.
66e78c9f: no dead code, no orphaned helpers (it verifiedGetVariantClass/ApplyVariantTraitsare still called on the success path), and the fault path was actually consolidated — two
duplicated error-case blocks became one
EmitFaultCase, a DRY reduction. The only ever-recoverablecandidate was
Fix E(below), permanently closed on the fault path's merit, so the honest figure iszero cleanly recoverable lines — we looked, and there is nothing left to cut. The +300 is
convention overhead + parity + genuine fault-tolerance, with nothing to trim.
On
Fix Especifically: a refactor to collapse theTryGetMethods/TryComputeVariantswrapperswas proposed and deliberately rejected. Those wrappers are the guards that stop an exception
escaping
FindTestsForType; removing them to recover the ~10–15 lines would reintroduce the silentclass-drop the fault path exists to prevent. In a PR whose thesis is "complexity goes down," we
still declined that reduction because the fault path wins — recorded here as a decision, not an
omission, to pre-empt the obvious reviewer question. With
Fix Eclosed, the cleanly recoverabletotal is zero.
The single largest block of genuinely new code is the discoverer's fail-loud discovery path (the
discoverer went 144 → 417 versus v2). Its growth since
c498c301is measured and deliberate —+66 raw, decomposing cleanly as +45 comment-stripped, +21 inline
//, +0///(45 + 21 + 0 = 66, a closure worth keeping visible). That +45 comment-stripped is itself
+19 code, +20 braces from new guarded blocks, and +6 blank lines (19 + 20 + 6 = 45); the braces
and blanks are the scaffolding of splitting fault handling into more guarded blocks, so the genuinely
new logic is the +19. Of the +66, +61 came from three silent-failure fault-path fixes (at
head
66e78c9) and the remaining +5 from a later comment-onlyFix Cresidual — inline hazardnotes, no code, confirmed at the framework level where stripping all comments leaves the total at
769 lines both before and after that edit, delta zero.
The discoverer's
///XML-doc line count is 11 both before and after — delta zero — which is whatmakes the growth provably inline
//and not XML docs. Over the full range the inline//grew+21 net (27 added, 6 removed), so a reviewer running
git diff c498c301 cb7c6dff7 | grep '^+.*//'sees 27 — the gross, not the +21 net; that the grep returns the gross rather than thenet is exactly what we measured. Of that, the fault-path pass alone contributed +16 net
(22 added, 6 removed), which classified by content is 14 reflection-hazard /
fault-path-invariant lines + 2 algorithm-semantics lines, with 0 throwaway (no section markers,
restatements, TODOs, or formatting breaks). That is the correct home for them: a hazard note like
"do not re-enter these reflected types on the fault path" belongs as a
//comment at the hazardsite, not as an XML doc on a public member.
Those 61 lines were not incidental: the maintainer ordered three confirmed silent-failure defects
fixed after each was reproduced by fault injection — a deliberate trade of 61 lines to stop a broken
discovery run exiting 0 green, in the same spirit as the
Fix Erejection above.The fault path exists for one reason, the strongest-verified fact in this
migration: xUnit v3 silently swallows an exception thrown out of
FindTestsForType— the testclass simply vanishes, fewer tests run, and the process still exits 0 (a green build with missing
coverage). This was proven by execution twice independently — once during design, and again during
review, when renaming a single reflection lookup made an entire fixtured class disappear at exit 0.
Without that context these lines read as gold-plating; with it, they are the justification for the
largest new block in the PR. (See the discovery-swallow disclosure under Risks.)
For scale: the rejected earlier attempt's framework was 3,461 lines; this is 1,045 — about 3.3×
smaller. That comparison is fair and worth making explicitly, since the reviewers of this PR saw
the earlier one.
Own the obvious objection before a reviewer raises it: for this repo's healthy suite, the fault
path is dead code.
ReportFault/EmitFaultCase/ApplyFlagTraits/TryGetRawFlagsarereachable only from the
catcharoundExpandMethod, which never fires here (0 genuine faultmarkers) — so ~20 of the +300 like-for-like lines are code that never runs on a green build. That is
deliberate, not oversight. The trap it guards is xUnit v3 silently swallowing a throw out of
FindTestsForType: a class that breaks during discovery simply disappears and the run still exits0. Code that never fires is the desired steady state for fault handling — the failure it prevents
is precisely the one that leaves no trace until the day it matters, and a reviewer who confirms the
path is unreached is confirming the suite is currently healthy, not that the guard is waste. It is
also not purely additive: introducing it consolidated two duplicated error-case blocks into a
single
EmitFaultCase.Behaviour preserved
Namespace.Class(SqlServer, Json).Method, producedby an insert of the
(DataStore, Format)suffix after the class name (not an append). This isbyte-for-byte identical to v2 specifically to preserve Azure DevOps test history. A guarded
append fallback exists only for a hypothetical future custom
DisplayName.(Json/Xml) still run.
Format.Allsites still expand both Json and Xml within one process. A method-level flagreplaces the class-level flag for that same dimension (per-dimension replacement), leaving the
other dimension as declared — so a method marked
SqlServeron aCosmosDbclass yields(SqlServer, Json), a combination the class never declared. This is not an intersection (whichwould make that method vanish) and not a widening; it matches v2 exactly.
CI filter changes
main's VSTest-style substring filters become native xunit.v3 trait queries. This is a grammarchange, not a string swap: native xunit.v3 rejects the legacy
--filteroption,--filter-querypredicates cannot be mixed with simple filters, and multiple
--filter-queryvalues are OR'd (so acompound
DataStore & Categorymust be one query). Changes span four of the fivebuild/jobs/*.ymlfiles —run-cosmos-tests.yml,run-sql-tests.yml,e2e-tests.yml,run-export-tests.yml;build.ymlneeds no change (its unit legs carry no data-store filter,and those assemblies contain zero fixtured classes).
CI filter mechanism detail — per-leg before/after, dynamic e2e query, 17-leg selection-neutrality (missing 0 / added 0), and pr-pipeline vs ci-pipeline sql (2105 / 2192)
run-cosmos-tests.ymlintegration (negative)--filter "FullyQualifiedName!~SqlServer"--filter-not-trait "DataStore=SqlServer" --filter-not-class "*SqlServer*"†run-sql-tests.ymlintegration (negative)--filter "FullyQualifiedName!~CosmosDb"--filter-not-trait "DataStore=CosmosDb" --filter-not-class "*CosmosDb*"†e2e-tests.yml(positive, compound)--filter "FullyQualifiedName~${appServiceType}&${categoryFilter}"--filter-query "/[(DataStore=${appServiceType})&(${categoryFilter}…)]"(built dynamically — see below)run-export-tests.yml(positive, compound)--filter '…~CosmosDb&Category=ExportLongRunning'/~SqlServer&…--filter-query "/[(DataStore=CosmosDb)&(Category=ExportLongRunning)]"/ SqlServer† Simple filters, not
--filter-query. This asymmetry is deliberate and required — seeIntegration-leg exclusion filters — a regression found late, and fixed below. The first-cut
--filter-query "/[(DataStore!=SqlServer)]"port shipped a bug; these two legs had to move off--filter-queryentirely.The e2e leg's query is built dynamically in
build/jobs/e2e-tests.yml:Each category clause is split on
&, wrapped in parentheses, re-joined with&, and ANDed with theDataStoreselector — so whatever polarity$categoryFiltercarries is preserved verbatim. Thebehavioural change is the selection mechanism: v2 matched the datastore by display-name substring
(
FullyQualifiedName~${appServiceType}), v3 byDataStoretrait equality — same intent,different mechanism, and the single most consequential change to the CI surface.
The two E2E
sqlfigures — 2105 and 2192 — are both correct; they belong to different pipelines.The seven leg families above are the
pr-pipeline.ymllegs that run on this PR (the 2105 / 2192 contrast here is the R4 column): it passes no categoryoverride and so takes the template defaults, which split reindex and bulk-update into their own legs
(
sql-main2105 +sql-reindex52 +sql-bulkupdate35). After merge,ci-pipeline.ymloverrides with
mainCategoryFilterand folds those back into a single leg, so the post-merge maingate enumerates cosmos 1481 / sql 2192, where
2192 − 2105 = 87 = IndexAndReindex 52 + BulkUpdate 35(disjoint), and everything still reconciles to1,536 + 2,197 + 28 = 3,761. Neither value is"the" E2E sql count — 2105 is pr-pipeline, 2192 is ci-pipeline — and stating either unlabelled
is what makes the two tables look contradictory. This split is pre-existing, not introduced by the
migration: the category values are byte-identical v2→v3, and
pr-pipeline.yml/ci-pipeline.ymlhave zero diff here. (
IndexAndReindexandReindexOperationare not a typo and neither is a no-op:each selects the same 104 tests — 52 cosmos + 52 sql — confirmed by name-level diff.)
Polarity is load-bearing. The E2E
DataStoreselector is a positive trait equality; an untraitedfault case is dropped by it regardless of the category clause's polarity. This holds because
(DataStore=<store>)is ANDed with the category clause, so a negative category default(
Category!=ExportLongRunning, perbuild/jobs/e2e-tests.yml) cannot rescue an untraited case.Because
DataStoreis positive on every E2E leg, the Fix B / Finding 2 demonstration under/[(DataStore=CosmosDb)]is representative of every E2E leg, not one arbitrary leg — a faultemitted untraited is invisible on all of them.
Zero-match is kept as a defect signal (MTP exit code 8) — no
--ignore-exit-code 8is added.Dev tools were ported too.
tools/ABTestRunner/Invoke-ABTest.ps1was first proven broken againstnative xunit.v3 (real output: exit 5, stderr
Unknown option '--filter'), then converted to--filter-query "/[(Category!=Export)&(Category!=ExportDataValidation)&(Category!=ExportLongRunning)&(Category!=Import)&(DataStore=SqlServer)]",with every predicate keeping its original polarity. After the fix the option is accepted, the
query parses, and > 0 tests are selected.
tools/MultiInstanceRunner/README.mdwas likewisecorrected to
--filter-querynode+trait syntax, alongside a stale-f net9.0→net10.0fix.There is deliberately no clean AFTER exit code for these: a pass/fail verdict needs a live FHIR
server, which this work did not stand up. What is proven is that they clear the exact barrier that
produced the
--filterexit 5 — not that a full run goes green.Live CI evidence — this PR's executed counts vs two independent v2 control builds (18 legs, all match)
Everything above this point is offline enumeration:
--list-testsrun locally under the committedfilter strings. This section is the live gate — what Azure DevOps actually executed.
d3e65a87b. Final resultsucceeded—42 / 42 jobs, 22 / 22 stages, 0 failed, 0 skipped.
main+ an unrelated change): build 51065 (PR Extend middleware with Inbound Request Logs #5755) and build 51056(PR Add Query Store performance diagnostics #5723).
Two controls, not one, deliberately: a PR build is
mainplus that author's change, so a singlebaseline cannot distinguish "the migration moved this number" from "this number moves on its own".
Where the two controls disagree with each other, the leg is natively variable and no conclusion
about the migration can be drawn from it.
Reading the numbers:
total / skipped, taken from the firstTest run summaryin each tasklog — with
--retry-failed-testsa log can contain several summaries, and only the first one is thefull selection (later ones are the retried subset only). Pass/fail is taken from the job result,
never from a summary; every job in every row below ends
succeededon all three builds.Fifteen of eighteen legs are identical across all three builds. That includes every E2E leg on
every FHIR version and both data stores — 8,819 selected cases across the five main E2E legs
alone — plus all five reindex legs and all three bulk-update legs.
The skip column is the load-bearing one.
[SkippableFact]/Skip.If→[Fact]/Assert.SkipWhenis the single largest semantic conversion in this PR (438 sites). If any of those193 skip decisions had inverted or been dropped, a test would flip between the skipped and executed
columns without changing the total. The skip count is identical to both v2 controls on all eighteen
legs — 29, 24, 28, 40, 39, 0×5, 0, 2, 0, 20, 107, 6, 117, 117 — 529 skips in total, reproduced
exactly. That is live proof that the skip conversion is decision-for-decision faithful, and it is
stronger evidence than the static count-and-polarity audit that preceded it.
The three legs that differ, and why they are not a finding. Integration SQL varies by up to ±3 —
but the two v2 controls disagree with each other on all three of those legs (449/448/449 vs
451/452/452), so this leg is natively variable on v2 and the migration cannot be the cause. In every
case v3 lands inside the v2 range. Note also that the variance is entirely in the succeeded column:
the skip count on those same three legs is invariant at 6 / 117 / 117 across all three builds. This
is also the residue of the
439-vs-438reconciliation above — the leg simply does not have onefixed number.
Disclosed, not hidden — reindex is flaky on v2 as well. Some Reindex jobs record failures on
their first attempt and pass on retry. On this PR that is
E2E R4 CosmosDb Reindex(4 first-attemptfailures). On the v2 controls it is
E2E Stu3 CosmosDb Reindex(5 first-attempt failures on#51065) and three separate legs on #51056. Every one of these jobs ends
succeededon all threebuilds. Pre-existing category flakiness, present on both sides of the migration, not introduced here.
Independent corroboration from coverage. Codecov reports 78.65 % line coverage for this PR,
against 78.70 % (#5755) and 78.78 % (#5723) on the two v2 control builds — a spread of
0.13 pp. Coverage is instrumented from what actually executed, so it is derived independently of
every count in this description. A migration that silently stopped running a slice of the suite would
show up here as a coverage drop; it does not.
Not comparable, and excluded rather than fudged: the export legs and the unit-test legs run under
different task names / conditions across these builds, so they are not tabulated; the unit leg is
reported separately above (14,887 / 0).
Integration-leg exclusion filters — a regression found late, and fixed
The two integration legs are the one place a mechanical filter port went wrong, and it is worth
a reviewer's full attention because of how it was found. Both legs replaced a v2 name exclusion
with a v3 trait predicate:
run-cosmos-tests.yml:--filter "FullyQualifiedName!~SqlServer"→ (first cut)--filter-query "/[(DataStore!=SqlServer)]"run-sql-tests.yml:--filter "FullyQualifiedName!~CosmosDb"→ (first cut)--filter-query "/[(DataStore!=CosmosDb)]"What broke. A trait
!=means "differs or is absent". Store-named classes that carry noDataStoretrait therefore survive the exclusion and get double-scheduled onto the opposite store'sleg. Measured: cosmos 291 → 334 (+43), sql 439 → 446 (+7).
Why it matters — and why it is not coverage loss. Nothing is dropped (
missing 0on both legs);the defect is extra tests. But the extras are backend-dependent: each is an
IClassFixture<…>whosefixture provisions a live store in
InitializeAsyncbefore any test body runs, and anIClassFixtureconstructor failure errors every test in the class rather than skipping it. So the Cosmos leg
would attempt 43 SQL-backed tests with no SQL provisioned — both integration legs would have gone
red on
main.Why CI did not catch it — the honest disclosure. There is no integration leg in this PR's check
list at all. The integration legs run only after merge, so this regression could not have surfaced
on the PR; it would have broken
main. That is exactly why it was caught by offline name-levelenumeration rather than by a red check here, and it is the single most reviewer-useful fact in this
section.
Why the fix is not "just correct the query". A query-filter fix is impossible on runner 3.2.2,
proven by execution — any explicit path segment collapses the trait predicate to zero matches, and
multiple
--filter-queryargs OR rather than AND, so an exclusion trait cannot be ANDed with aname exclusion inside
--filter-query:The fix. The two negative legs drop
--filter-queryfor simple filters, ANDing a traitexclusion with a class-name exclusion:
--filter-not-trait "DataStore=SqlServer" --filter-not-class "*SqlServer*"→ exit 0,found 291
--filter-not-trait "DataStore=CosmosDb" --filter-not-class "*CosmosDb*"→ exit 0,found 439
Both clauses are load-bearing:
--filter-not-classalone finds 596,--filter-not-traitalone finds334 / 446; only the AND of the two reproduces v2's exact membership. Verified against the v2
baselines (v2
origin/main, v3 this PR) at name level — missing 0 / added 0 on both legs, so thefixed legs run the same tests v2 ran, not merely the same count. (These are the shipped-filter CI
legs; distinct from the unfiltered R4 Integration assembly A/B of 707 / 707 under Verification, which
tests a different thing — that the discoverer edit moved no test.)
The asymmetry is deliberate — do not "harmonise" it. The seven positive E2E/export leg definitions stay on
--filter-query; only these two negative integration legs use simple filters. That looks untidy, butsimple filters are the only mechanism on 3.2.2 that ANDs an exclusion trait with an exclusion name.
Converting these two legs back to
--filter-queryfor consistency silently reintroduces the +43 / +7double-scheduling regression above.
Verification
Result up front — every claim below is measured, cross-model, and expanded in the collapsed detail:
enumeration; 87 classes stable (none added, none vanished); display names byte-identical, so
Azure DevOps test history survives.
Microsoft.Health.Fhir.SqlServer.UnitTests1,053 → 1,053,failed: 0.reproduce their v2 baselines exactly on every version that ships them — cosmos 291 / 291 (R4, Stu3;
no R5-cosmos leg exists), sql 439 / 439 (R4, Stu3, R5), missing 0 / added 0 — the six binaries
proved distinct by SHA256, so the identical totals are not one assembly counted thrice. (Debug
binaries; the Release equivalent is sql 438 / 438, same missing 0 / added 0 — see the build
configuration note above.)
claude-opus-4.8arbiter againstfreshly built binaries; fault-path findings were raised by a different-provider
gpt-5.6-solreviewer and confirmed by fault injection — not self-verified by the implementer.
cb7c6dff7. Twocommits have landed since, and neither disturbs a figure above.
8e02cdfb9changes two YAML linesand no
.csfile (+4/−2).d3e65a87binlines three dead no-op wrappers left behind by the retryhelper's removal, touching two test
.csfiles (+68/−77, net −9) — bodies only: an A/Benumeration across all four affected legs before and after that commit returned diff 0 on every
leg, no test name added, removed or renamed. The repo-wide ledger below is stated at head and
already includes it.
Full verification detail — name-level diff tables, the counting rule, risky-construct coverage, per-assembly reconciliation, and cross-model provenance
The same tests run — verified by name-level diff, not by pass/fail
The central risk of this migration was never "do the tests pass" — a framework rewrite can lose
whole classes and still report green. It is "does the same set of tests still run". This was
measured, not asserted: an independent arbiter built both sides from source with the same
toolchain (
c498c301pre-fix,66e78c9fpost-fix), enumerated every leg with--list-tests, anddiffed the discovered test names — not merely their counts.
The gate is written at name level, because counts alone can lie. A class losing four tests while
another gains four leaves the total unchanged and reads as a perfect pass; only a name-level multiset
diff catches an equal-sized swap. What was actually measured is the stronger statement — no test
name moved — so this section says "the same tests," never "the same number of tests."
Across all seven enumerations of the R4 E2E assembly — unfiltered plus each bare
DataStoreand export predicate — v2 and v3 agree exactly, and every enumeration's runner-reported
found N test(s)matched its canonical stripped count on both sides. What this table proves: thediscoverer edit (pre-fix
c498c301vs post-fix66e78c9f) moved no test within the R4 E2E assemblyunder bare trait predicates. What it does not prove: anything about the shipped CI filter
strings — those are validated separately by the positive-leg enumeration above. The rows below are
therefore labelled by assembly and bare predicate, not by CI-leg name (the
integration-*/e2e-*names belong to CI legs and are reserved for them):main)DataStore!=SqlServerDataStore!=CosmosDbDataStore=CosmosDbDataStore=SqlServerDataStore=CosmosDb)DataStore=SqlServer)On top of the per-name diff, a whole-class check: 87 classes on both sides — none appeared, none
vanished, and none changed its per-class test count. (The R4 Integration assembly was separately
A/B'd name-for-name at 707 / 707, also identical.) Display names were byte-identical —
Namespace.Class(DataStore, Format).Method— so no normalisation was needed, which additionallymeans Azure DevOps test-history keys still match and historical trend data survives the
migration.
Counting rule — stated because getting it wrong has already manufactured two false counts on this
PR. For every discovery figure: trim each
--list-testsline before matching, drop the chrome(banner,
Test discovery summary, theduration:footer) and blanks, and treat the runner's ownfound N test(s)line as the figure of record. The rule is load-bearing, not pedantry: a v2-erabanner filter silently stopped matching when the v3 banner text changed (producing the dead
1565 / 2226 / 1537 / 4 / 6, each exactly +1), and a two-space-indentedduration:footer oncesurvived a
^duration:anchor. A discovery number published without its counting rule is how thenext wrong number is born — that is this PR's own history, not a hypothetical.
Coverage of the risky construct: of 256 total attribute sites, 146 are method-level across 28
files (the case where per-dimension replacement can produce a class-undeclared combination); 25 of
those 28 files were covered directly, each with a zero diff — including
AuditTests(29/29) andBundleTransactionTests(46/46), the two classes whose class- and method-level attributes conflictmost sharply.
Per-assembly executed count, separately:
Microsoft.Health.Fhir.SqlServer.UnitTestsruns1,053 → 1,053 (exact) — v3 reports
total: 1053, failed: 0, skipped: 0, zero delta from the v2baseline.
The seven counts reconcile against the R4 E2E composition — 1,536
(CosmosDb, …)+2,197
(SqlServer, …)+ 28 non-variant = 3,761. The negative-trait legs (!=) include all 28non-variant tests, because
!=means differs or absent; the positive-trait legs (=) excludethem. That asymmetry is why the
DataStore=CosmosDbenumeration is 1,536 but theDataStore!=SqlServerenumeration is 1,564 — the difference is exactly those 28 tests — and1,536 + 2,197 + 28 = 3,761closes. A reviewer who spots the mismatchwithout this note would assume tests went missing.
This discovery-neutrality was measured by a separate
claude-opus-4.8arbiter against freshly builtbinaries on both sides — not by the implementer who wrote the framework — and the fault-path
findings were likewise raised by a different-provider
gpt-5.6-solreviewer and confirmed by faultinjection on
claude-opus-4.8before any fix was written (see below). Self-verified migration claimsare worth less, so the provenance is stated explicitly.
How the fault-path findings were validated (cross-model)
The three fault-path findings above were not accepted on plausibility. They were raised by a reviewer
running
gpt-5.6-sol— deliberately a different provider from theclaude-opus-4.8implementer —and each was then confirmed by fault injection by a separate
claude-opus-4.8verifier thatreproduced it in situ before any fix was written. A fourth finding from the same reviewer was
cleared by an execution A/B and is deliberately not in this PR; a review that only ever confirms
is not a review.
Framework behaviour (from the proven spike and the ported build)
failed:0 skipped:4; trait-filter counts29 / 2 / 2 / 2 / 15 / 14(traits merge, not replace).injected
(DataStore, Format)internally;Failed:0proves it).66e78c9): a discovery failure is converted into a trait-taggedfailing
ExecutionErrorTestCase. Under a positive filter/[(DataStore=CosmosDb)]the faultcases now select and fail —
total: 19,failed: 11, exit 2 — where before the fix the samefilter reported
total: 6,failed: 0, exit 0 (green over a broken discovery run). Residual:a throwing attribute constructor on a class with no class-level attribute is emitted untraited
and stays invisible under positive filters (see Risks).
OK=28 BAD=0.fix).
--list-testsunder the new filter must select > 0 tests.The two negative integration legs now use simple
--filter-not-trait/--filter-not-classfilters(not
--filter-query; see Integration-leg exclusion filters), verified against all three shippedintegration assemblies (R4, Stu3, R5) to reproduce their v2
!~baselines at name level — cosmos291 / 291 (R4, Stu3), sql 439 / 439 (R4, Stu3, R5), missing 0 / added 0 (binaries proved distinct by
SHA256; Debug binaries — Release is sql 438 / 438, same missing 0 / added 0, see the build
configuration note under Integration-leg exclusion filters). The
first-cut
--filter-queryport over-selected (+43 / +7 untraited store-named tests) and is fixed;any future divergence from those baselines is a blocking regression.
Build gate: the solution builds in Release with the only remaining errors being the 4 known
pre-existing
VerifyExactSdkVersionE2E errors (SDK 10.0.303 vsglobal.jsonpin 10.0.302);global.jsonis intentionally not edited.