Introduction
Concurrent code is notoriously difficult to test. Race conditions appear intermittently, timing-dependent bugs pass on fast machines and fail on slow CI servers, and non-deterministic scheduling makes reproduction nearly impossible. This lesson covers strategies for writing reliable, deterministic tests for concurrent Ruby code.
Key Concepts
- Non-determinism: The property of concurrent code where the execution order varies between runs, making bugs appear and disappear unpredictably.
- Deterministic testing: Techniques that remove scheduling randomness so tests produce the same result every time.
- Synchronization point: A place in code where threads or fibers coordinate, allowing tests to control execution order.
- Stress testing: Running concurrent code under heavy load many times to surface rare race conditions.
Real World Context
A CI pipeline that passes 99% of the time but fails randomly on a concurrent test is worse than no test at all — it trains the team to ignore failures. Writing deterministic concurrent tests eliminates flaky builds and gives you genuine confidence that your thread-safe code actually works.
Deep Dive
The Fundamental Challenge
Consider a simple thread-safe counter. A naive test looks correct but is unreliable:
ruby# Flaky test — timing-dependent def test_counter_is_thread_safe counter = ThreadSafeCounter.new threads = 10.times.map do Thread.new { 1000.times { counter.increment } } end threads.each(&:join) assert_equal 10_000, counter.value # Sometimes passes, sometimes fails! end
This test might pass because the GVL serializes access on your fast development machine, but fail on CI where thread scheduling differs. The test does not actually verify thread safety — it verifies that the particular scheduling happened to be safe.
Strategy 1: Use Queue for Synchronization
Replace timing assumptions with explicit synchronization using Thread::Queue:
rubydef test_producer_consumer queue = Thread::Queue.new results = [] ready = Thread::Queue.new consumer = Thread.new do ready << :go # Signal that consumer is running while (item = queue.pop) != :done results << item * 2 end end ready.pop # Wait until consumer is ready queue << 1 queue << 2 queue << 3 queue << :done consumer.join assert_equal [2, 4, 6], results end
The ready queue eliminates the race between starting the consumer and pushing items. Every run produces the same result because the test controls the order of operations.
Strategy 2: Extract and Test the Logic Separately
Separate the concurrent orchestration from the business logic. Test the logic synchronously, then test the orchestration with minimal concurrent code:
ruby# Pure logic — easy to test class OrderProcessor def process(order) validate(order) calculate_total(order) apply_discount(order) end end # Concurrent wrapper — thin layer class ConcurrentOrderProcessor def initialize(pool_size:) @queue = Thread::Queue.new @processor = OrderProcessor.new @workers = pool_size.times.map { spawn_worker } end private def spawn_worker Thread.new do while (order = @queue.pop) != :stop @processor.process(order) end end end end
Now OrderProcessor is tested with fast, deterministic unit tests. The concurrent wrapper only needs a small integration test.
Strategy 3: Stress Testing for Race Conditions
When you need to verify thread safety, run many iterations to increase the probability of surfacing races:
rubydef test_thread_safety_under_stress 100.times do |trial| counter = ThreadSafeCounter.new threads = 20.times.map do Thread.new { 500.times { counter.increment } } end threads.each(&:join) assert_equal 10_000, counter.value, "Race condition detected on trial #{trial}" end end
Running the test 100 times with 20 threads each dramatically increases the chance of catching a race. If this test passes consistently, you have strong evidence (though not proof) of thread safety.
Strategy 4: Timeout Guards
Concurrent tests can hang forever if a deadlock occurs. Always add a timeout:
rubydef test_no_deadlock result = nil thread = Thread.new { result = potentially_deadlocking_operation } # Fail fast instead of hanging CI forever completed = thread.join(5) # 5-second timeout assert completed, "Operation deadlocked — thread did not finish in 5 seconds" assert_equal expected_value, result end
The join(timeout) variant returns nil if the thread does not finish within the specified seconds, letting the test fail with a clear message instead of hanging.
Common Pitfalls
- Using
sleepfor synchronization in tests —sleep(0.1)is a guess, not a guarantee. Use Queue, Mutex, or ConditionVariable to synchronize threads deterministically. - Testing thread safety with a single thread — A test that runs concurrent code on one thread proves nothing about thread safety. Always use multiple threads in your test.
- Ignoring flaky test failures — If a concurrent test fails even once, there is a real bug. Investigate immediately rather than re-running and hoping it passes.
Best Practices
- Keep concurrent tests isolated — Each test should create its own threads, queues, and shared state. Never share concurrent resources between test cases.
- Add timeouts to every concurrent test — A 5-10 second timeout catches deadlocks and prevents CI from hanging indefinitely.
- Run stress tests in CI — Configure your CI pipeline to run thread-safety stress tests with a higher iteration count than local development.
Summary
- Concurrent tests are unreliable when they depend on scheduling order or timing.
- Use
Thread::Queueas a synchronization primitive to make tests deterministic. - Separate business logic from concurrent orchestration to maximize testable surface area.
- Stress tests with many iterations and threads increase confidence in thread safety.
- Always add timeouts to prevent deadlocked tests from hanging CI.
Code Examples
# Deterministic concurrent test using Queue for synchronization
def test_worker_processes_all_items
input_queue = Thread::Queue.new
output_queue = Thread::Queue.new
worker = Thread.new do
while (item = input_queue.pop) != :done
output_queue << item.upcase
end
output_queue << :finished
end
# Feed items in a controlled order
input_queue << "alpha"
input_queue << "beta"
input_queue << "gamma"
input_queue << :done
# Collect results deterministically
results = []
while (result = output_queue.pop) != :finished
results << result
end
worker.join(5) # Timeout guard
assert_equal ["ALPHA", "BETA", "GAMMA"], results
end