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.
Summarize fuzz testing: define dumb, grammar-based and coverage-guided fuzzers, and describe when SREs should use fuzzing (e.g., parsing, protocol handlers, public-facing APIs). Give one concrete example of a service component you'd fuzz and the kinds of edge cases fuzzing would reveal.
Sample Answer
Direct answer
Fuzzers sit on a spectrum from mutating random bytes and watching what breaks, to using runtime coverage feedback to steer toward unexplored code paths, and which one is worth reaching for depends on how deep into the input-handling logic you need to get before interesting behavior even starts: dumb fuzzing for shallow, structurally simple targets, coverage-guided fuzzing for anything with real parsing depth.
Structured elaboration
| Fuzzer type | Definition | Strength | Weakness |
|---|---|---|---|
| Dumb (mutation-only, "blind") | Takes a valid seed input and applies random byte-level mutations, bit flips, byte insertion/deletion, splicing two seeds, with no understanding of input structure or program internals | Trivial to set up, needs no grammar or instrumentation, finds shallow crashes fast | Almost never gets past an early structural gate, a magic-byte header, a checksum, a strict schema validator, because a random mutation is overwhelmingly likely to fail that check and never reach the logic behind it |
| Grammar-based | Generates inputs from an explicit grammar or schema describing the valid input format, so every generated input is structurally valid but semantically varied | Gets past structural gatekeeping by construction, reaching deep application logic a dumb fuzzer never would; well suited to structured protocols and APIs | Only as good as the grammar; a bug in a case the grammar author did not anticipate, or a genuinely malformed-but-still-parsed input, is invisible to it by construction |
| Coverage-guided | Instruments the target so each execution reports which code branches it exercised, then mutates inputs toward previously unexplored coverage, effectively learning the input's structure empirically | Combines the low setup cost of mutation-based fuzzing with much greater depth, since it self-discovers what structural elements unlock new code, current state of the art for deep parser and protocol-handler bugs without hand-authoring a grammar | Needs the target to be instrumentable and fast to execute, thousands of iterations per second, which rules out fuzzing a live network endpoint directly, the parsing function has to be fuzzed in process |
When SREs should use fuzzing: parsing code, any function turning untrusted bytes or strings into structured data, config parsers, log-line parsers, wire-protocol decoders; protocol handlers, anything implementing a network protocol's framing or state machine where malformed or adversarial peers are a realistic threat, not a hypothetical; and public-facing APIs, the boundary where fully untrusted input first enters the system and therefore has the highest ratio of input the developer never explicitly considered.
Concrete example: a service's structured-log ingestion endpoint, which parses client-submitted JSON log lines before writing them into the log pipeline. Fuzzing this component (coverage-guided, in process, against the parsing function directly rather than over the network) would plausibly reveal: deeply nested JSON that exhausts the parser's recursion limit or stack; duplicate keys with conflicting types, does the parser take the first, the last, or raise; extremely long string values that were never length-validated before being written downstream; numeric fields with values outside the target type's range; and malformed UTF-8 (Unicode Transformation Format, 8-bit) byte sequences that a naive character-length check counts differently than a byte-length check.
Trade-offs and pitfalls
- Treating fuzzing as a replacement for systematic design techniques (equivalence partitioning, boundary value analysis) rather than a complement is a common wrong turn: fuzzing is best at finding what you did not think to test, and gives no completeness guarantee over the boundary cases you already know matter.
- A crash a fuzzer finds is a starting point, not a finished bug report; minimizing the input, assessing exploitability and impact, and deduplicating against already-known crashes is real, ongoing work that needs to be budgeted rather than treated as an afterthought.
- Coverage-guided fuzzing's power depends on execution speed; fuzzing a component that does real network or disk I/O per iteration runs orders of magnitude fewer iterations than fuzzing a pure in-memory function, so isolating the parsing logic from its I/O dependencies specifically to make it fuzzable at speed is a design decision worth making early, not an afterthought.
Line and branch coverage are insufficient for edge-case confidence. Propose a set of meaningful coverage and quality metrics aimed at edge-case coverage (for example: boundary-condition coverage, mutation score, scenario coverage, property-assertion coverage). Explain how you'd instrument tests and dashboards to track risk-based test completeness.
Sample Answer
Direct answer
Line and branch coverage answer "was this code executed," not "would a wrong answer have been caught," so a suite can reach 100% of both while never noticing a broken boundary condition. Three additional metrics close that gap: mutation score (did the suite actually notice when the code was deliberately broken), boundary-condition coverage (were the specific edge values exercised, not merely the surrounding code path), and property-assertion coverage (were the declared invariants actually checked by a test, not just described in a specification document).
Structured elaboration
- Mutation score: the fraction of deliberately injected code mutants (small, systematic changes like swapping a relational operator or a boundary constant) that the suite "kills," meaning at least one test fails against the mutated code. mutation score=total mutantsmutants killed. This measures the STRENGTH of the assertions, not whether the code ran.
- Boundary-condition coverage: the fraction of boundary values identified by boundary value analysis (testing the values immediately below, at, and immediately above each bounded input) that appear as an explicit test input, distinct from line coverage since a single typical-case test can reach 100% of a bounded function's lines while touching none of its actual boundary values.
- Scenario coverage: the fraction of enumerated business use-case scenarios exercised, relevant whenever the same code path serves multiple scenarios with different correctness expectations that line coverage cannot distinguish between.
- Property-assertion coverage: the fraction of declared invariants or properties (from property-based or contract testing) that have at least one test actively checking them, versus properties that exist only in a specification document with nothing enforcing them.
Worked example (executed): why 100% line and branch coverage cannot distinguish a weak suite from a strong one
def is_adult(age):
return age >= 18
mutants = {
'age>18': lambda age: age > 18,
'age<=18': lambda age: age <= 18,
'age>=17': lambda age: age >= 17,
'age>=19': lambda age: age >= 19,
'age==18': lambda age: age == 18,
'not(age>=18)': lambda age: not (age >= 18),
}
def mutation_score(suite):
killed = set()
for age in suite:
original = is_adult(age)
for name, mutant in mutants.items():
if mutant(age) != original:
killed.add(name)
return killed, len(killed) / len(mutants)
for suite in ([17, 20], [17, 18, 19]):
killed, score = mutation_score(suite)
print(f"suite={suite} killed={sorted(killed)} score={score:.3f}")
This single-expression function has exactly one branch; any input reaches 100% line and branch coverage. Six standard mutation operators applied to it (relational-operator replacement and boundary-constant replacement): age > 18, age <= 18, age >= 17, age >= 19, age == 18, and not (age >= 18). Running the harness above against two different test suites:
| Suite | Inputs | Line/branch coverage | Mutants killed | Mutation score |
|---|---|---|---|---|
| weak_suite | [17, 20] | 100% | 4 / 6 (age<=18, age>=17, age==18, not(age>=18)) | 0.667 |
| strong_suite (BVA-derived) | [17, 18, 19] | 100% | 6 / 6 | 1.000 |
weak_suite fails to kill age > 18 and age >= 19, both of which only diverge from the original function exactly at age = 18 or age = 19, values the weak suite never tests. strong_suite, derived directly from boundary value analysis of the single threshold at 18, kills every mutant. Both suites reach identical line and branch coverage; mutation score is the metric that actually distinguishes them.
Instrumenting this in practice
Track mutation score and boundary-coverage percentage as first-class metrics alongside line and branch coverage, not as a replacement for them, and gate merges on a minimum mutation score specifically for high-risk modules rather than project-wide, since mutation testing is computationally expensive (it reruns the full suite once per mutant). Feed both numbers, per module, into the same dashboard that already reports line and branch coverage, trended over time rather than as a single snapshot, so a module whose boundary coverage or mutation score silently drops, for example because a boundary-focused test was deleted during an unrelated refactor, becomes visible on that dashboard before it causes an incident, rather than being caught only in hindsight.
Trade-offs & pitfalls
Mutation testing's computational cost (a full suite re-run per mutant) means it is typically run on a schedule or targeted at high-risk modules rather than on every commit, a real operational trade-off rather than a flaw in the metric itself. An "equivalent mutant," a mutant that is semantically identical to the original code despite a textual change (for example, replacing a multiplication by 1 with the bare value), can never be killed no matter how strong the suite is, and a team chasing 100% mutation score without accounting for equivalent mutants wastes effort chasing an unreachable target. Boundary-condition coverage also inherits whatever gaps exist in the underlying boundary value analysis, since it depends on someone having enumerated the boundaries by hand first; unlike mutation testing, it does not discover boundaries you failed to anticipate on its own.
Write pytest tests that validate an API's pagination endpoint for edge cases: page number 0, negative page size, huge page size, last page with fewer items, concurrently changing data while paginating, and requesting a page beyond total results. Provide test structure, sample input, and assertions.
Sample Answer
Direct answer
A pagination endpoint's edge-case suite needs to cover invalid inputs (page 0, negative page size), extreme inputs (a huge page size), the natural end-of-data cases (a partial last page, a page beyond the total results), and the concurrency case where the underlying data changes between page fetches, since offset-based pagination is not inherently stable under concurrent writes.
Structured elaboration and worked example (executed)
import pytest
class PaginatedStore:
def __init__(self, items):
self._items = list(items)
def get_page(self, page_number, page_size):
if page_number < 0:
raise ValueError("page_number must be >= 0")
if page_size <= 0:
raise ValueError("page_size must be > 0")
start = page_number * page_size
end = start + page_size
return self._items[start:end]
@pytest.fixture
def store():
return PaginatedStore([f"item-{i}" for i in range(1, 24)]) # 23 items
def test_page_zero(store):
assert store.get_page(0, 5) == ["item-1","item-2","item-3","item-4","item-5"]
def test_negative_page_size_raises(store):
with pytest.raises(ValueError):
store.get_page(0, -5)
def test_huge_page_size_returns_all(store):
page = store.get_page(0, 10_000)
assert len(page) == 23 and page[0] == "item-1" and page[-1] == "item-23"
def test_last_page_fewer_items(store):
page = store.get_page(4, 5) # 23 items / page_size 5 -> pages of 5,5,5,5,3
assert page == ["item-21","item-22","item-23"]
def test_page_beyond_total_results(store):
assert store.get_page(100, 5) == []
def test_concurrently_changing_data():
store = PaginatedStore([f"item-{i}" for i in range(1, 11)])
page1 = store.get_page(0, 5) # items 1-5
store._items.insert(0, "item-NEW") # simulate an insert between fetches
page2 = store.get_page(1, 5)
assert page1 == ["item-1","item-2","item-3","item-4","item-5"]
assert page2[0] == "item-5" # documents the shift artifact, see below
Running pytest -v against this file: 6 passed in 0.63s.
What the concurrency test actually demonstrates
The last test does not merely check that the endpoint doesn't crash; it documents a real correctness property (or lack thereof) of offset-based pagination: after page 1 returns items 1-5, inserting a new item at the FRONT of the dataset and then fetching "page 2" (offset 5, limit 5) returns item-5 again as the first element, because every existing item's index shifted by one when the insert happened. The test asserts this exact, verified behavior rather than glossing over it, which is the point: a candidate who hasn't executed this scenario is likely to assume offset pagination is safe under concurrent writes when it measurably is not. The fix, if this were unacceptable, is typically cursor-based pagination anchored to a stable key (e.g. an ID or timestamp) rather than a raw offset, which does not shift under front-inserts.
Trade-offs & pitfalls
A suite that only tests the six named edge cases in isolation, without also asserting an aggregate invariant (no duplicates or omissions across a full walk of all pages), can still pass every individual test here while shipping a suite that never catches a systemic duplicate/omission bug spanning multiple pages; this test file is deliberately scoped to single-request edge cases and boundary inputs, and should be read as a complement to, not a replacement for, that full-walk verification.
Design unit, integration, and chaos/incident tests to detect off-by-one errors and integer overflow in a distributed counter that aggregates per-node counters into a global total. Describe invariants you would assert (e.g., monotonic increase), how to simulate node restarts and network partition, and how property-based testing can help find subtle counter bugs.
Sample Answer
Direct answer
A distributed counter aggregating per-node counters into a global total needs three test layers targeting three distinct bug classes: unit tests for the merge logic's off-by-one and overflow behavior in isolation, integration tests that exercise the merge under realistic node-restart and network-partition conditions, and chaos/incident-style tests that inject those faults into a running system and assert the same invariants hold end to end. The core invariant to assert throughout is that the global total is monotonically non-decreasing from the perspective of any single observer, even while individual node-local counters reset.
Structured elaboration
Off-by-one from node restarts. When a node process restarts, its local counter resets to zero and climbs again; a merge function that naively treats every reported value as an absolute delta will double-count the node's pre-restart contribution. The correct merge tracks each node's last-seen value and only takes the raw value as the delta when it is smaller than the last-seen value (a reset signal), otherwise takes the difference.
Overflow from aggregation. Even if every per-node counter individually fits in 32 bits, the SUM across many nodes can exceed it; this is a distinct bug class from per-node overflow and needs its own boundary tests sized to the actual node count and per-node rate the system expects, not to a single node's range.
Invariants to assert. Monotonic non-decrease of the global total between any two observations by the same client (never decreasing, since counters only increment); conservation, meaning the global total after a merge equals the sum of each node's true lifetime contribution, independent of the order partial values arrived in; and idempotence, meaning re-merging a value already incorporated does not double-count it (needed because retries under partition are common).
Simulating node restarts and partitions. For restarts, drive the merge function directly with a crafted event sequence containing a reset (a later value smaller than an earlier one from the same node), which is deterministic and needs no real process management. For network partitions, at the integration/chaos layer, actually partition the network between nodes and the aggregator (via a proxy that can drop or delay traffic, or namespace-level packet blocking in a test cluster) and assert that once the partition heals, buffered/retried updates land at the correct total rather than being lost or double-applied, since partition-then-heal is exactly when retry-driven double-counting shows up.
Property-based testing for subtle counter bugs. Generate random sequences of per-node events (increments, resets, out-of-order arrivals, duplicate deliveries) and assert the three invariants above hold after every event, not just at the end; this catches sequences a human would not think to write by hand, such as a reset immediately followed by a duplicate delivery of the pre-reset value.
Worked example (executed)
def naive_merge(events):
total, last_seen = 0, {}
for node, value in events:
total += value # BUG: treats every snapshot as an absolute delta
last_seen[node] = value
return total
def correct_merge(events):
total, last_seen = 0, {}
for node, value in events:
prev = last_seen.get(node, 0)
delta = (value - prev) if value >= prev else value # reset detected
total += delta
last_seen[node] = value
return total
# node A counts 0->5->9, restarts (local counter resets to 0), counts 0->2->3
events = [("A", 5), ("A", 9), ("A", 2), ("A", 3)]
print(naive_merge(events)) # -> 19
print(correct_merge(events)) # -> 12 (true lifetime contribution: 9 before restart + 3 after)
Actual output: naive_merge returns 19, correct_merge returns 12, matching the true lifetime contribution of 9 (before restart) plus 3 (after restart). The aggregate-overflow case was checked separately with a pinned seed: 50 simulated nodes (random.seed(20260724)), each contributing a value uniformly sampled in [40,000,000, 50,000,000], summed to a true total of 2,247,438,431, which exceeds INT32_MAX (2,147,483,647). A checked-summation implementation raised OverflowError: global total 2154010351 exceeds INT32_MAX (2147483647) after adding node value 44617210, correctly flagging it mid-sum; an unchecked 32-bit wrapping summation of the same 50 values silently produced -2,047,528,865, a large positive true total reported as a large negative number with no error at all.
Trade-offs and pitfalls
The most common mistake is testing the reset-handling logic and the overflow-handling logic as if they were independent, when in production they compound: a node that restarts frequently under load is also a node likely to be near a rate spike, so a bug in reset detection can mask or interact with an overflow bug in ways neither isolated test catches, which is the specific argument for chaos-level tests that inject restarts and high throughput simultaneously rather than as separate scenarios. A second pitfall is asserting only the final global total after a fault-injection run; because monotonic non-decrease is an invariant over the WHOLE observation sequence, a test that only checks the end state can miss a transient dip (a decrease at some intermediate point) that a real client observing at that moment would have seen and acted on incorrectly.
Design a distributed rate limiter that supports bursts, persists counters safely across process restarts, and avoids integer overflow for clients that may perform up to 1M requests/day. Explain your algorithm, data layout, how to handle wraparound, clock skew across nodes, and tests to validate counter edge cases.
Sample Answer
Direct answer
Use a sliding-window counter (or fixed-window counter with a token-bucket layer on top for bursts), keyed per client per window, persisted in an external durable store (Redis with AOF/RDB persistence, or a database row) so the count survives a process restart. Store the count in a 64-bit field, since a 32-bit field can silently wrap around within a few years at this client's volume, and compute window boundaries from a single authoritative clock, never from each node's local clock, or nodes disagree on which window a request falls into. The hard part of this design is not the algorithm, it is proving the counter edge cases (overflow, wraparound, restart-safety, clock skew) with actual tests, which is where most rate-limiter incidents come from.
Structured elaboration
Algorithm. A fixed window (count resets every N seconds) is simple but allows up to 2x the intended rate at a window boundary (a client can send a full window's worth of requests in the last instant of one window and another full window's worth in the first instant of the next). A sliding window counter interpolates between the current and previous window's counts, weighted by how far into the current window we are, which removes most of that boundary burst while staying O(1) per client (unlike a sliding window LOG, which stores every request timestamp and costs O(requests-per-window) memory). Layer a token bucket on top for EXPLICIT burst support: a bucket refills at the steady-state rate and holds up to a burst capacity of tokens, so a client that has been quiet can spend a short burst above the smoothed rate without being penalized, while the underlying sliding-window counter still enforces the long-run 1,000,000/day ceiling.
Data layout. Key: {client_id}:{window_id}. Value: {count: int64, window_start_ts: int64} for the sliding-window counter, plus a separate {tokens: float64, last_refill_ts: int64} per client for the token bucket. Both live in the same durable store and both need the SAME overflow/restart/clock-skew discipline as the main counter, a token bucket is not "free" persistence-wise.
Restart safety. Because the counter lives in an external store, a process restart is a non-event for correctness as long as the increment operation is atomic (a single INCR in Redis, or an atomic UPDATE in a relational database) and the store's own durability is configured (Redis AOF fsync policy, or the database's write-ahead log). The counting service is stateless with respect to the count itself; it must never cache the count in local memory across requests without immediately persisting the increment.
Overflow avoidance at 1,000,000 requests/day. A signed 32-bit counter's maximum value is 231−1=2,147,483,647. If the counter is a LIFETIME counter (never reset, e.g. a total-requests-ever field used for billing or analytics alongside the rate limiter), it overflows after 2,147,483,647/1,000,000≈2,147.5 days, about 5.9 years, of sustained 1M/day traffic, a real risk for a long-lived account. A signed 64-bit counter's max is 263−1≈9.22×1018, which takes roughly 25 billion years to overflow at the same rate, effectively forever. The per-window counter itself resets every window so it never gets near either bound, but any LIFETIME or cumulative counter derived from the same system must use 64-bit width.
Wraparound handling. A fixed-width signed integer does not raise an error on overflow, it silently wraps to a large negative number (two's-complement behavior). For a rate limiter this is a serious bug: a check like count < limit reads a wrapped negative count as "well under limit" and admits unlimited traffic. The fix is either using a 64-bit field wide enough that wraparound is not a practical concern, or, for any fixed-width field that could still approach its bound, an explicit bounds check that rejects or rolls over the counter deliberately instead of letting the language/database wrap it silently.
Clock skew across nodes. If each node computes window_id = floor(now() / window_seconds) from ITS OWN local clock, and two nodes' clocks differ by even a few tens of seconds (realistic NTP, Network Time Protocol, drift during a network partition), a client alternating requests across the two nodes can land in two different windows for the same real-time instant, effectively doubling its quota near a window edge. The fix is to derive window_id from a single authoritative time source shared by all nodes (e.g. the timestamp returned by the store itself, such as Redis's TIME command, rather than each node's time.now()).
Worked example (all four claims executed, not asserted)
import numpy as np
REQ_PER_DAY = 1_000_000
INT32_MAX, INT64_MAX = 2**31 - 1, 2**63 - 1
print(INT32_MAX / REQ_PER_DAY / 365) # -> 5.88 years to overflow a lifetime int32 counter
print(INT64_MAX / REQ_PER_DAY / 365) # -> ~2.53e10 years for int64
# 1. wraparound bug, naive int32 counter
c = np.int32(INT32_MAX - 3)
for _ in range(8):
c = np.int32(c + np.int32(1)) # wraps silently, no exception
print(c, c < 5) # -> -2147483644 True (limiter would ADMIT the request)
# 2. width-checked counter: explicit reject instead of silent wraparound
class SafeCounter:
def __init__(self, start, width_max=INT32_MAX):
self.value, self.width_max = start, width_max
def try_increment(self):
if self.value >= self.width_max:
raise OverflowError("counter at max representable value")
self.value += 1
safe = SafeCounter(INT32_MAX - 3)
try:
for _ in range(8): safe.try_increment()
except OverflowError as e:
print("raised:", e) # -> raised at value=2147483647
# 3. restart safety: a persisted counter (here, a JSON file standing in for
# a durable store) survives the in-memory process object being destroyed
import json, os, tempfile
path = os.path.join(tempfile.gettempdir(), "counter.json")
json.dump({"count": 0}, open(path, "w"))
def increment():
s = json.load(open(path)); s["count"] += 1; json.dump(s, open(path, "w")); return s["count"]
for _ in range(7): increment()
count_before_crash = json.load(open(path))["count"] # process A "crashes" here
count_after_restart = json.load(open(path))["count"] # process B starts fresh
print(count_before_crash, count_after_restart) # -> 7 7
# 4. clock skew: local clocks disagree on window index; a shared clock agrees
WINDOW_SECONDS = 60
def window_index(ts): return int(ts // WINDOW_SECONDS)
shared_time = 119.5
node_a_local, node_b_local = shared_time + 0.0, shared_time + 45.0 # 45s skew
print(window_index(node_a_local), window_index(node_b_local)) # -> 1 2 (DIFFERENT windows, bug)
print(window_index(shared_time), window_index(shared_time)) # -> 1 1 (fixed: same authoritative clock)
Executed output (captured, not paraphrased): 5.88 years to a 32-bit lifetime-counter overflow versus ~2.53e10 years for 64-bit; the naive int32 counter reads -2147483644 after wrapping, and -2147483644 < 5 evaluates True, confirming the limiter would wrongly admit the request; the width-checked counter raises OverflowError at value=2147483647 instead of wrapping; the persisted-counter test shows count_before_crash == count_after_restart == 7; and the local-clock test shows node A and node B computing DIFFERENT window indices (1 vs 2) for the same instant, while both computing 1 when reading from the shared authoritative timestamp.
Trade-offs & pitfalls
The most common mistake is testing the sliding-window math in isolation and never actually exercising the persistence layer: kill the counting process (or, in a test, just re-instantiate a fresh client against the same store) and confirm the count is unchanged, don't just trust that "it's in Redis so it's fine." A second common mistake is choosing a 32-bit counter for a "small" per-window count while forgetting that the SAME storage layer is often reused for a cumulative lifetime metric, where the overflow horizon is a matter of years, not centuries. Third, clock-skew tests are frequently skipped entirely because they are awkward to set up locally; the fix above (deriving the window index from the store's own clock) removes the entire class of bug rather than requiring every node's NTP configuration to be perfect. Finally, remember the token bucket's own state (tokens, last-refill-ts) needs the identical restart-safety and overflow discipline as the main counter; a rate limiter that gets the window counter right but keeps burst tokens in process memory silently resets everyone's burst allowance on every deploy.
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.