Course contentsShow
← C#
Lesson 1673 of 1,90815. Software Quality and TestingFree lesson

Testing Multi-threaded Code

Apply synchronization primitives to test concurrent logic without causing race conditions.

Testing Multi-threaded Code

In this lesson, you will learn how to stabilize your integration tests by using synchronization primitives to ensure your multi-threaded code behaves predictably under observation.

When you test logic that involves concurrent operations—such as background workers or parallel processing tasks—the greatest enemy is the "race condition." If your test asserts a result before the background thread has finished executing, you’ll end up with flaky tests that pass or fail randomly depending on CPU scheduling.

Using Synchronization Primitives for Deterministic Tests

The goal is to force your test to wait for the system under test (SUT) to reach a "quiescent" state before performing assertions. Instead of relying on Task.Delay (which is fragile and slows down your suite), use synchronization primitives like SemaphoreSlim or TaskCompletionSource.

Imagine you are testing a service that updates a shared state in a background task. You can inject a synchronization object into the service to signal the test when the work is complete:

// Inside your test
var signal = new TaskCompletionSource<bool>();
var service = new BackgroundProcessor(onComplete: () => signal.SetResult(true));

// Act
service.StartTask();

// Wait for the signal with a timeout to prevent infinite hanging
var completed = await Task.WhenAny(signal.Task, Task.Delay(5000)) == signal.Task;

// Assert
Assert.True(completed, "The background task failed to signal completion.");
Assert.Equal(expectedState, service.Result);

By passing a callback or a signaling mechanism into your code, you create a controlled "hook" that allows the test to orchestrate execution flow. If you are testing legacy code where injection is difficult, you might use a CountdownEvent or ManualResetEventSlim to block the test thread until the background thread reaches a specific checkpoint.

Always ensure your synchronization primitives have a timeout. A test that hangs forever is a productivity killer; it is better for a test to fail with a clear "Timed out waiting for thread" message than to leave your CI/CD pipeline suspended.

Key Takeaway: Use explicit synchronization primitives to signal test completion, turning non-deterministic timing issues into reliable, observable synchronization points.