xUnit 4.0.0 makes full test-case parallelization an explicit option. That is useful, but xUnit 4 ParallelMode.All changes a quiet assumption in many suites: tests in the same class, including separate rows of one theory, may now overlap. A static fake, shared fixture, temporary file, or database record that was safe under collection-level parallelism can become a race.
I treat this as an isolation change, not a speed switch. Before enabling it across a suite, I want a deterministic failure that proves the risk and a deterministic check for each guardrail.
What xUnit 4 ParallelMode.All changes
The xUnit.net v3 4.0.0 release notes describe full test-case parallelization as a new feature. The default is still ParallelMode.Collections, so upgrading does not silently enable the broader mode. I have to opt in at the assembly level:
using Xunit.Sdk;
using Xunit.v3;
[assembly: Parallelization(
Mode = ParallelMode.All,
MaxThreads = 2,
Algorithm = ParallelAlgorithm.Conservative)]
With Collections, tests within a collection are serialized. With All, every test case is eligible to run beside every other test case. That includes two cases from the same class and two pre-enumerated rows from the same theory. The official parallel test execution guide documents the modes, algorithms, and available opt-out scopes.
I set MaxThreads = 2 in the sample so the scheduling condition is easy to inspect. It is a demonstration setting, not a recommendation for CI. The right value depends on available CPU, memory, and the external systems touched by the tests.
Before changing the mode, I scan for mutable static fields, IClassFixture and ICollectionFixture implementations, fixed file names, environment-variable changes, test servers bound to fixed ports, and records addressed by shared IDs. I also check theory data sources for objects that rows can mutate. That inventory tells me whether the resource should become concurrency-safe, receive a unique per-test identity, or stay behind an explicit opt-out. It is much easier to make that choice before a broad CI failure mixes several races together.
Reproduce a lost update without a stopwatch
A timing-only test built around Task.Delay can pass for the wrong reason. I prefer coordination primitives that force the interleaving I need to observe.
The unsafe sample runs two theory rows against one static counter. Both rows read before either can write, and both finish writing before either asserts:
private static readonly Barrier Gate = new(participantCount: 2);
private static int counter;
[Theory]
[InlineData("first")]
[InlineData("second")]
public void Shared_counter_loses_an_update(string worker)
{
Assert.NotEmpty(worker);
var observed = Volatile.Read(ref counter);
Assert.True(Gate.SignalAndWait(TimeSpan.FromSeconds(10)));
Volatile.Write(ref counter, observed + 1);
Assert.True(Gate.SignalAndWait(TimeSpan.FromSeconds(10)));
Assert.Equal(2, Volatile.Read(ref counter));
}
Both rows observe 0, both write 1, and both assertions fail. The barriers make the lost update repeatable; a busy machine does not have to get lucky with scheduling. A timeout keeps a broken configuration from hanging forever.
This test is intentionally red. I keep it in a separate project so the failure is evidence produced by the verifier, not a permanently failing test in the guarded suite.
Keep parallel tests safe and opt out narrowly
If shared state is meant to support concurrent access, I make the operation atomic:
Interlocked.Increment(ref safeCounter);
That is appropriate for a counter, but a lock or concurrent collection may be a better fit for a compound invariant. Thread safety also does not provide test isolation by itself. Two tests can update a data structure correctly and still observe each other's logical data.
When a test owns an exclusive resource, xUnit 4 provides targeted opt-outs. A theory whose rows must not overlap can declare:
[Theory(DisableParallelization = true)]
[InlineData("first")]
[InlineData("second")]
public void Shared_state_cases_opt_out_of_parallelism(string worker)
{
// Use the exclusive resource.
}
I do not want the verifier to trust that attribute merely because the two rows happened to run one after another. The sample pull request includes a control configuration that compiles the same method without DisableParallelization. Two barriers force both control rows to attempt a one-at-a-time lease before either can release it. Exactly one row fails. The normal Release build restores the opt-out, and both rows pass.
The sample pins xunit.v3.mtp-v2 at 4.0.0 and selects Microsoft Testing Platform in global.json. The official Microsoft Testing Platform setup guide covers that runner integration. After restore, the validation uses no credentials or remote services.
When I would not enable full parallelization
I would keep the default while a suite relies heavily on shared databases, fixed ports, process-wide environment variables, static mocks, or reused file paths. Opting out every other test adds complexity without much concurrency benefit. First removing those hidden dependencies usually produces a clearer suite.
I also would not use this sample as a performance claim. Its barriers intentionally coordinate two cases; they do not measure throughput. Full parallelization can increase contention, memory use, and pressure on dependencies, so I would compare representative CI runs after correctness checks are in place.
Finally, runner options can override assembly configuration. I would verify the effective CI command, audit collection fixtures and theory data sources, and choose the narrowest documented opt-out that matches the lifetime of the protected resource.
Which shared resource would you audit first before enabling ParallelMode.All?
Happy testing!
Top comments (0)