Testing concurrent and async C# code in xUnit: TaskCompletionSource, FakeTimeProvider, race tests

C# Async & Multithreading November 13, 2026 csharp

Tests are async Task with await (Q106).

csharp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ======================================================================
// Q: How do you test concurrent and async code?
//
// - Tests are async Task with await (Q106). Never .Result inside a test.
// - Time: FakeTimeProvider instead of Task.Delay (timers, PeriodicTimer,
//   timeouts).
// - Controlling the order: with a TaskCompletionSource you "freeze" a fake at a
//   specific point and decide yourself when it completes, as shown in the code.
// - Race tests: run the same operation many times in parallel and check the
//   invariant. E.g. 100 concurrent reservations against a stock of 10 must give
//   exactly 10 successes. For database races: an integration test against a real
//   PostgreSQL (Testcontainers, Q109).
// - A test that "usually passes" doesn't prove there is no race. Correctness
//   comes from the design (atomic operations, constraints). Tests catch
//   regressions.
// In the demo project:
// - Channel<T> and IAsyncEnumerable:
//   Infrastructure/Messaging/InMemoryMessageBus.cs.
// - PeriodicTimer, try/catch inside the loop and FOR UPDATE SKIP LOCKED:
//   OutboxProcessor.cs.
// - Task.WhenAll: GET /api/products/MIC-
//   LED/availability?mode=parallel|sequential.
// - Interlocked: QueryCounter.cs and the fake ERP.
// - Streaming: GET /api/orders/export.
// ======================================================================

[Fact]
public async Task Concurrent_callers_share_a_single_token_refresh()
{
    var gate = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
    var calls = 0;
    var sut = new TokenProvider(fetch: _ => { Interlocked.Increment(ref calls); return gate.Task; });

    var first  = sut.GetAsync(CancellationToken.None);
    var second = sut.GetAsync(CancellationToken.None);    // starts while the first one is "hanging"
    gate.SetResult("token-1");

    Assert.Equal("token-1", await first);
    Assert.Equal("token-1", await second);
    Assert.Equal(1, calls);                               // the fetch happened only once
}