Skip to content

Xunit2 -> Xunit3 Migration - #5507

Closed
Mikael Weaver (mikaelweave) wants to merge 111 commits into
mainfrom
personal/mikaelw/xunit3-v2
Closed

Xunit2 -> Xunit3 Migration#5507
Mikael Weaver (mikaelweave) wants to merge 111 commits into
mainfrom
personal/mikaelw/xunit3-v2

Conversation

@mikaelweave

@mikaelweave Mikael Weaver (mikaelweave) commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Description

  • Migrates the entire test suite from xUnit v2 to xUnit v3 running on Microsoft Testing Platform, replacing VSTest as the runner across every CI leg.
  • Rewrites this repo's custom xUnit extensions — fixture argument sets, [RetryFact]/[RetryTheory], and skippable tests — against v3's new discovery and execution APIs, which have no v2 equivalents.
  • Fixes several ways the v3 move could have silently lost tests: collection traits not propagating to fixture argument set variants, a missing framework attribute disabling discovery for a whole assembly, and retried failures being reported as skips.
  • Adds CI guards so a leg that discovers or runs nothing fails loudly instead of exiting green: per-leg discovery floors, a checked-in manifest of expected unit test projects, and reconciliation of every retry attempt against the final TRX.
  • Adds a test harness for the extensions themselves (150 tests over 34 purpose-built scenario programs), since none of this behaviour was previously covered by anything.

Changes overview

The PR touches 293 files (+12,738 / −968), but roughly two-thirds of that is new test code that adds no production behaviour:

Area Files Lines Review priority
src/Microsoft.Health.Extensions.Xunit/ — the custom framework 21 +3,067 / −631 This is the PR.
build/jobs/ — pipeline YAML + guard scripts 8 +702 / −28 Read this second.
src/Microsoft.Health.Extensions.Xunit.UnitTests/ — new harness 48 +5,014 New tests, additive
src/Microsoft.Health.Extensions.Xunit.TestAssets/ — new scenarios 68 +2,966 New fixtures, additive
Test sources across the repo 108 +903 / −195 Mechanical
.csproj / props / solution files 33 +56 / −57 Mechanical

Where to actually look:

  • CustomXunitTestFrameworkDiscoverer.cs (+1,399), RetryTestCase.cs (+698) and CustomXunitTestFrameworkExecutor.cs (+341) hold nearly all the real logic — v2's discoverer/executor surface no longer exists, so these were rewritten rather than edited.
  • build/jobs/scripts/Assert-RetriedFailuresPassed.ps1 (+155) and Assert-UnitTestProjectsDiscovered.ps1 (+102) are the new safety nets; the five YAML files mostly wire them in and switch each leg to the MTP runner.

Why the file count is high without much to review: 98 of the 108 changed test files have 15 or fewer changed lines and 73 have 5 or fewer — package/using swaps and attribute renames. The two largest, AssemblyChecker.cs and AssemblyValidationsTester.cs, are new shared helpers that assert every test assembly is configured for v3, not per-test edits.

Related issues

AB#189958

Testing

Describe how this change was tested.

FHIR Team Checklist

  • Update the title of the PR to be succinct and less than 65 characters
  • Add a milestone to the PR for the sprint that it is merged (i.e. add S47)
  • Tag the PR with the type of update: Bug, Build, Dependencies, Enhancement, New-Feature or Documentation
  • Tag the PR with Open source, Azure API for FHIR (CosmosDB or common code) or Azure Healthcare APIs (SQL or common code) to specify where this change is intended to be released.
  • Tag the PR with Schema Version backward compatible or Schema Version backward incompatible or Schema Version unchanged if this adds or updates Sql script which is/is not backward compatible with the code.
  • When changing or adding behavior, if your code modifies the system design or changes design assumptions, please create and include an ADR.
  • CI is green before merge Build Status
  • Review squash-merge requirements

Semver Change (docs)

Patch|Skip|Feature|Breaking (reason)

- Add xunit.v3, xunit.v3.mtp-v2, xunit.v3.assert, xunit.v3.common, xunit.v3.extensibility.core (3.2.2)
- Add Microsoft.Testing.Extensions.Telemetry, Microsoft.Testing.Platform.MSBuild (2.0.2)
- Bump Microsoft.Testing.Extensions.CodeCoverage 18.1.0 -> 18.3.1
- Bump xunit.runner.visualstudio 3.0.2 -> 3.1.5
- Remove YTest.MTP.XUnit2, xunit, xunit.assert, xunit.extensibility.core, Xunit.SkippableFact

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Enable UseMicrosoftTestingPlatformRunner
- Switch xunit.runner.json from Content to None + CopyToPublishDirectory=Never
- Replace YTest.MTP.XUnit2 reference with MTP extensions (Telemetry, MSBuild)
- Drop xunit.runner.visualstudio remove directive (now used as normal ref)
- Suppress xUnit1031, xUnit1051, SA1208, SA1209, SA1518

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- CustomAnalysisRules.Test.ruleset: suppress xUnit1031, xUnit1051 and StyleCop SA1208/SA1209
- THIRDPARTYNOTICES.md: remove YTest.MTP.XUnit2 and Xunit.SkippableFact entries
- xunit.runner.json: update schema URL to v3 configuration schema

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace xunit package with xunit.v3.mtp-v2, xunit.v3.common, and xunit.v3.extensibility.core.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- CustomXunitTestFramework now inherits the v3 XunitTestFramework (configFileName ctor), overrides CreateDiscoverer/CreateExecutor taking Assembly.
- CustomXunitTestFrameworkDiscoverer uses v3 XunitTestAssembly + IXunitTestClass + ValueTask-based FindTestsForType/FindTestsForMethod with Func<ITestCase, ValueTask<bool>> callback; integrates new FixtureArgumentSet* types.
- CustomXunitTestFrameworkExecutor is restructured around RunTestCases + custom XunitTestAssemblyRunner/CollectionRunner/ClassRunner/TestRunner types that propagate FixtureArgumentSet variants, and uses reflection to seed the v3 XunitTestAssembly AssemblyFixtureTypes lazy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xUnit v3 needs its own IXunitTestClass/IXunitTestCollection/IXunitTestMethod implementations that carry the fixture-argument-set variants.

- Add FixtureArgumentSetTestClass, FixtureArgumentSetTestCollection, FixtureArgumentSetTestMethod.
- Add FixtureArgumentSetsAttribute (carries the Flags enum combinations + CollectionBehavior).
- Remove obsolete TestClassWithFixtureArguments and TestClassWithFixtureArgumentsTypeInfo (v2 ITypeInfo-based).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- AssemblyFixtureAttribute, RetryFactAttribute, RetryFactDiscoverer, RetryTestCase, RetryTheoryAttribute, RetryTheoryDiscoverer updated for xUnit v3 API (Assembly-based ctors, ValueTask discovery, IXunitTestCase/IXunitTestCollection abstractions).
- Add local Skip, SkippableFactAttribute, SkippableTheoryAttribute classes replacing the now-removed Xunit.SkippableFact package. Skip.If/IfNot throw SkipException.ForSkip(reason) which xUnit v3 recognizes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Swap 'xunit' PackageReference to 'xunit.v3.mtp-v2' across all 19 *UnitTests*.csproj and Microsoft.Health.Fhir.Tests.Common.csproj.
- Remove 'Xunit.SkippableFact' PackageReference from Azure.UnitTests and the four *.Core.UnitTests projects (replaced by local Skip/SkippableFact types in Microsoft.Health.Extensions.Xunit).
- Add ProjectReference to Microsoft.Health.Extensions.Xunit in Azure.UnitTests (needed for the local SkippableFact attribute).
- Swap 'xunit.assert'/'xunit.extensibility.core' to their 'xunit.v3.*' equivalents in SchemaManager.UnitTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Swap 'xunit' PackageReference to 'xunit.v3.mtp-v2' and drop 'Xunit.SkippableFact' from the four *.Tests.Integration.csproj files (R4, R4B, R5, Stu3).

The xunit.runner.json copy metadata is handled in the shared .projitems (coming in a later commit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Swap 'xunit' PackageReference to 'xunit.v3.mtp-v2' and drop 'Xunit.SkippableFact' and 'System.IO.FileSystem.AccessControl' across all four *.Tests.E2E.csproj files (R4, R4B, R5, Stu3).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- TestFramework now uses typeof() syntax (v3 no longer takes typeName/assemblyName string pair).
- AssemblyFixture moves to Xunit namespace (fully qualified to disambiguate from Microsoft.Health.Extensions.Xunit).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xUnit v3 changes IAsyncLifetime.InitializeAsync/DisposeAsync from Task to ValueTask. Update all test fixtures and test classes that implement IAsyncLifetime, OnInitializedAsync, or OnDisposedAsync hooks accordingly. Also remove stale 'using Xunit.Abstractions;' where ITestOutputHelper moved to the Xunit namespace.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ojitems for MTP

xUnit v3 MTP runner requires xunit.runner.json to be copied to the test output directory. Add the Shared.Tests.Integration xunit.runner.json and register it (plus the E2E one) in the shared .projitems with Link + CopyToPublishDirectory=Never so each downstream test project picks it up. Add v3 $schema URL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xUnit v3 parallelizes more aggressively by default. CustomQueriesUnitTests mutates shared static state (CustomQueriesHash) so add a CollectionDefinition with DisableParallelization=true and attach the test class to it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Tests

ITestOutputHelper moved to the Xunit namespace in xUnit v3.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When using Microsoft Testing Platform (xUnit v3) the DotNetCoreCLI@2 'test' command no longer emits TRX automatically. Pass --report-trx, disable built-in test-result publishing, and add an explicit PublishTestResults@2 step. Keep failTaskOnFailedTests=true for the non-coverage path and keep it false for the coverage path until testfx#7167 is fixed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The MTP runner does not accept the legacy VSTest '--filter FullyQualifiedName~X' expressions. Switch to '--filter-query' with trait expressions (DataStore + Category) for E2E and export jobs, and use '--filter-not-class'/'--filter-not-trait' for integration-test exclusions. Also pass 'publishTestResults: false' on the integration-test DotNetCoreCLI@2 tasks so the separate PublishTestResults@2 step owns TRX publishing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MTP requires xunit.runner.json to be copied into each test project's output directory. Add a PowerShell task that asserts the file is present for every integration-test project prior to test execution, and prints its contents to the build log for diagnosis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove 'using Xunit.Abstractions;' (namespace removed in v3; ITestOutputHelper lives in Xunit)
- Add 'using System.Threading.Tasks;' where IAsyncLifetime now returns ValueTask
- Add 'using Xunit;' to files that lost the only Xunit reference via the Abstractions removal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mikaelweave Mikael Weaver (mikaelweave) changed the title Personal/mikaelw/xunit3 v2 [DRAFT[ Xunit2 -> Xunit3 Apr 17, 2026
@mikaelweave
Mikael Weaver (mikaelweave) marked this pull request as ready for review April 17, 2026 06:30
@mikaelweave
Mikael Weaver (mikaelweave) requested a review from a team as a code owner April 17, 2026 06:30
@mikaelweave Mikael Weaver (mikaelweave) changed the title [DRAFT[ Xunit2 -> Xunit3 [DRAFT] Xunit2 -> Xunit3 Apr 17, 2026
Comment on lines +170 to +186
foreach (var fixtureType in classFixtureTypes)
{
var constructor = fixtureType.GetConstructors()
.SingleOrDefault(ctor => !ctor.IsStatic && ctor.IsPublic);
if (constructor == null)
{
continue;
}

foreach (var parameter in constructor.GetParameters())
{
if (parameter.ParameterType.IsEnum)
{
fixtureParameterTypes.Add(parameter.ParameterType);
}
}
}
The CustomXunitTestFrameworkDiscoverer was creating FixtureArgumentSetTestCollection with disableParallelization hard-coded to false, ignoring the [CollectionDefinition(DisableParallelization = true)] attribute on test classes like ReindexJobTests. This caused long-running integration tests that share a single SQL fixture to run concurrently and hang on resource contention.

Now the discoverer reads CollectionDefinitionAttribute.DisableParallelization from the test class and propagates it to the test collection created for each fixture-argument variant.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
xunit v3 defaults parallelizeTestCollections to true. Integration tests share SQL fixtures and hang when collections run concurrently (ReindexJobTests). Match the E2E config by setting parallelizeTestCollections: false.

Reverts the speculative DisableParallelization plumbing in the custom discoverer (the custom discoverer creates one collection per method, so DisableParallelization on a single collection had no effect).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…xunit v2 behavior

In xUnit v2, all methods of a test class belong to a single test collection and
therefore run serially with a shared IClassFixture lifetime. The v3 rewrite of
CustomXunitTestFrameworkDiscoverer accidentally created a new
FixtureArgumentSetTestCollection for every (method, variant) combination, which
caused xUnit v3's scheduler to run methods of the same class in parallel. This
broke tests like ReindexJobTests which rely on per-class serialization and
shared fixture state, causing intermittent hangs on the SQL integration test
pipeline stages.

Cache collection/class instances per (class, variant) inside FindTestsForType
so methods of the same test class + variant share the same collection instance,
matching v2 semantics. Revert the over-broad parallelizeTestCollections=false
workaround from xunit.runner.json; cross-class parallelism is restored.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…llelism

In xunit v3 test collections are the unit of parallelism: methods within a
collection run serially, collections run in parallel. The previous discoverer
used SharedPerVariant as the default, which collapsed every class with the
same fixture argument variant (for example [FhirStorageTestsFixtureArgumentSets(DataStore.SqlServer)])
into one collection. All SqlServer integration tests across all classes then
ran serially, causing R4/R4B SQL test stages to exceed the 60 minute timeout.

Always scope the collection identity to the test class so each (class, variant)
pair is its own collection. This matches xunit v2 behavior: one IClassFixture
per class, methods within a class run serially, and classes run in parallel.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Linux unit-test 'Publish unit test results' task was the only publish
step still using failTaskOnFailedTests: true. MTP's --retry-failed-tests
records the original (pre-retry) failure in the trx, so a retried-and-passed
test still failed the publish step even though dotnet test succeeded.

Match the workaround used by every other PublishTestResults task in the
repo (coverage, e2e, sql, cosmos, export).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ute wrote it

The check named TraitAttribute exactly, but xUnit propagates any ITraitAttribute
from a collection definition to the classes that join it - TraitAttribute is
merely the built-in spelling of one. A custom trait attribute on a definition
would therefore tag its members while the guard saw nothing, which is the same
silent leg-reshaping the guard exists to catch, arriving by a slightly different
route. The discoverer this branch changes already reasons in terms of the
interface; this now agrees with it.

Verified by putting a custom ITraitAttribute on a definition another class joins:
under the old exact-type test the assembly checks all passed, and under this one
all three report the offender by name.

The scan above is left matching TraitAttribute exactly on purpose - it reads a
trait's name out of the first constructor argument, a shape only that attribute
guarantees - and a comment now says so.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Membership was read only from the non-generic [Collection] attribute, and only
when it carried a constructor argument. xUnit v3 adds [Collection<TDefinition>],
which names the definition in its generic argument and takes no constructor
argument at all - so it missed the guard twice over. On a branch whose whole
purpose is moving this repository to v3, the spelling people are about to start
writing was the one spelling that could reintroduce the incident unseen.

Ask the interface question instead, then read membership from whichever of the
three forms the class used: a name, a definition type, or a generic argument.

Verified with a definition another class joins as [Collection<TDefinition>]: the
assembly checks passed with the previous code and report the offender by name
with this one.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ayed

An intercepting bus replays whatever it still holds when it is disposed, so a
failure can never be lost by forgetting to report it. The cost is that silence
is not something a caller can express by omission: leaving a bus alone publishes
it anyway, a moment later.

Deciding which attempt wins was therefore only half the job. The empty-summary
path discarded the earlier attempt when the current one won, but not the current
attempt when the earlier one won; the crash path did the same, and its comment
said so - it expected the finally to replay the current attempt's messages, next
to the ones just published for the earlier attempt. When the current attempt was
holding an abstention, that is an ITestSkipped arriving immediately after an
ITestFailed for the same test, which is the reordering the skip-after-failure
path a few lines above exists to prevent. Disposal even announced it, logging an
internal error while replaying.

Both paths now go through one rule that publishes the winner and discards the
loser, so the two cannot drift apart again. Pinned with the real bus: under the
previous behaviour the test records ITestFailed followed by ITestSkipped, and
disposal is asserted to have nothing left to fall back on.

Reachable only if an exception escapes the runner after an abstention was held,
which xUnit normally converts into a failure - hence a unit-level pin rather than
a test asset.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Reading a declaration's attributes constructs all of them, so a single attribute
whose constructor throws costs that declaration every trait beside it - including
the Category a CI leg selects on. The attribute that throws need not have anything
to do with traits, which is what makes this easy to arrive at without meaning to.

The fallback now names the attribute types from metadata and constructs them one at
a time, so the sound ones survive the broken one. Reaching this path needs two
faults at once: a trait attribute that throws, which is what sends the read down the
fallback, and a second attribute that throws when constructed.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…f them

A declaration whose dimensions all name nothing was already rescued: the failure
standing in for its tests is reported once per value each dimension's type declares,
so a leg selecting positively on one of them still sees it. A declaration that names
nothing in only some of its dimensions has the same empty product and the same
missing tests, but was left carrying only the dimensions it did name - and a leg
selecting positively on a silent dimension, which is how every E2E and export leg
selects a data store, matched nothing and passed green and empty.

The dimensions a declaration leaves open are now widened individually. Where the open
position was meant to be inherited from the class, the class's own declaration is
closed over separately and still contributes its combinations, so the only cost is a
failure reported under a value the declaration did not name - loud, and traceable
back to it. The alternative is silence.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…from

The custom framework is what expands fixture argument sets into variants, and the
variants are what carry the DataStore trait every E2E and export leg selects
positively on. Losing the declaration throws nothing: the classes are discovered as
ordinary xUnit tests, carry no data store trait, and are filtered out. A filtered-out
class never constructs its fixture, so no connection is attempted and nothing errors -
the leg runs the handful of classes that declare traits by hand, reports success, and
some two thousand E2E tests are simply absent.

Also traits the E2E-only copy of the assembly checks, which compiled into the E2E
assemblies but was invisible to their filters, so a check added there would have run
nowhere. The copy shared with the integration assemblies stays untraited on purpose -
those legs select by excluding a store, and an exclusion drops a class that names both -
and now says so.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A leg that filters positively on DataStore reports success when its filter
matches nothing, so the count is the only thing separating everything
passed from nothing ran. Each leg now asserts a floor on what discovery
selects, well below its real count, before the run starts.

The floor deliberately does not ride on --minimum-expected-tests: that
option is applied again to the much smaller pass --retry-failed-tests
starts, so any floor above the size of a retry pass turns a flake that
recovered into a failed leg with exit 9. Measured on a probe: three
passing tests and one that fails only on its first run exit 9 under
--retry-failed-tests 3 --minimum-expected-tests 3.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Disposal replaying held messages is both the fail-safe for a caller that
forgot them and the ordinary way the last surviving result reaches the
bus, and it announced an internal error for both. Every run printed the
line, so nobody had reason to believe it the one time it meant something.
The deliberate handoff now says so, and the warning is left for the case
it was written for.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…es from

Three things that were only obvious from the outside: a failure arriving
after an abstention has been deferred deliberately never sets
LastFailureMessage, because the outcome that follows reports a failure
anyway and capturing it would publish two results for one test; the E2E
runner settings are not published, so the legs' parallel-none switch is
the only thing keeping suites that share a server off each other; and the
bus now writes diagnostics through an injectable sink so a test can read
them without redirecting the console, which is process-wide and would
have made those tests unsafe to run beside the rest.

The extensions harness also joins the R4 and R5 solution filters, so the
tests guarding this machinery are present in the solution a developer
actually opens.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A leg that filters on a DataStore trait can only select a class that
either carries that trait by hand or gets it from an argument-set
attribute the framework expands into per-store variants. A class with
neither is silently invisible: no leg selects it, every leg still exits
zero, and nothing says so.

Four E2E classes are in that state today, and were on main too, so this
adds a guard rather than a behaviour change. The guard fails on an
unexpected unreachable class and equally on an allow-list entry that has
since become reachable, so the list cannot quietly rot into a blanket
exemption.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The integration legs select by excluding the other store's trait. An
exclusion is the mirror hazard of a positive filter: a class that gains
a trait it did not have before drops out of the leg, and the leg still
passes. They had no floor, so add the same pre-run discovery count the
other legs use, with the identical filter arguments the run itself uses.

The E2E floor parameter loses its default. A default is the one value
that is certainly wrong for a leg whose author never considered the
question, and it would apply silently. All five call sites already pass
one.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
One claimed a deferral can only originate from an abstention; ordinary
deferred failures, the common retry path, set the same flag. The other
described retry selection as applying to transient failures, which reads
as a transience test that ShouldRetry does not perform.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Both scenarios need one collection to cancel the run while the other is
mid-attempt, and both arranged that by sleeping: the canceller slept, the
other slept longer, and the interleave was whatever the machine's speed
made it. On a loaded agent the cancellation lands after the other side's
window has closed, and the run then reports a failure about the scenario
rather than about the product. That is a test suite lying about the code,
which is the one thing this whole change exists to prevent.

Each side now waits to be told, so the order holds at any speed. The
waits are asynchronous because a blocking one holds a runner thread: with
a blocking wait the collection being waited for did not get a thread
until the waiter had already given up, which was measurably worse than
the sleeps it replaced.

The run-duration backstop moves from 20s to 60s. It only has to separate
a cancelled run from one that sat through three 30s retry delays, so 60s
still says exactly that while no longer failing a correct run that was
merely slow to start a child process.

Verified: the old timing fails deterministically when the canceller is
made 8s late, the new handshake passes under the same condition, and the
harness is green over eight consecutive runs including three with every
core saturated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Nine test assemblies declare an assembly fixture, name it in no constructor
anywhere, and depend entirely on what its constructor does: install the FHIR
model info provider. This change removed the local implementation that used
to guarantee that and now leans on the framework to do it. Nothing anywhere
asserted the guarantee, so if it ever stopped holding the provider would go
uninstalled and the failure would surface far from the cause.

A scenario now declares such a fixture the same way and a test reads whether
it was constructed before that test ran, which is precisely the property the
nine assemblies rely on.

Verified by removing the declaration: the scenario then fails, and passes
again once it is restored.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The retry extension every leg runs with treats an attempt that did not fail
as a recovery. A test that fails and then skips - Assert.Skip, or a
SkippableFact deciding at runtime that its environment is not there - is
therefore reported as recovered: the final TRX records NotExecuted, the
runner exits 0 and the leg is green. Reproduced here as a scenario: with a
second retried test present to keep the run non-empty, an attempt of
2 failed followed by 1 passed + 1 skipped prints "Passed!" and exits 0.

Nothing in the repository skips dynamically today, so this is a trap laid
for the next person rather than a live hole, and it is invisible at review
time because the skip and the failure are in different tests.

Every leg now reconciles the attempts it kept: Assert-RetriedFailuresPassed
reads the per-attempt TRX files the runner leaves under Retries/ and fails
the step unless every test that failed an attempt is Passed in the final
one. Results are keyed by (assembly, test name), because shared test files
are compiled into several assemblies and the names alone collide.

The rationale comment claiming the runner's exit code was the retry-aware
verdict was rewritten in all seven blocks: it is that only for tests that
end up failing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Dimensions are declared by position but bound to a fixture's constructor by
enum type, and xUnit's fixture cache is keyed by type as well, so two
dimensions of one type cannot be told apart. Before this change the second
was dropped: the method expanded to a single variant named for the whole
combination, so a declaration reading as four pairings ran once, with the
first value used for both arguments, and reported as though it had covered
all of them. That is a suite claiming coverage it does not have.

On origin/main the merge built its dimensions with Dictionary.Add, so this
threw. The rewrite made it a silent overwrite; this restores the refusal at
discovery, where it is reported per method as a failing stand-in under each
combination the method would have claimed, leaving its siblings alone.

Verified by removing the check: the scenario then expands to one variant
named "(Sql, Cosmos)" and the three new harness tests go red.

Both attributes in the repository - HttpIntegrationFixtureArgumentSets and
FhirStorageTestsFixtureArgumentSets - use distinct enum types and are
unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
One method carrying fixture argument sets exempts its whole class from the
check that every test class is selected by some leg, because that is enough
for the framework to expand the class. It expands only that method: a
sibling declaring nothing of its own, in a class declaring nothing of its
own, is discovered as an ordinary test case with no DataStore trait, and
every leg selects positively on that trait. So one decorated method can
hide any number of siblings that run nowhere, from the very check written
to find tests that run nowhere.

No class is in that state today. Verified by putting one there: dropping
the class-level attribute from BulkUpdateTests leaves its eleven
undecorated methods unreachable, the new check names all eleven, and the
class-level check stays green - which is the blind spot itself.

Only the E2E copy runs this. The integration legs filter by excluding a
store, and an exclusion still selects a test carrying no trait at all, so
the same scan there would report tests that do run.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The unit test leg names no projects: it runs whatever '**/*UnitTests/*.csproj'
matches. A project that leaves the glob - renamed, moved, or its directory
suffix changed - is not reported anywhere. The other nineteen run, all of
them pass, and the leg is green with an assembly's tests missing.

The floor the other legs use does not fit this shape. They count what a
filter discovers; here the loss is a whole project that was never run at
all, which no count of the tests that did run can see.
--minimum-expected-tests cannot either: it is applied per assembly, so it
says nothing about an absent one, and it has to stay at 1 because the
runner re-applies it to the smaller pass --retry-failed-tests starts.

Twenty projects match today. Adding more is fine; losing one fails the leg
before the tests run. Verified both ways against the working tree.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The three helpers that turn a no-result run into something the runner can
publish each fell through to doing nothing for an outcome they did not
recognise, while the summary beside them counted that same outcome as a
failure. The verdict comes from what is published, not from the summary, so
another member of the enum would have been counted as failed and reported as
nothing at all. Each helper now refuses an outcome it was not written for.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The check that every failed test passed on a later attempt keyed results by
their display name, and display names are not identities: a theory whose rows
differ only in the case of an argument produces two tests a case-insensitive
lookup cannot separate, and this repository already has one. A row that passed
then answered for a row that failed and skipped, which is the exact failure
this check was written to catch, and the leg went green.

Results are now keyed by the identifier the report gives each test, which the
retry attempts reuse, together with the assembly it came from. A result with no
identifier fails the leg rather than being dropped.

The script is covered by tests for the first time, running the real script over
reports shaped like the runner's own.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The leg's guard counted the projects its glob matched and failed when the count
dropped. A count only notices projects leaving while none arrive: renaming one
project and adding another in the same change leaves the count where it was,
and the renamed project stops being tested with nothing to show for it.

The expected projects are now listed by path and each one is checked, so a
project that leaves the glob is named whatever else changed alongside it.
Projects that are not listed still run and are reported, since adding tests
should not fail a leg.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The discovery floor that notices a leg selecting nothing repeated the store
filter that the test run used, so an edit to one copy would leave the floor
guarding a selection no longer run. Each leg now defines its filter once and
both steps read it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
An assertion over a collection says nothing when the collection is empty,
and both of these could be emptied by the very regression they exist to
catch: a fault reported as a test that ran, and options never read at all.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@mikaelweave Mikael Weaver (mikaelweave) changed the title [DRAFT] Xunit2 -> Xunit3 Xunit2 -> Xunit3 Migration Aug 24, 2026
@mikaelweave

Copy link
Copy Markdown
Contributor Author

Superseded by stack #5760, which contains this PR's changes split into three reviewable PRs:

# PR Contents Size
1 #5756 Shared collection definitions + unit test manifest guard. Inert on xUnit v2. 12 files, +221/-2
2 #5758 The core v2 -> v3 flip: Extensions.Xunit rewrite, CPM/props switch, ~109 test sources, Tests.Common guards, and all five build/jobs/*.yml. 166 files, +4521/-966
3 #5759 Extensions.Xunit.UnitTests harness + .TestAssets. 121 files, +7996/-0

The split is content-preserving, and that is verifiable. The tip of the stack (#5759 at 721924bc23f544933443c76bdaa5329b323b220d) has git tree hash d2b9abd5cf685da5bee86bbd0aef60e3a1a4a0e6, identical to this PR's head b7513423c5e40ab9dda4e338d66dea41a6c8cfda. An identical tree object hash is byte-for-byte equality, so the three PRs reconstruct exactly the tree reviewed here — nothing added, dropped, or altered in the carve.

Why three layers and not more

The migration cannot be split further along the obvious "mechanical syntax vs real change" line, because every mechanical edit is a v3 breaking-API change that will not compile on v2 (IAsyncLifetime Task->ValueTask, ITestOutputHelper moving namespace, the TestFramework attribute signature, SkippableFact). Three things force a single atomic flip:

  1. Directory.Packages.props removes the xUnit v2 package versions outright, and Central Package Management makes that global.
  2. Directory.Build.props applies the MTP references to every project matching Contains('Test').
  3. Microsoft.Health.Extensions.Xunit is referenced by every test project and can only bind one xUnit major.

The pipeline YAML rides with layer 2 for a related reason: main already runs xUnit v2 under Microsoft Testing Platform via the YTest.MTP.XUnit2 shim, which accepts VSTest-style --filter "FullyQualifiedName!~SqlServer". xUnit v3's MTP requires --filter-query '/[(DataStore=SqlServer)&(...)]' instead. Swapping the shim without flipping the YAML would leave every integration and E2E leg selecting nothing and reporting success.

Where to focus review

Layer 1 is small on purpose: it isolates the highest-value behavioural fix in the whole migration. On main, ReindexTests was itself the [CollectionDefinition] and carried a category trait. Under v3 a collection definition's traits propagate to every class in the collection, so CustomSearchParamTests inherited a category it never declared and a CI leg filtering that category out silently dropped it. Exclusion filters report success for what they never selected, so this class of bug is invisible in a green build.

Closing in favour of the stack. The branch personal/mikaelw/xunit3-v2 is left in place as the provenance record for the tree-hash equality above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Build No-ADR ADR not needed No-PaaS-breaking-change Open source This change is only relevant to the OSS code or release.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants