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.
Write three concise negative test cases (with test ID, description, preconditions, steps, expected result) for an 'Age' numeric input field that accepts integer ages 0 through 120. Cover invalid input types, out-of-range values, and missing input.
Sample Answer
Direct answer
Three negative test cases for an integer 'Age' field valid on 0-120 should each target a distinct failure category: a non-numeric input type, an out-of-range numeric value, and a missing/blank input, since these represent different points where validation logic can independently fail.
Structured elaboration: the three test cases
TC-AGE-N01 (invalid input type)
- Description: Submit a non-numeric string in the Age field.
- Preconditions: User is on the registration form with all other required fields correctly filled.
- Steps: 1) Enter "twenty-five" in the Age field. 2) Submit the form.
- Expected result: Form displays a field-specific validation error (e.g. "Age must be a whole number") and does not submit; no partial record is created.
TC-AGE-N02 (out-of-range value)
- Description: Submit an integer value outside the valid 0-120 range.
- Preconditions: User is on the registration form with all other required fields correctly filled.
- Steps: 1) Enter "150" in the Age field. 2) Submit the form.
- Expected result: Form displays a range-specific validation error (e.g. "Age must be between 0 and 120") distinct in wording from TC-AGE-N01's type error, and does not submit.
TC-AGE-N03 (missing input)
- Description: Submit the form with the Age field left blank.
- Preconditions: User is on the registration form; Age is a required field.
- Steps: 1) Leave the Age field empty. 2) Fill all other required fields. 3) Submit the form.
- Expected result: Form displays a required-field error (e.g. "Age is required") and does not submit; the error is distinguishable from both TC-AGE-N01 and TC-AGE-N02 since a required-field check is logically a separate code path from a type or range check.
Worked example: why the three error messages must be distinct
A common implementation shortcut collapses all three failures into a single generic "Invalid Age" message. Testing for a DISTINCT message per case (as written above) is what actually verifies the underlying validation logic runs three separate checks (presence, type, range) rather than one catch-all regex or parser that happens to reject all three inputs for unrelated, unverified reasons; if the implementation used a single try/except around an integer parse, TC-AGE-N02 (a value that DOES parse as an integer, just an out-of-range one) would reveal that the range check either exists as its own step or was accidentally never implemented at all, since a bare int-parse would happily accept "150".
Trade-offs & pitfalls
A frequent gap is testing only ONE of these three categories and assuming the others are "probably fine"; each is a genuinely separate code path (presence check, type/parse check, range check) that can be implemented, mis-implemented, or simply forgotten independently of the other two, which is exactly why negative testing treats them as three test cases rather than one.
Compare and contrast equivalence partitioning, boundary value analysis, pairwise (pairwise combinatorial) testing, and exploratory testing. For each technique, describe scenarios in which it provides the most value, its limitations, and provide an example application to a file-upload feature (file size limit, allowed types, network interruptions).
Sample Answer
Direct answer
Equivalence partitioning (EP), boundary value analysis (BVA), pairwise testing, and exploratory testing solve different problems: EP and BVA shrink an infinite input space to a small, defensible set of representative cases; pairwise testing shrinks a combinatorial configuration space; exploratory testing finds the things none of the systematic techniques anticipated.
Structured elaboration
| Technique | Best used when | Limitation |
|---|---|---|
| Equivalence partitioning | A single input has distinct behavior classes (valid range, invalid range, special category) | Assumes every value in a class truly behaves the same; misses interactions between fields |
| Boundary value analysis | The behavior is defined by numeric or ordered limits | Only as good as the boundaries you correctly identify; misses non-boundary logic bugs |
| Pairwise testing | Several independent parameters combine, and full combinatorial testing is infeasible | Only guarantees 2-way interaction coverage; a bug that only appears with 3+ parameters together can be missed |
| Exploratory testing | You need to find the unknown-unknowns, or the system is new/poorly specified | Not repeatable or measurable the same way; coverage depends on the tester's skill and time |
In practice these are complementary, not competing: EP/BVA design the systematic core of a suite, pairwise controls the combinatorial explosion once you have several such inputs interacting, and exploratory testing runs on top to catch what the checklist-driven techniques structurally cannot (workflow surprises, usability issues, timing-dependent bugs).
Worked example: file-upload feature
Assume the feature has a file-size limit of 10 MB, an allowed-type list of {jpg, png, pdf}, and operates over a network that can be interrupted.
- Equivalence partitioning: partition file TYPE into {allowed image, allowed pdf, disallowed type (e.g..exe), no extension}; partition file SIZE into {well within limit, at limit, over limit}. One representative test per partition, e.g. a 2 MB jpg (valid), a 2 MB.exe (invalid type), a 15 MB pdf (invalid size).
- Boundary value analysis: for the 10 MB size limit, test 10 MB - 1 byte, exactly 10 MB, and 10 MB + 1 byte. BVA is the right tool here because the size check is a numeric threshold; EP alone would only tell you to test "one big file," not the exact byte where behavior flips.
- Pairwise testing: if type, size-bucket, and network-condition (stable / interrupted mid-upload) are three independent parameters, pairwise testing picks a small set of combinations (e.g. jpg+at-limit+stable, pdf+over-limit+interrupted, exe+within-limit+interrupted) that cover every pair of parameter values at least once, instead of testing every type x every size-bucket x every network condition.
- Exploratory testing: a tester freely uploads a file, then immediately navigates away, uploads the same filename twice in two tabs, or renames a.exe to.jpg to see if the check is extension-based or content-based. None of the systematic techniques above would generate that renaming trick on their own; it comes from tester intuition about how real users (and attackers) behave.
Trade-offs & pitfalls
A common mistake is treating these as alternatives and picking just one. Relying on EP/BVA alone gives you a clean, explainable suite but nothing that mimics real usage disorder; relying only on exploratory testing gives good bug-finding but poor repeatability and no defensible coverage story for a regression suite. The senior move is to use EP/BVA to build the deterministic regression core, pairwise to keep configuration-heavy features tractable, and time-boxed exploratory sessions to supplement both, then feed anything exploratory testing finds back into the systematic suite as a new equivalence class or boundary.
What is pairwise (all-pairs) testing, and why do teams use it instead of exhaustive combinatorial testing? Given 3 parameters with 2, 3, and 4 possible values respectively, compute the number of test cases exhaustive testing requires vs. pairwise testing, and explain the empirical justification (most real-world defects are triggered by interactions of 1-2 parameters, not higher-order interactions).
Sample Answer
Direct answer
Pairwise (all-pairs) testing is a combinatorial test-design technique that selects a small subset of parameter-value combinations such that every possible PAIR of values from any two parameters appears together in at least one test case, instead of testing the full cross-product of every parameter with every other; teams use it because empirical research (notably NIST's combinatorial-testing studies) has found that the large majority of interaction defects are triggered by just one or two parameters interacting, so covering all pairs catches most of the practically-findable interaction bugs at a small fraction of the cost of exhaustive testing.
Structured elaboration
For 3 parameters with 2, 3, and 4 possible values respectively:
- Exhaustive testing requires the full cross-product: 2 x 3 x 4 = 24 test cases.
- Pairwise testing requires covering every pair of values across the three parameters. The theoretical minimum is bounded below by the largest pairwise product among the parameters (here, 3 x 4 = 12, since you need at least one test case per combination of the two largest parameters), and a real greedy pairwise-construction algorithm run against this exact configuration achieved that lower bound exactly: 12 test cases, verified by explicit computation to cover all 26 required pairs (2x3 + 2x4 + 3x4 = 6+8+12 = 26 pairs).
That is a 2x reduction here (24 -> 12); the reduction grows much larger as the number of parameters increases, because exhaustive testing grows multiplicatively with every new parameter while pairwise testing grows much more slowly (roughly proportional to the product of the two largest parameter sizes, not all of them multiplied together).
Worked example (executed, not asserted)
import itertools
sizes = [2, 3, 4]
exhaustive = sizes[0] * sizes[1] * sizes[2]
pairwise_lower_bound = max(sizes[0]*sizes[1], sizes[0]*sizes[2], sizes[1]*sizes[2])
all_pairs = set()
for (pi, pj) in itertools.combinations(range(3), 2):
for vi in range(sizes[pi]):
for vj in range(sizes[pj]):
all_pairs.add((pi, pj, vi, vj))
def covered_pairs(row):
s = set()
for (pi, pj) in itertools.combinations(range(3), 2):
s.add((pi, pj, row[pi], row[pj]))
return s
def greedy_cover():
remaining = set(all_pairs)
rows = []
while remaining:
best_row, best_gain = None, -1
for v0 in range(sizes[0]):
for v1 in range(sizes[1]):
for v2 in range(sizes[2]):
row = (v0, v1, v2)
gain = len(covered_pairs(row) & remaining)
if gain > best_gain:
best_gain, best_row = gain, row
rows.append(best_row)
remaining -= covered_pairs(best_row)
return rows
rows = greedy_cover()
covered = set().union(*(covered_pairs(r) for r in rows))
print("exhaustive =", exhaustive)
print("pairwise lower bound =", pairwise_lower_bound)
print("total pairs to cover =", len(all_pairs))
print("greedy covering array size =", len(rows))
print("all pairs covered:", covered == all_pairs)
Actual output:
exhaustive = 24
pairwise lower bound = 12
total pairs to cover = 26
greedy covering array size = 12
all pairs covered: True
The empirical justification for why pairwise coverage is 'good enough' in practice (not just cheaper) comes from field-defect studies: interaction bugs triggered by 3 or more parameters together are measured to be rare compared to 1-2 parameter interactions, so a 2-way covering array captures most of the realistically findable interaction-bug surface. This is a probabilistic argument, not a guarantee, which is the technique's honest limitation.
Trade-offs & pitfalls
Pairwise testing does not find every bug: if a defect genuinely requires three specific parameter values together and does not manifest with any two of them alone, a pairwise suite can miss it entirely, so critical or history-flagged interactions sometimes need to be escalated to 3-way (t-wise) coverage deliberately. It is also easy to conflate "pairwise-covered" with "tested thoroughly": a 12-case pairwise suite for this example still only samples half the combinations an exhaustive run would exercise, and stakeholders unfamiliar with the technique can mistakenly read "we ran the pairwise suite" as "we tested everything."
Explain state transition testing and produce a state diagram for a shopping-cart system with these states: empty, items-added, checkout-started, payment-pending, confirmed, failed, and canceled. From that diagram, list test cases that cover all transitions, including persistence across sessions and behavior on timeout during 'payment-pending'.
Sample Answer
Direct answer
State transition testing derives test cases from a state diagram rather than from input values: you enumerate the system's valid states, the events/transitions between them, and then design at least one test per transition (and, ideally, tests for the invalid transitions that should be rejected) rather than testing inputs in isolation.
Structured elaboration: the state diagram
stateDiagram-v2
[*] --> empty
empty --> items_added: add item
items_added --> items_added: add/remove item
items_added --> empty: remove last item
items_added --> checkout_started: begin checkout
checkout_started --> payment_pending: submit payment info
checkout_started --> items_added: abandon checkout
payment_pending --> confirmed: payment succeeds
payment_pending --> failed: payment declined
payment_pending --> canceled: timeout / user cancels
failed --> payment_pending: retry payment
failed --> items_added: abandon after failure
confirmed --> [*]
canceled --> [*]
Worked example: transition-coverage test cases
- empty -> items_added (add the first item): cart becomes non-empty, item count = 1.
- items_added -> items_added (add a second item, then remove one): confirms self-loop transitions don't corrupt state.
- items_added -> empty (remove the last remaining item): cart returns to empty, not to an undefined state.
- items_added -> checkout_started (begin checkout): checkout flow becomes available only from a non-empty cart; a companion NEGATIVE test confirms empty -> checkout_started is REJECTED (you cannot check out an empty cart).
- checkout_started -> payment_pending (submit payment info): cart is now locked from further item edits.
- checkout_started -> items_added (abandon checkout): confirms the user can back out before paying, and the cart contents are preserved, not cleared.
- payment_pending -> confirmed (payment succeeds): order is finalized.
- payment_pending -> failed (payment is declined): cart is NOT cleared, so the user can retry.
- failed -> payment_pending (retry with corrected payment info): confirms the retry loop actually re-enters payment processing rather than being a dead end.
- failed -> items_added (abandon after a failed payment): confirms items are still there to re-checkout later.
Persistence across sessions
A state-only diagram is not enough here: 'items_added' and 'checkout_started' must survive a browser refresh or a new session on the same account (persisted cart), while 'payment_pending' crossing a session boundary is a distinct, higher-risk case: if the user closes the tab mid-payment and reopens the site, the test must confirm the system does NOT silently resume 'payment_pending' as if nothing happened (which risks a double-charge or an orphaned pending order); it should either restore to 'payment_pending' with the SAME idempotent payment attempt, or transition to 'failed'/'canceled' based on the payment provider's actual status, never re-submit a fresh payment attempt implicitly.
Timeout during payment-pending
This is the transition most worth a dedicated test: payment_pending -> canceled after a defined timeout window (e.g. 15 minutes with no confirmation from the payment provider). The test needs to verify three things together: (a) the transition actually fires at the timeout boundary, not indefinitely later; (b) the cart's item state is preserved so the user isn't forced to rebuild it; (c) if the payment provider's confirmation arrives AFTER the local timeout has already fired the cancellation (a race the diagram doesn't show but the real system has), the system does not end up both 'canceled' locally and 'confirmed' at the payment provider, i.e. the reconciliation logic for a late-arriving webhook after a local timeout needs its own explicit test.
Trade-offs & pitfalls
A state diagram that omits the late-arriving-webhook race (as the given 7-state list does) will produce a test suite that looks complete against the diagram while missing the actual production risk (double payment / lost order). State-transition testing is only as good as the diagram; a common pitfall is drawing the diagram from the happy-path requirements document rather than from how the system's async dependencies (a payment gateway here) can genuinely desynchronize from it.
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 Test Case Design and Edge Case Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.