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.
List edge cases and failure modes to consider when implementing file uploads to the backend: zero-byte files, maximum allowed size exceeded, partial uploads due to network drop, streaming memory blowup, malicious filenames, and content-type mismatches. How would you write an integration test to simulate a partial upload and assert correct cleanup or resume behavior?
Sample Answer
Direct answer
File-upload edge cases span three failure categories: malformed or extreme content (zero-byte files, over-size files, streaming memory blowup on very large files), interrupted transport (partial uploads from a dropped network connection), and hostile input (malicious filenames, content-type mismatches), and the highest-value integration test simulates a partial upload and asserts the system cleans up the incomplete artifact rather than leaving orphaned data.
Structured elaboration
- Zero-byte file: the upload succeeds at the transport layer but the resulting file has no content; the system must decide and enforce whether an empty file is valid (many business contexts say no) rather than silently accepting it.
- Maximum allowed size exceeded: must be rejected with a clear, early error (ideally before the whole file transfers, via a Content-Length check) rather than accepting the full transfer and only then rejecting it, which wastes bandwidth and time.
- Streaming memory blowup: an implementation that buffers the entire file in memory before processing it can be forced into an out-of-memory condition by a large-but-under-the-nominal-limit file if the limit check itself happens too late or is missing on a different code path (e.g. a chunked-transfer-encoding request that never declares Content-Length).
- Malicious filenames: filenames containing path-traversal sequences (
../../etc/passwd), null bytes, or unusual encodings must be sanitized or rejected before the filename is ever used to construct a filesystem path, never trusted as literal path input. - Content-type mismatches: a file whose extension claims
.jpgbut whose actual bytes are something else (a script, or a different file format) must be validated by content sniffing, not just the client-supplied extension or MIME-type header, since both are attacker-controlled.
Worked example: integration test for a partial upload
def test_partial_upload_is_cleaned_up(upload_service, tmp_storage):
upload_id = upload_service.start_upload(filename="report.pdf", declared_size=10_000_000)
# simulate a network drop after only 30% of the bytes arrive
upload_service.receive_chunk(upload_id, data=b"x" * 3_000_000)
upload_service.simulate_connection_drop(upload_id)
# assert the system does NOT expose a partial file as if it were complete
assert upload_service.get_status(upload_id) == "incomplete"
assert not tmp_storage.has_committed_file("report.pdf")
# assert cleanup: after the configured retention window, the partial artifact is removed
upload_service.run_cleanup_sweep(older_than_seconds=0)
assert not tmp_storage.has_temp_artifact(upload_id)
# assert resume behavior: the client can either resume from the last committed chunk
# or must restart, and the API's documented contract for which one applies is what
# the test actually pins down (this example asserts a resume-from-offset contract)
resumed = upload_service.resume_upload(upload_id, filename="report.pdf")
assert resumed.resume_offset == 3_000_000
The test's structure matters as much as its assertions: it exercises three distinct states (in-progress, post-drop, post-cleanup) rather than a single before/after snapshot, because a partial-upload bug frequently lives specifically in the TRANSITION between those states (e.g. a race where cleanup runs before the drop is even detected, or a resume that silently restarts from zero instead of the last committed offset, wasting the bytes already transferred).
Trade-offs & pitfalls
A common gap is testing the size limit only against the DECLARED size in a header, never against the ACTUAL bytes received; a client can lie about Content-Length, and a server that trusts it exclusively can still be driven into the memory-blowup scenario by a request that declares a small size but streams far more. The resume-vs-restart contract above is also a real design decision, not a given: if the system does not actually support resuming from an offset, the test should instead assert that a resume attempt cleanly restarts rather than silently corrupting a half-written file by appending to it.
You're reviewing a REST API endpoint POST /upload that accepts a JSON payload: {"name": string, "size": int, "tags": [string]}. Describe and enumerate the edge cases and boundary conditions you would test for this endpoint across inputs, auth, storage, and concurrency. For each case explain why it matters, the likely failure mode, and a simple mitigation or monitoring signal you would add.
Sample Answer
Direct answer
For POST /upload accepting {name, size, tags}, the edge cases split into four axes: malformed or boundary INPUT values, AUTH gaps, STORAGE failure modes, and CONCURRENCY races. The highest-value cases sit where two axes intersect, for example a storage write failure happening mid-request while a concurrent duplicate upload is also in flight, since that is exactly where a naive implementation is most likely to leave the system in an inconsistent state.
Structured elaboration
| Axis | Case | Why it matters | Likely failure mode | Mitigation or monitoring signal |
|---|---|---|---|---|
| Input | Empty name, an extremely long name near a storage or database column limit, or a name containing path-traversal-like sequences | A filename is often used to construct paths or keys downstream | An overlong name gets silently truncated inconsistently across layers; a traversal-like name is used unsanitized to build a filesystem or storage path | Validate length and character set server-side before use; never construct a raw path directly from client input |
| Input | size of 0, negative, or larger than the value type can hold | size is often trusted for quota accounting or pre-allocation | A negative or overflowed value corrupts quota math or causes an allocation error | Validate size is a positive, bounded integer before any accounting logic runs |
| Input | Declared size does not match the actual number of bytes received | The declared value is client-controlled and can simply be wrong or dishonest | The server trusts the declared size for downstream accounting while storing a differently-sized object, or a memory-buffering implementation is driven into excessive memory use by an under-declared size | Reject on mismatch after the transfer completes, and compare against a running byte count during streaming rather than trusting the header alone |
| Input | tags array is empty, very large, or contains duplicate entries | Downstream search or filtering logic may assume tags are deduplicated and bounded | Duplicate tags stored and indexed redundantly; an unbounded tag count degrades downstream queries | Deduplicate and cap the tag count server-side; validate per-tag length |
| Auth | Missing or expired token; valid token but the caller lacks permission for this resource; valid token for a DIFFERENT tenant or account in a multi-tenant system | The last case is an authorization boundary, not just authentication, and a mistake here is a data-exposure bug, not a rejection bug | A missing token is correctly rejected but a cross-tenant token might succeed if only authentication, not authorization scope, is checked | Missing/invalid token returns one status; valid-but-unauthorized returns a distinct status; cross-tenant access must be denied even with an otherwise-valid token |
| Storage | Write failure partway through the upload | An unfinished write can leave a partially-written object | A truncated object becomes visible/committed as if it were complete | The object must not be visible/committed until the write is fully confirmed; a partial write is rolled back or marked failed, never exposed |
| Storage | Storage quota exceeded for the account | Quota enforcement is often checked before the write starts but not re-checked if it changes mid-request | A user exceeds their quota if two uploads race past the same check simultaneously | Enforce the quota check atomically against the write, not as a separate earlier read-then-write step |
| Storage | Object name collision with an existing object | Silently overwriting, versioning, and rejecting are three different, valid product decisions | The system's actual behavior on collision goes untested because it was never explicitly decided | Whichever behavior the product chose, assert exactly that behavior, not merely "no error" |
| Concurrency | Two identical uploads (same name, same content) submitted concurrently, for example from a double-click or an automatic client retry | Whether this should deduplicate or create two records is a real design decision | An unintended duplicate object or database row if dedup was assumed but not implemented | Assert the actual chosen behavior explicitly, backed by an idempotency mechanism if dedup is intended |
| Concurrency | The metadata write (a database row for name/size/tags) and the object write (the actual bytes to storage) are two separate operations | One can succeed while the other fails, since they are not a single atomic operation | An orphaned stored object with no database row, or a database row pointing at a missing object | A reconciliation process detects and repairs the mismatch within a bounded time, or the two writes are coordinated so a partial failure rolls back both |
Worked example: two of the richer cases
Declared-versus-actual size mismatch: declare size = 500000 in the JSON payload, but stream only 400000 actual bytes in the request body. Expected: the endpoint rejects with a client-error status once the mismatch is detected, rather than committing a truncated object or trusting the client-declared size for any downstream accounting.
Metadata/storage write inconsistency: force the storage-layer write to fail (via a test double that simulates a failure) AFTER the database row has already been inserted. Expected: either the database row is rolled back or marked failed so no "ready" record ever points at a missing object, or a reconciliation sweep detects and repairs the mismatch within a stated bound; the test should assert whichever specific contract the system actually implements, not a vague "eventually consistent" claim with no concrete check behind it.
Trade-offs & pitfalls
A common gap is testing the JSON metadata fields exhaustively while treating the actual byte stream as an afterthought, when the declared-versus-actual mismatch is precisely where trust-boundary bugs concentrate. A second common gap is testing authentication only for "no token at all" and never for the cross-tenant authorization case, which is a more severe class of bug (data exposure) than a simple rejection. A third pitfall is testing storage and concurrency cases only against a mocked storage backend that always fails cleanly and instantly, which hides how a real backend behaves under a SLOW-but-eventually-successful write, a genuinely different and common failure mode from a clean, immediate failure.
For a bank ledger system that records transfers between accounts, propose a set of invariants suitable for property-based testing (for example: sum of all balances remains constant excluding external deposits/withdrawals). Describe how you would model transactions and sequences of operations, generate randomized operation sequences, detect invariant violations using Hypothesis or an equivalent tool, and shrink failing cases to minimal counterexamples for developers.
Sample Answer
Direct answer
For a bank ledger, the core property-based invariant is conservation: the sum of all account balances stays constant across any sequence of internal transfers (excluding genuine external deposits/withdrawals), and a secondary invariant is that no balance ever goes negative; the design work is modeling transactions as a sequence of RULES a state machine can apply, generating randomized sequences of those rules, checking both invariants after every step, and letting the property-testing tool shrink any violating sequence down to the shortest sequence of operations that still breaks the invariant.
Structured elaboration
This is the same conservation-invariant property whether it's framed as a bank ledger, a payment-reconciliation system (money in minus money out across a settlement window must equal the net balance change), or a debit/credit accounting ledger (every debit must be matched by an equal credit, so the sum across all entries nets to zero); all three are the identical mathematical invariant applied to a different vocabulary, which matters because it means the SAME property-based test design transfers directly between them.
Modeling with a stateful property-based testing tool (Hypothesis's RuleBasedStateMachine in Python, though the same shape exists in other ecosystems, e.g. QuickCheck-style state machines):
- State: a dict of account balances, initialized to known starting values, plus the known invariant total.
- Rules: a
transfer(src, dst, amount)rule that the state machine can call with randomly generatedsrc,dst, andamountvalues; a well-modeled rule also needs to correctly handle its OWN expected-failure path (an insufficient-funds transfer should raise and leave every balance untouched, which is itself part of the invariant, not a separate concern). - Invariants, checked after every applied rule:
sum(balances.values()) == expected_total(conservation) andall(b >= 0 for b in balances.values())(no negative balances). - Shrinking: when a generated sequence of transfers violates an invariant, the tool automatically searches for a shorter, simpler sequence that still violates it (fewer transfers, smaller amounts, fewer distinct accounts involved), which is the property-based testing payoff over a hand-written unit test: a random 40-operation sequence that fails shrinks down to the 2-3 operations that actually matter, which is what a developer needs to see to fix the bug.
Worked example (executed): a genuine conservation-invariant violation found and shrunk
Two ledger implementations were built and tested with a Hypothesis RuleBasedStateMachine against the conservation invariant above:
from hypothesis import settings
from hypothesis.stateful import RuleBasedStateMachine, rule, initialize, invariant
from hypothesis import strategies as st
ACCOUNTS = ["A", "B", "C"]
class IntLedgerMachine(RuleBasedStateMachine):
@initialize()
def setup(self):
self.balances = {"A": 1000_00, "B": 1000_00, "C": 1000_00}
self.total = sum(self.balances.values())
@rule(src=st.sampled_from(ACCOUNTS), dst=st.sampled_from(ACCOUNTS),
amount=st.integers(min_value=1, max_value=50000))
def transfer(self, src, dst, amount):
if src == dst or self.balances[src] < amount:
return
self.balances[src] -= amount
self.balances[dst] += amount
@invariant()
def conserved(self):
assert sum(self.balances.values()) == self.total
class FloatLedgerMachine(RuleBasedStateMachine):
@initialize()
def setup(self):
self.balances = {"A": 1000.0, "B": 1000.0, "C": 1000.0}
self.total = sum(self.balances.values())
@rule(src=st.sampled_from(ACCOUNTS), dst=st.sampled_from(ACCOUNTS),
amount=st.floats(min_value=0.01, max_value=500, allow_nan=False, allow_infinity=False))
def transfer(self, src, dst, amount):
if src == dst or self.balances[src] < amount:
return
self.balances[src] -= amount
self.balances[dst] += amount
@invariant()
def conserved(self):
assert sum(self.balances.values()) == self.total
IntLedgerMachine.TestCase.settings = settings(max_examples=300, stateful_step_count=50)
FloatLedgerMachine.TestCase.settings = settings(max_examples=300, stateful_step_count=50)
The integer-cents machine, run for 300 examples of up to 50 transfer steps each, PASSED with no invariant violation found.
The float-balance machine, run with the identical settings, FOUND a violation and shrunk it, quoted verbatim from the executed run:
state = FloatLedgerMachine()
state.setup()
state.transfer(amount=416.76907716096747, dst='A', src='B')
state.transfer(amount=258.0, dst='A', src='B')
state.transfer(amount=374.0, dst='A', src='C')
state.transfer(amount=0.3333333333333333, dst='A', src='B')
AssertionError: assert sum(self.balances.values()) == self.total
This is a genuine, measured floating-point conservation drift: each individual transfer is internally correct (debit exactly equals credit at the moment it's applied), but repeated binary floating-point addition and subtraction accumulates rounding error, so the SUM across all balances drifts away from the true conserved total of 3000.0 after four transfers. (The exact shrunk sequence and step count are not a fixed property of the bug: a different Hypothesis version, seed, or run will shrink to a different, similarly small, sequence of float transfers; re-running the code above is expected to reproduce the CLASS of failure, not necessarily this literal transcript.) This is exactly the class of bug property-based testing is well-suited to catch and a fixed set of hand-picked unit-test amounts would very likely miss, since it depends on the specific bit-level rounding behavior of the particular float values chosen, which random generation reliably stumbles into and shrinking reliably minimizes to a small, reproducible reporting case.
Trade-offs and pitfalls
The most common wrong turn is writing the conservation invariant as exact equality (==) when the underlying representation is floating-point, as demonstrated above; the fix is either representing money as integer minor units (cents) so exact-equality conservation genuinely holds (confirmed passing in the integer version above), or, if floating-point is unavoidable for some other reason, relaxing the invariant to an explicit, documented tolerance (abs(actual - expected) < epsilon) rather than silently accepting drift with no check at all. A second pitfall is modeling only the successful-transfer path and never generating transfers that SHOULD fail (insufficient funds, zero or negative amount, transferring to a nonexistent account); the invariant check after a REJECTED transfer (balances unchanged) is just as valuable a thing for the state machine to verify as the check after an accepted one, and skipping it misses bugs where a supposedly-rejected transfer partially mutates state before raising.
You have a configurable product offering 12 independent boolean feature flags and several numeric configuration ranges. Full combinatorial testing is impossible. Propose a combinatorial testing strategy (pairwise or t-wise), explain how to apply constraints, select critical interactions to test, choose tooling, and describe how to generate stable deterministic test data for these combinations.
Sample Answer
Direct answer
With 12 independent boolean feature flags alone, exhaustive testing requires 2^12 = 4096 combinations, before even accounting for the numeric configuration ranges; a pairwise (2-way interaction) strategy instead constructs a much smaller covering array that still guarantees every PAIR of flag values appears together in at least one test case, which is the strategy to propose here, refined with constraints to exclude invalid combinations and prioritized coverage for the interactions most likely to matter.
Structured elaboration
- Confirm the exhaustive number, so the case for pairwise is concrete, not hand-waved. 2^12 = 4096 is verified below by direct computation, not assumed.
- Choose pairwise (t=2) as the default strength, because the empirical justification (NIST combinatorial-testing research: the large majority of field defects are triggered by interactions of 1-2 parameters) means a 2-way covering array catches most of the practically findable interaction bugs at a fraction of the cost. If this product has a known history of 3-way interaction bugs (e.g. flag A + flag B + a specific numeric range together), escalate just that subset to 3-way (t=3) rather than raising the strength for the whole array.
- Apply constraints before generating the array. Real flag sets usually have dependencies (e.g. flag 'enable-advanced-mode' being off makes flag 'show-advanced-panel' meaningless, or two flags are mutually exclusive). Feed these as constraints to the pairwise generator so it never wastes a test case on an impossible combination, and so invalid combinations don't silently get treated as "covered."
- Select critical interactions to force-include. Beyond the algorithm's default output, explicitly force in any pair the team already suspects is risky (from past incidents, code review, or an area with recent churn), since a generic pairwise tool has no knowledge of your defect history and will treat every pair as equally important.
- Choose tooling. A combinatorial-test-design tool (e.g. PICT, or a Python library implementing an all-pairs algorithm) takes the parameter/value list plus constraints and outputs a minimal covering set; hand-building this for 12+ parameters is impractical and error-prone.
- Generate stable, deterministic test data. Seed the tool's random/tie-breaking steps explicitly (most pairwise generators have some randomized component when multiple candidate combinations tie on coverage gain) so the same input configuration always produces the same test-case list across CI runs; without a fixed seed, two runs of "the same" pairwise suite can silently test different combinations, which breaks reproducibility when someone tries to re-run a failing case.
Worked example
Computed directly (not asserted): 2^12 = 4096 exhaustive combinations for the boolean flags alone. A minimal pairwise covering array for 12 binary factors needs only a small number of test cases to cover all pairs; a greedy pairwise-construction algorithm run against this exact configuration (12 factors, 2 values each, 264 total pairs to cover) found a valid covering set of 9 test cases that provably covers every one of the 264 pairs (verified by explicit set-membership check, not eyeballed), against 4096 exhaustive combinations, a roughly 450x reduction. Practical pairwise tools typically report numbers in the 12-16 range for this exact shape because they also handle tie-breaking and constraints conservatively, but the order of magnitude (single digits to teens, not thousands) is the point the candidate needs to defend.
Trade-offs & pitfalls
The biggest pitfall is presenting pairwise coverage as equivalent to exhaustive coverage: it explicitly does not guarantee catching a bug that only manifests when three or more specific flags/ranges combine, so it must be paired with targeted testing of any known higher-order risk area, not treated as a full replacement for domain judgment. A second pitfall is generating the covering array once and reusing it forever as flags are added or removed; the array must be regenerated whenever the parameter list changes, or coverage silently degrades for the new/changed flags.
Describe the essential components of a well-formed manual test case for a QA team. Your answer should: 1) list and explain each field you would include in a reusable template (for example: id, title, preconditions, setup, steps, expected result, test data, postconditions, severity, priority, attachments), 2) show a short example test case for a login form (include realistic values), and 3) explain why each field matters for traceability and automation-readiness.
Sample Answer
Direct answer
A reusable manual test case template needs ten fields: ID, title, preconditions, setup, steps, expected result, test data, postconditions, severity, and priority, each existing to answer a specific question a later reader (or an automation engineer converting the case) would otherwise have to guess.
Structured elaboration: the fields and why each matters
| Field | Purpose | Why it matters for traceability/automation |
|---|---|---|
| ID | A stable, unique identifier (e.g. TC-LOGIN-014) | Lets a bug report, a requirement, or a CI result reference this exact case unambiguously, surviving renames of the title |
| Title | A short, human-scannable summary | Lets someone triaging a failure list understand WHAT broke without opening every case |
| Preconditions | The system/account state required before the steps begin | Without this, a case is not independently repeatable: two testers running it from different starting states get different results |
| Setup | Concrete actions to REACH the preconditions (not just state them) | Converts an implicit assumption into an explicit, reproducible action, which is exactly what an automation script needs to encode |
| Steps | Numbered, unambiguous actions | The literal script a human or a machine follows; ambiguity here ("log in" without specifying which account) is the single most common cause of a flaky manual test |
| Expected result | The specific, checkable outcome per step (or at the end) | Distinguishes 'the test ran' from 'the test passed'; a vague expected result ("it should work") cannot be objectively verified |
| Test data | The exact input values used | Without this, the case cannot be re-run identically, which breaks reproducibility for both manual re-testing and automated regression |
| Postconditions | The state the system should be in after the test, and any required teardown | Prevents one test's leftover state (e.g. a locked account) from silently breaking the NEXT test that assumes a clean state |
| Severity | How bad the underlying defect would be if this case fails (e.g. crash vs. cosmetic) | An objective, defect-focused rating, useful for triage independent of business priority |
| Priority | How soon this specific test case should be run/fixed relative to others (e.g. in a time-boxed regression pass) | A separate, business-driven rating from severity; a low-severity cosmetic bug can still be high priority right before a demo |
Worked example: a login-form test case using this template
ID: TC-LOGIN-014
Title: Successful login with valid credentials
Preconditions: A user account exists (qa.demo@example.com / Passw0rd!2026) and is active
Setup: Navigate to https://app.example.com/login
Steps:
1. Enter 'qa.demo@example.com' in the Email field
2. Enter 'Passw0rd!2026' in the Password field
3. Click 'Log In'
Expected result: User is redirected to /dashboard; a welcome banner displays the account's first name
Test data: qa.demo@example.com / Passw0rd!2026
Postconditions: User session is active; log out to restore a clean pre-test state for subsequent cases
Severity: Critical (login is a blocking path for all other functionality)
Priority: P1 (run in every regression pass)
Trade-offs & pitfalls
A template with every field filled in verbosely for every trivial case creates its own maintenance burden; the discipline that actually matters is NEVER skipping preconditions, steps, and expected result (the three fields without which a case is not independently executable), while allowing lighter-weight handling of severity/priority for exploratory or low-risk cases where a full triage rating adds little value. Skipping 'setup' specifically (describing preconditions as a state without describing how to reach it) is the most common template violation in practice, and it is exactly the field automation-conversion depends on most, since a script needs concrete actions, not a narrative assumption.
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.