Testing concurrent and async C# code in xUnit: TaskCompletionSource, FakeTimeProvider, race tests
Tests are async Task with await (Q106).
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 } |