close

DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

xUnit 4 ParallelMode.All: Protect Shared State from Test Races

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)]
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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 (1)

Collapse
 
iqtechsolutions profile image
Ivan Rossouw

Process-wide configuration would be first on my audit list: environment variables, current directory, static clocks, and fixed ports can’t be namespaced inside one process as easily as rows or files. My rule is to give namespacable resources a per-test key, make genuinely shared compound state atomic, and opt out only for truly process-global resources. I’d also assert that each row can still read its own marker after the barrier—an atomic counter proves no update was lost, but not that logical state stopped leaking between tests. Have you seen ParallelMode.All expose more process-global leaks or database-fixture collisions so far?