Test Case Design and Edge Case Analysis Questions
Systematically deriving the cases, inputs, and conditions most likely to expose defects. Covers formal test-design techniques (equivalence partitioning, boundary value analysis, decision tables, state transitions, and pairwise/combinatorial design) and writing clear, maintainable test cases with documented expected results. Also covers the edge-case mindset: boundary conditions, invalid and unexpected inputs, corner cases, and the attention to detail that anticipates failures when validating complex behavior.
You own a service computing distributed quantiles for telemetry. Discuss edge cases that arise from floating-point rounding, extremely skewed distributions, and merging partial aggregates. Propose unit/property tests, tolerance margins, and strategies (e.g., use KLL vs t-digest) to ensure correctness across scale and input skew.
Sample Answer
Direct answer
Testing a distributed quantile-computation service needs to target three distinct edge-case sources: floating-point summation order changing the result when partial aggregates from different shards are merged in different orders, extreme skew in the input distribution making a single global tolerance margin wrong for both the largest and smallest magnitude buckets simultaneously, and the merge algorithm itself (KLL or t-digest, two different sketch data structures for approximate quantiles) having its own accuracy-vs-memory trade-off that the test tolerance has to be calibrated against, not assumed away. Both numeric findings below are from actual executed code.
Structured elaboration
Floating-point rounding in merges. IEEE-754 addition is not associative: (a + b) + c can differ from a + (b + c) in the last bits, which matters specifically because merging partial aggregates from different shards can happen in different orders depending on which shards report first, race conditions in a distributed merge, or a retry re-ordering inputs. A test suite needs to assert the merge is either provably order-INDEPENDENT (a design property, not just an observed one) or, if it is not, that the ORDER-DEPENDENT variance stays within an explicitly stated and tested tolerance, rather than silently assuming associativity holds.
Extremely skewed distributions. A quantile service handling both a very large-magnitude bucket (e.g. aggregate latency across a huge fleet) and a very small-magnitude one (e.g. a rarely-hit endpoint) in the same merge cannot use a single absolute epsilon sized for the large bucket, because that same absolute epsilon is enormous relative to the small bucket's own scale; the tolerance margin needs to be relative (proportional to the value's own magnitude), not a single global absolute number, exactly the finding demonstrated below.
Merging partial aggregates. The correctness property to test is not "the merged result exactly matches summing everything from scratch" (true for exact floating-point summation only under math.fsum-style compensated summation, not ordinary running sums), but "the merged result matches a REFERENCE exact computation within an explicitly stated tolerance," and that tolerance needs its own justification (derived from the sketch algorithm's documented error bound, or from the floating-point precision limit of the numeric type in use), not picked arbitrarily.
KLL vs t-digest. KLL (the Karnin-Lang-Liberty sketch, introduced by Karnin, Lang, and Liberty at FOCS 2016) gives a provable, distribution-independent error bound on quantile estimates for a fixed sketch size; t-digest gives good empirical accuracy, especially at extreme quantiles (very close to 0 or 1), but its error bound is not as uniformly provable across the whole distribution. The test-design implication: if the service's SLA (service-level agreement, a committed accuracy or latency guarantee) requires a provable worst-case bound regardless of input distribution shape, KLL's guarantee is the one to test against explicitly (generate adversarial distributions and confirm the bound holds, not just typical ones); if the service instead needs to be very accurate specifically at extreme quantiles like p99.9 (99.9th percentile) on typical traffic patterns, t-digest's empirical accuracy at the tails is the property worth the bulk of the test budget, since its worst-case guarantee is weaker.
Worked example (executed)
import math
huge = 1.0e16
small_values = [1.0] * 10 # ten unit weights, same shape as merging count-weighted centroids
sum_huge_first = huge
for v in small_values: sum_huge_first += v
sum_small_first = 0.0
for v in small_values: sum_small_first += v
sum_small_first += huge
print(sum_huge_first) # 1e+16
print(sum_small_first) # 1.000000000000001e+16 <- different from huge_first!
Actual output: merging huge-shard-first lost all 10 of the small weights to rounding (sum_huge_first == 1e+16 exactly, unchanged from huge alone), while merging small-shards-first preserved them (sum_small_first == 1.000000000000001e+16, matching math.fsum's exact ground truth). This is a genuine, reproducible non-associativity, not a hypothetical: merge order changed the result by exactly 10.0 units, confirming that a merge implementation using plain running sums is order-DEPENDENT and needs either compensated summation (Kahan/Neumaier-style, which math.fsum uses) or an explicit, tested tolerance for order-induced variance.
The skew case constructed two merges sharing the SAME absolute tolerance (abs_tol = 5.0, sized to be reasonable for a large-magnitude bucket) against a deliberately buggy merge that drops the smallest partial sum: for large_bucket_parts = [5000000.0007, 5000000.0009, 4.0], the buggy merge (drops the 4.0 part) produced an absolute error of 4.0, which passes the abs_tol=5.0 check (correctly, since 4.0 truly is small relative to a 10-million-scale bucket). For small_bucket_parts = [0.0007, 0.0009, 0.0004] (the SAME buggy logic, dropping the smallest of the three), the absolute error was 0.0004000000000000002, which ALSO passes the same abs_tol=5.0 check, despite being a 20.0% relative error against the correct total of 0.002. The identical bug (a dropped shard) is correctly caught as significant on the large bucket's own scale and incorrectly waved through as insignificant on the small bucket's scale, using one shared absolute tolerance, which is exactly why a quantile service spanning orders of magnitude needs a relative tolerance (or a tolerance explicitly scaled per-bucket-magnitude), not one global epsilon.
Trade-offs and pitfalls
The most common mistake is asserting exact equality between a merged result and a from-scratch recomputation, which will intermittently fail from ordinary floating-point non-associativity even when the merge logic is completely correct, teaching the team to distrust or disable the assertion rather than fixing the actual issue (the tolerance should be explicit and justified, not zero). The skew-tolerance finding above is the second, subtler pitfall: a single passing tolerance check across a whole test suite can hide a real bug specifically in the low-magnitude buckets, which is disproportionately dangerous for a quantile service because the SMALL-magnitude bucket is often the rare, high-value signal (e.g. a low-traffic but business-critical endpoint) that a uniform-tolerance test suite is least equipped to protect. A third pitfall is picking KLL or t-digest based on general reputation rather than which specific accuracy property (uniform worst-case bound vs strong tail accuracy) the service's actual SLA requires, since the wrong choice can pass every "typical" test case and only fail exactly where the SLA is measured.
Design robust alerting rules for a service metric (requests/sec) that sometimes has missing datapoints and occasional counter resets. Explain strategies to avoid flapping and false positives (e.g., evaluation windows, rate-of-change vs absolute thresholds, counter reset detection) and how to handle edge cases like sparse sampling or metrics ingestion delays.
Sample Answer
Direct answer
Robust alerting on requests-per-second needs to treat the metric as a monotonic counter's derived RATE, not a raw value: alert on rate-of-change with explicit counter-reset detection rather than an absolute threshold on the raw counter, use an evaluation window wide enough to smooth normal sampling jitter without hiding a real spike, and treat a missing datapoint as "no data available for this interval," never as "zero traffic." From a test-design standpoint, each of these is a distinct EDGE CASE of the metric's time series that needs its own test input, not just a design principle stated in the abstract, and the executed example below constructs exactly those edge cases and checks the alerting logic against them.
Structured elaboration
Rate-of-change vs absolute thresholds. An absolute threshold on a raw counter is wrong on two counts: it eventually fires on any long-lived healthy counter simply because the counter keeps climbing regardless of actual traffic rate, and it fails to fire on a genuine spike immediately after a counter reset, since the raw value is small again even though the RATE just jumped. A rate-of-change check (delta between consecutive samples, normalized to a per-minute or per-second rate) measures what actually matters, request rate, independent of how long the counter has been accumulating.
Counter-reset detection. A monotonic counter resets to zero whenever the process that owns it restarts (a deploy, a crash-restart, a scale-down/scale-up cycle). A naive delta calculation (current - previous) produces a large NEGATIVE number across a reset, which if not explicitly detected and handled, either falsely alerts on a huge negative "drop" or, worse, gets squared/absolute-valued somewhere downstream into a false spike alert. The fix is explicit: whenever the current value is smaller than the previous value, treat the CURRENT value itself as the new interval's contribution (the counter's own climb since restarting), not current - previous.
Avoiding flapping. An evaluation window that requires the rate condition to hold across multiple consecutive samples (not a single sample crossing the threshold) absorbs ordinary sampling jitter without requiring a much higher, less sensitive threshold; the trade-off is detection latency, a wider window is slower to alert on a real event, so the window size is itself a parameter that should be chosen from the actual noise characteristics of the specific metric, not copied from an unrelated metric's alerting config.
Sparse sampling and ingestion delay. A missing datapoint (a scrape failure, a delayed ingestion pipeline) must never be silently treated as a zero, since a rate calculation across a real gap (current - previous divided by an assumed one-interval time delta, when the actual elapsed time was several intervals) either fabricates a rate that's too low (if the gap is treated as normal time) or too high (if the delta is computed correctly but the false zero corrupts the previous value). The correct handling explicitly skips delta computation across any interval containing a missing sample and resumes cleanly once real data returns, rather than inferring a rate through the gap.
Worked example (executed)
def rate_of_change_alert(samples, rate_threshold):
fired, prev = [], None
for i, v in enumerate(samples):
if v is None:
prev = None # missing datapoint: don't compute a delta across the gap
continue
if prev is None:
prev = v
continue
delta = v - prev if v >= prev else v # reset detection
if delta > rate_threshold:
fired.append(i)
prev = v
return fired
# one sample per minute; None = missing datapoint (scrape failure)
samples = [1000, 1050, 1100, None, 1160, 5, 55, 2000]
# index: 0 1 2 3 4 5 6 7
# index 5 is a process restart (counter dropped 1160 -> 5): true new traffic = 5/min
# index 7 is a genuine spike: true new traffic = 2000 - 55 = 1945/min
print(rate_of_change_alert(samples, rate_threshold=500))
Actual output: [7]. The alert correctly fires only at index 7 (delta 1945, a genuine spike) and correctly does NOT fire at index 5, where the raw counter dropped from 1160 to 5; that drop is treated as a reset, contributing a delta of 5 (the counter's own climb since restarting), not a nonsensical -1155/min. It also correctly skips index 3 (the missing datapoint), never computing a delta across that gap, and resumes cleanly at index 4. For comparison, a naive absolute-threshold check on the same series (fire whenever the raw value exceeds 1500) also only fires at index 7 in this particular series, but for the wrong reason, because the raw counter happened to be large again at that point, not because it measured the actual rate; that check would ALSO eventually fire on any long-lived healthy counter purely from accumulation, and would MISS a real spike whose post-reset raw value stayed below the absolute threshold even while its rate was extreme.
Trade-offs and pitfalls
The most common mistake is choosing a single global evaluation window and rate threshold for every service's requests-per-second metric, when the right window and threshold depend on that specific metric's normal sampling noise and traffic pattern; copying a working config from one service to another is a frequent, quiet source of both false positives and missed real events. A second pitfall is detecting resets by checking for a value near exactly zero rather than any decrease, which misses a "partial reset" (a counter that restarts at a nonzero value due to some persisted state, or a metrics pipeline that occasionally re-delivers an older, smaller value out of order), so the correct test is "did the value decrease relative to the previous sample," not "did it hit exactly zero." A third pitfall, specific to the missing-datapoint case, is a monitoring pipeline that backfills a missing sample with the last-known value before the alerting logic ever sees it; that silently converts a real data gap into a fabricated flat rate, which passes every rate-of-change test built against the raw samples while actually hiding the gap from the alerting logic entirely, so the edge-case test needs to run against what the alerting logic ACTUALLY receives after any upstream backfill, not against the idealized raw time series.
You need to design a service endpoint that computes the median of a stream of integers submitted by clients and returns the current median on request. Enumerate edge cases (empty stream, even vs odd counts, duplicates, negative numbers, very large streams, concurrent updates, late-arriving data). For each edge case propose unit/integration tests and strategies for scaling and testing performance under realistic loads.
Sample Answer
Direct answer
A streaming-median service needs its correctness edge cases (empty stream, even versus odd counts, duplicates, negative numbers) verified deterministically against an independent full-sort oracle, and its concurrency/scale edge cases (concurrent updates, late-arriving data, very large streams) verified through a different kind of test: not "does it produce the exact right number" in isolation, but "does the chosen synchronization strategy preserve the single-writer correctness guarantees under contention." Conflating these two test types, treating a concurrency test as if it were just a bigger correctness test, is the most common design mistake here.
Structured elaboration
A two-heap design (a max-heap for the lower half of values seen so far, a min-heap for the upper half, rebalanced after every insert so their sizes differ by at most one) gives O(log n) insert and O(1) median read, and is the natural implementation to test against.
Deterministic correctness edge cases (each independently verifiable against a full-sort oracle on the same input):
- Empty stream: median must be explicitly undefined (raise a clear error), never a fabricated
0or an unhandled index error into an empty heap. - Even vs odd counts: odd count reads from the larger heap's root alone; even count averages both heaps' roots. These are genuinely different code paths and both need their own test.
- Duplicates: repeated values must route through the same heap-balancing logic as distinct values, since a naive implementation using a set or a dedup step upstream would silently corrupt the count.
- Negative numbers: the max-heap-of-negated-values trick (many two-heap implementations negate values to simulate a max-heap using a min-heap library) is a common place for a sign bug to hide, since negative input values interacting with an already-negated internal representation is exactly the kind of double-negative bug that only shows up when tested explicitly, not when only positive test values are used.
- Very large streams: correctness at scale is not a NEW property, it is the same correctness property checked at more points; the test design is to spot-check the running median against the full-sort oracle at multiple points DURING a large stream (not just at the end), since a bug that only manifests after the heaps have rebalanced many times could be masked by checking only the final state.
Concurrency and late-arriving-data edge cases (design discussion, not independently executable in a single-threaded correctness harness):
- Concurrent updates: if multiple clients submit values simultaneously, the two-heap structure needs an explicit locking or a single-writer-with-queue strategy, since the rebalancing step (moving an element between heaps) is not atomic across two heap operations; the test for this is not "does the median stay correct under load" measured by wall-clock race conditions (nondeterministic and environment-dependent), it is a deterministic test that SERIALIZES a specific interleaving of two logically-concurrent operations and asserts the result matches ONE of the valid serialized orderings, proving the locking strategy actually prevents a torn read/write rather than merely making races rare.
- Late-arriving data: a value that should have been included in an earlier median snapshot arrives after that snapshot was already served to a client. The system needs an explicit policy (recompute and republish a corrected median, or accept that historical snapshots are immutable once served) and the test is for the CHOSEN policy's actual behavior, e.g. asserting a late value updates the current running median correctly, and, if immutability is the policy, asserting a previously-served median value is never silently mutated in place.
Unit versus integration tests, and a scaling/performance strategy that does not rely on wall-clock numbers. Each deterministic correctness case (empty, odd/even counts, duplicates, negatives, large-stream) belongs at the UNIT level: instantiate the StreamingMedian class directly, feed it a fixed sequence, assert against the oracle, no network or endpoint involved. The concurrency, late-arrival, and endpoint-level behaviors belong at the INTEGRATION level: exercise the actual service endpoint (HTTP submit-and-query, or whatever the transport is) with multiple simulated clients and assert on OBSERVABLE endpoint behavior (correct median served, no error responses, no lost submissions), not on internal heap state the endpoint doesn't expose. For scaling specifically, the durable test strategy is a complexity argument backed by a measurement of GROWTH RATE, not an absolute number: submit increasing stream sizes (e.g. 10x steps) to the integration-level endpoint and assert the per-submission latency stays within a small constant multiple of its value at the smallest size tested, which is a scale-invariant assertion (consistent with the O(log n) per-insert cost of the two-heap structure) instead of a fixed millisecond target that would be meaningless across different test environments.
Worked example (executed, deterministic correctness suite)
import heapq, random
class StreamingMedian:
def __init__(self):
self.lo, self.hi = [], [] # lo: max-heap via negation, hi: min-heap
def add(self, x):
if not self.lo or x <= -self.lo[0]: heapq.heappush(self.lo, -x)
else: heapq.heappush(self.hi, x)
if len(self.lo) > len(self.hi) + 1: heapq.heappush(self.hi, -heapq.heappop(self.lo))
elif len(self.hi) > len(self.lo): heapq.heappush(self.lo, -heapq.heappop(self.hi))
def median(self):
if not self.lo and not self.hi: raise ValueError("median of an empty stream is undefined")
return float(-self.lo[0]) if len(self.lo) > len(self.hi) else (-self.lo[0] + self.hi[0]) / 2.0
def oracle(values):
s = sorted(values); n = len(s)
if n == 0: raise ValueError("empty")
mid = n // 2
return float(s[mid]) if n % 2 else (s[mid-1] + s[mid]) / 2.0
sm = StreamingMedian()
try: sm.median()
except ValueError: print("empty raises: PASS")
for x in [5, 1, 9]: sm.add(x)
print(sm.median(), oracle([5,1,9])) # odd: 5.0 5.0
sm2 = StreamingMedian()
for x in [5, 1, 9, 3]: sm2.add(x)
print(sm2.median(), oracle([5,1,9,3])) # even: 4.0 4.0
sm3 = StreamingMedian()
for x in [-10, 5, -3, 0, 8, -1]: sm3.add(x)
print(sm3.median(), oracle([-10,5,-3,0,8,-1])) # negatives: -0.5 -0.5
random.seed(2026)
data = [random.randint(-1_000_000, 1_000_000) for _ in range(20000)]
sm4, seen, mismatches = StreamingMedian(), [], 0
for i, x in enumerate(data, 1):
sm4.add(x); seen.append(x)
if i in {1,2,3,6666,10000,19999,20000}:
if sm4.median() != oracle(seen): mismatches += 1
print(mismatches) # -> 0
Executed output: the empty-stream case raises as expected; the odd-count case gives 5.0 matching the oracle's 5.0; the even-count case gives 4.0 matching 4.0; the negative-number mix gives -0.5 matching the oracle's -0.5; the 20,000-element run spot-checked at seven points against the full-sort oracle shows 0 mismatches.
Trade-offs & pitfalls
The most common mistake is treating "runs correctly on a large random stream" as evidence the concurrency story is fine too; a single-threaded correctness test, however large, says nothing about a race condition in the rebalancing step, which only a deliberately-interleaved, deterministic concurrency test (or a formal reasoning argument about the locking strategy) can address. A second pitfall is asserting a wall-clock throughput number ("handles 10,000 updates per second") as a pass/fail bar; that is an environment-dependent claim and does not belong in a test suite, the durable test is a complexity argument (O(log n) per insert) plus a correctness check at scale, not a timing assertion. Third, the negative-heap-via-negation implementation trick is exactly where an off-by-one or sign error hides most easily, a test suite that only uses positive integers will never exercise that code path at all.
Write unit tests (in pseudocode or your language of choice) that cover the following edge cases for a function that finds the kth smallest element: empty input, single element, duplicates, k out of range, k equals length, and large inputs. For each test describe the expected result and why it catches a common bug class.
Sample Answer
Direct answer
A kth-smallest/largest function needs six edge-case tests beyond the normal case: empty input, a single element, duplicate values, k out of range (too low or too high), k equal to the length (asking for the max/min of the whole list), and a large input to confirm both correctness and reasonable performance at scale.
Structured elaboration and worked example (executed)
def kth_smallest(nums, k):
if not nums:
raise ValueError("empty input")
if k < 1 or k > len(nums):
raise IndexError("k out of range")
return sorted(nums)[k-1]
def test_empty():
try:
kth_smallest([], 1)
assert False, "should have raised"
except ValueError:
pass # catches: calling the function on an empty collection at all
def test_single():
assert kth_smallest([7], 1) == 7 # catches: off-by-one in index math when len==1
def test_duplicates():
assert kth_smallest([5,3,5,1,5], 3) == 5 # sorted [1,3,5,5,5]; catches: dedup logic that silently drops repeats
def test_k_out_of_range():
for bad_k in (0, 5):
try:
kth_smallest([1,2,3], bad_k)
assert False
except IndexError:
pass # catches: missing bounds check, which would otherwise raise a confusing IndexError from sorted[k-1] itself
def test_k_equals_length():
assert kth_smallest([4,1,3], 3) == 4 # catches: an off-by-one that treats len(nums) as out of range
def test_large_input():
import random
random.seed(42)
nums = [random.randint(-10**6, 10**6) for _ in range(50000)]
k = 25000
assert kth_smallest(nums, k) == sorted(nums)[k-1] # catches: correctness diverging from a full-sort baseline at scale
test_empty()
test_single()
test_duplicates()
test_k_out_of_range()
test_k_equals_length()
test_large_input()
print("ALL kth_smallest TESTS PASSED")
Running this script produces: ALL kth_smallest TESTS PASSED.
Why each test catches a specific bug class
- Empty input: catches implementations that call
sorted([])[k-1]and get an unhelpful IndexError instead of a clear, documented failure. - Single element: the smallest interesting off-by-one target;
k-1indexing is exactly where a 0-based/1-based confusion shows up. - Duplicates: a common bug is treating the problem as "kth DISTINCT value" by accident (e.g. deduplicating before sorting), which silently changes the answer whenever duplicates exist.
- k out of range: without an explicit bounds check, Python's own list indexing will still raise something, but the exception type/message becomes an implementation accident rather than a documented contract, which breaks callers who catch a specific exception type.
- k equals length: the boundary between "valid" and "just past valid"; an implementation using
k > len(nums) - 1instead ofk > len(nums)would incorrectly reject this case. - Large input: a quickselect-style O(n) implementation (as opposed to full-sort O(n log n)) is the realistic optimization for this problem; testing it against a full-sort baseline at scale is the only way to catch a partition-logic bug that happens to work on tiny hand-picked inputs but misbehaves on larger, more varied data.
The kth-LARGEST variant is the same test list with the selection direction reversed (sorted(nums)[-k] or an equivalent), and needs its own explicit 10-item edge-case pass covering the same six categories plus negative numbers, all-identical elements, an already-sorted-ascending input, an already-sorted-descending input, and k=1 (the single maximum) specifically, since a max-heap-based implementation can have a distinct off-by-one from a min-heap-based one even though the two problems look symmetric.
Trade-offs & pitfalls
A fast implementation (quickselect, or a heap of size k) is more error-prone at the boundaries than the simple sort-and-index version shown here, precisely because it manages partial state (a partition point, or a heap that must stay exactly size k); the sort-and-index version is the right "oracle" to test a faster implementation against on randomized large inputs, exactly as the large-input test above does.
How would you test for race conditions in a shared in-memory counter used by multiple threads in a service? Describe deterministic unit test approaches, integration/stress tests, tools to control thread scheduling, and how to make a failing case reproducible for regression tests.
Sample Answer
Direct answer
Testing for race conditions needs three complementary approaches: a deterministic unit test that reasons about the shared-state operation directly (read-modify-write is not atomic unless explicitly protected), a stress test that runs many threads concurrently to make lost updates observable, and a technique to WIDEN the race window (an injected delay, or a thread-scheduling-control tool) so the failure reproduces reliably rather than only occasionally.
Structured elaboration and worked example (executed; the original draft's class/method bodies were flattened to a single indent level and did not parse at all, threading.Lock was never instantiated with (), and t.start/t.join/counter.increment were referenced as bound methods but never called, so after fixing indentation the original logic would have started zero threads and done zero increments; all fixed below and re-executed for real)
import threading, time
class UnsafeCounter:
def __init__(self):
self.value = 0
def increment(self):
current = self.value
time.sleep(0) # yields control, widening the race window deterministically for the test
current += 1
self.value = current
class SafeCounter:
def __init__(self):
self.value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
current = self.value
time.sleep(0)
current += 1
self.value = current
def run_concurrent_increments(counter, n_threads=8, increments_per_thread=200):
def worker():
for _ in range(increments_per_thread):
counter.increment()
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
for t in threads:
t.start()
for t in threads:
t.join()
return counter.value
Actually running both counters three times and printing the results
expected = 8 * 200
for i in range(1, 4):
unsafe_result = run_concurrent_increments(UnsafeCounter(), 8, 200)
safe_result = run_concurrent_increments(SafeCounter(), 8, 200)
lost = expected - unsafe_result
print(f"Run {i}: Unsafe counter result: {unsafe_result} expected: {expected} lost updates: {lost} | Safe (locked) counter result: {safe_result} expected: {expected}")
Running this with expected = 8 * 200 = 1600, repeated three times to characterize the real variance:
Run 1: Unsafe counter result: 289 expected: 1600 lost updates: 1311 | Safe (locked) counter result: 1600 expected: 1600
Run 2: Unsafe counter result: 224 expected: 1600 lost updates: 1376 | Safe (locked) counter result: 1600 expected: 1600
Run 3: Unsafe counter result: 251 expected: 1600 lost updates: 1349 | Safe (locked) counter result: 1600 expected: 1600
The unsafe counter lost over a thousand of the 1,600 increments on every one of three repeated runs, achieved specifically because of the time.sleep(0) injected between the read and the write; the EXACT loss count is itself nondeterministic (289 vs. 224 vs. 251 across three runs, since it depends on the OS scheduler's precise interleaving), which is the honest, important nuance a single hardcoded number would hide: the race's existence and severity are reliably reproducible, but the precise lost-update count is not, so a regression test should assert the SHAPE of the result (safe counter always exactly 1600; unsafe counter reliably and substantially below 1600, e.g. < 1550) rather than an exact lost-update figure. Without the injected yield point, this exact race is genuinely non-deterministic in a first attempt (a first run without the sleep can show 0 lost updates purely by luck of scheduling), which is the single most important practical lesson: a race-condition test that doesn't force the interleaving is not a reliable regression test, it is a coin flip.
Techniques to control thread scheduling for reproducibility
- Injected yield points (as above): the cheapest, most portable technique; insert a
sleep(0)or an explicitthreading.Eventwait at the exact point the race window exists, which is only possible if you already suspect where the race lives (this makes it a great REGRESSION test once a race is found and fixed, but a weaker DISCOVERY tool for an unknown race). - High iteration count under real contention: run many threads doing many operations without any injected delay, relying on statistical likelihood; cheap to set up but non-deterministic, and a passing run proves nothing (absence of evidence, not evidence of absence).
- Dedicated concurrency-testing tools (e.g. a deterministic scheduler, or a tool that systematically explores thread interleavings): the most rigorous option for genuinely unknown races, at the cost of tooling complexity and often language/runtime-specific support.
Making a failing case reproducible for regression
Once a race is found (as above), the injected-yield-point version of the test becomes the PERMANENT regression test: it deterministically reproduces the failure against the buggy implementation and deterministically passes against the fixed (locked) implementation, every run, which is exactly the property a flaky, timing-dependent test lacks.
Trade-offs & pitfalls
The injected-delay technique only reproduces races you already suspect existed at a specific point in the code; it cannot discover an UNKNOWN race elsewhere in the same class, which is why it complements, rather than replaces, high-contention stress testing as a broader discovery net. The same technique applies equally to a Redis-backed or relational-DB-backed counter, or a shared in-memory cache read/write, by injecting the delay at the equivalent read-then-write gap (e.g. a GET followed by a SET without an atomic INCR, or a SELECT followed by an UPDATE without row-level locking).
Unlock Full Question Bank
Get access to all 35 Test Case Design and Edge Case Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.