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.
Design tests and acceptance criteria to ensure strict tenant data isolation in a multi-tenant SaaS backend using a shared database. Include tests for tenant-scoped queries, role-based access checks, accidental cross-tenant joins, data export redaction, and privacy compliance (e.g., GDPR). Provide examples of negative tests that prove data cannot leak between tenants.
Sample Answer
Direct answer
Multi-tenant isolation in a shared database cannot be proven by testing that the RIGHT data comes back for the right tenant; it has to be proven by testing that the WRONG data is structurally impossible to get back, even when a query, a role, or an export path forgets to scope by tenant. That reframing (design negative tests that PROVE leakage cannot happen, not just positive tests that show correct data appearing) is the core of the answer, and it needs to be demonstrated at the query layer, the access-control layer, and the data-export layer separately, since each can leak independently of the others.
Structured elaboration
- Tenant-scoped query tests: every query path that touches tenant data gets a positive test (returns exactly that tenant's rows) AND a negative test (a query missing the tenant filter, simulating a real regression, returns data spanning MULTIPLE tenants and is explicitly asserted to fail an isolation check, not silently pass).
- Role-based access checks: a user authenticated as belonging to tenant A, attempting any operation scoped to tenant B's resources (by ID, by URL parameter, or by a tampered request), must be rejected at the authorization layer regardless of what the underlying query would have returned; this test specifically defends against a broken or bypassed application-layer check, which is why it needs to be independent of the query-level tests above.
- Accidental cross-tenant joins: a
JOINacross two tables that both carrytenant_idbut where only one side of the join is filtered is a realistic regression (a developer adds a join for a new feature and forgets to add the second table's tenant filter); a specific test should construct exactly this shape of query with tenant filtering deliberately removed from one side and assert cross-tenant rows appear, so the test suite has evidence this failure mode is monitored, not just theoretically understood. - Data export redaction: bulk export/report paths are a distinct risk surface from interactive queries, since they often run with elevated internal privileges (to aggregate across a whole table efficiently) and are easy to under-scope; test that an export triggered for tenant A's account contains zero rows, zero aggregate contributions, and zero column values sourced from any other tenant.
- Privacy compliance (e.g. GDPR, the EU's General Data Protection Regulation): tenant isolation intersects with data-subject rights, specifically the right to erasure and the right to data portability; a deletion or export request scoped to one tenant/data subject must not touch or expose another tenant's records, and this needs its own test distinct from ordinary CRUD isolation tests, since deletion cascades (foreign keys, soft-delete flags, downstream caches) are a common place for scope to silently widen.
Worked example
Executed for real against SQLite, a documents table with rows across 3 tenants (100, 200, 300):
-- NEGATIVE TEST 1: a correctly tenant-scoped query for tenant 100 returns ONLY its own rows.
SELECT id, tenant_id, title FROM documents WHERE tenant_id = 100;
-- Actual result: 2 rows (ids 1, 2), both tenant_id = 100. No other tenant present.
-- NEGATIVE TEST 2: the regression this whole test plan exists to catch: a query that
-- forgot the tenant_id filter (e.g. after a careless refactor) must be DETECTABLE.
SELECT COUNT(*) AS rows_returned, COUNT(DISTINCT tenant_id) AS distinct_tenants_returned
FROM documents;
-- Actual result: rows_returned = 5, distinct_tenants_returned = 3.
-- An automated isolation check asserting distinct_tenants_returned == 1 for any tenant-scoped
-- query path would FAIL this shape immediately, which is the point: the test infrastructure
-- itself should be able to catch a missing filter, not rely on a human noticing.
-- ACCEPTANCE CHECK: assert zero cross-tenant rows in the scoped query's result set.
SELECT CASE WHEN SUM(CASE WHEN tenant_id != 100 THEN 1 ELSE 0 END) = 0
THEN 'PASS: no cross-tenant rows' ELSE 'FAIL: leak detected' END AS result
FROM documents WHERE tenant_id = 100;
-- Actual result: 'PASS: no cross-tenant rows'
The key evidence here is the contrast between test 1 (correctly scoped, 2 rows, single tenant) and test 2 (unscoped, 5 rows, 3 distinct tenants): the acceptance check pattern (assert COUNT(DISTINCT tenant_id) = 1) is exactly the kind of generic, reusable assertion that should run against EVERY tenant-scoped query path in an integration test suite, not just this one example table, so a future regression on any endpoint trips the same check.
Trade-offs and pitfalls
The most common mistake is writing only positive tests ("tenant A sees tenant A's data") and treating that as proof of isolation; a positive test can pass for years while an unrelated new feature introduces a leak, because nothing in the positive test would ever notice extra rows showing up alongside the correct ones unless the assertion specifically checks for their ABSENCE. A second mistake is relying entirely on application-layer tenant scoping without a database-level backstop (like PostgreSQL row-level security policies, or a mandatory tenant_id predicate enforced by a query-building layer that cannot be bypassed); application-layer-only isolation means every new query in the codebase is a fresh opportunity to forget the filter, while a DB-level backstop turns a forgotten filter into a hard failure instead of a silent leak. Finally, remember that isolation tests need to cover WRITE paths, not just reads: an UPDATE or DELETE missing a tenant filter is a more severe incident than a leaked read, since it can silently corrupt or destroy another tenant's data, and the same "run it unscoped, assert the blast radius" testing pattern applies there too.
Describe unit tests (test cases and assertions, not implementation) for a function merge_sorted_arrays(a, b) that merges two sorted integer arrays. Cover empty arrays, single-element arrays, arrays with duplicates, different lengths, and arrays containing negative numbers.
Sample Answer
Direct answer
For merge_sorted_arrays(a, b), the edge cases to cover are both-empty, one-empty, single-element, duplicates (both within and across the two arrays), different lengths, and negative numbers, because each independently exercises a different branch of the two-pointer merge loop; alongside it, linked-list cycle detection is a second classic data-structure edge-case artifact worth pairing with it, because it tests the opposite skill (detecting an ill-formed structure rather than merging two well-formed ones) using the same "trace pointer movement by hand" reasoning discipline.
Structured elaboration
A standard two-pointer merge (i, j walking a and b, appending the smaller front element, then draining whichever array has leftovers) has three places a bug commonly hides:
- The loop-exit condition (
while i < len(a) and j < len(b)): if one input is empty, the loop body never runs, so ALL correctness has to come from the drain step (out.extend(a[i:]),out.extend(b[j:])) after the loop, not the loop itself. Both-empty and one-empty tests specifically exercise this drain path. - The tie-breaking comparison (
a[i] <= b[j]vsa[i] < b[j]): using strict<instead of<=still produces a correctly SORTED result on duplicate values, but silently reorders which array's copy of a tied value comes first, which matters if the merge needs to be stable (e.g. preserving a secondary sort key attached to each element). - The negative-number case doesn't exercise different code than positive numbers in a correct implementation, but it is a high-value test anyway because it catches implementations that reach for an unsigned-length-based trick (e.g. treating array length or magnitude as a sentinel) instead of a pure comparison-based merge.
Linked-list cycle detection (Floyd's slow/fast pointer technique) is a different function entirely, but belongs in the same edge-case-design conversation because its edge cases follow the identical decomposition logic: empty structure, single-node structure, and "does the malformed case actually loop the way you think it does" (a self-loop where a single node points to itself, versus a cycle that starts partway through the list rather than at the head).
Worked example (executed)
def merge_sorted_arrays(a, b):
i, j = 0, 0
out = []
while i < len(a) and j < len(b):
if a[i] <= b[j]:
out.append(a[i]); i += 1
else:
out.append(b[j]); j += 1
out.extend(a[i:])
out.extend(b[j:])
return out
class ListNode:
def __init__(self, val, next=None):
self.val = val
self.next = next
def has_cycle(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast:
return True
return False
| Function | Test | Input | Expected |
|---|---|---|---|
merge_sorted_arrays | both empty | [], [] | [] |
merge_sorted_arrays | one empty | [], [1,2,3] | [1,2,3] |
merge_sorted_arrays | single-element | [5], [3] | [3,5] |
merge_sorted_arrays | duplicates (across and within) | [1,2,2], [2,2,3] | [1,2,2,2,2,3] |
merge_sorted_arrays | different lengths | [1,5,9], [2,3] | [1,2,3,5,9] |
merge_sorted_arrays | negative numbers | [-5,-1,3], [-4,0,2] | [-5,-4,-1,0,2,3] |
has_cycle | empty list (head=None) | None | False |
has_cycle | single node, no cycle | one ListNode | False |
has_cycle | single node, self-loop | node.next = node | True |
has_cycle | cycle partway through a 4-node list | last node points back to node 2 | True |
All 10 tests executed with pytest: 10 passed in 0.01s.
Trade-offs and pitfalls
For the merge function, the most common wrong turn is testing only "different-length, no duplicates" happy-path inputs and treating that as sufficient because the algorithm LOOKS simple; the duplicate-across-arrays case specifically catches an off-by-one in whichever array's pointer advances first on a tie. For cycle detection, the common wrong turn is testing only a cycle that starts at the head, which passes even for a broken implementation that (incorrectly) checks slow == head instead of slow is fast; a cycle that starts partway through the list, as tested here, is the case that actually forces the fast/slow pointers to meet somewhere other than the entry node, proving the algorithm's core invariant rather than a coincidental match.
Explain the practice of writing two to three concrete test cases before or immediately after implementing a function. Why is this approach valuable for catching edge cases early? Provide a short example workflow for a simple algorithm (for instance, computing factorial or parsing CSV) showing the initial two to three example tests, expected outcomes, and how they guide implementing and validating corner cases.
Sample Answer
Direct answer
Writing two or three concrete example tests before, or immediately after, implementing a function forces you to state its contract in checkable form while you still remember the corner cases you were thinking about, rather than discovering them later once the code is already written around a narrower assumption.
Structured elaboration
This is a lightweight, test-driven-development-adjacent discipline, not full test-driven development; it does not require a strict red-green-refactor cycle for every function, but writing a small number of examples up front, rather than zero, and rather than a large exhaustive suite before any code exists, captures most of the benefit at low cost.
Why it catches edge cases early: choosing two or three examples forces a decision about the function's corner cases at the point of least sunk cost. Before writing factorial, you have to decide what factorial(0) returns and what happens for negative input, before you have written a loop that silently assumes the input is at least 1. If the implementation is written first, with no separate statement of expected behavior at those inputs, they are easy to skip entirely and never notice.
The mechanism: each example is a concrete input-output pair. A "typical" case establishes the mainline contract; a case known to be a corner case for the function's domain (zero, an empty collection, a boundary value) forces an explicit decision on it; a third case, invalid input, forces an explicit decision on error handling rather than leaving it implicit. The examples then serve two purposes: they are a mini-specification that clarifies your own thinking before implementation, and they become permanent regression tests that keep the corner-case decisions from silently regressing later.
Worked example (executed)
import pytest
def factorial(n: int) -> int:
if n < 0:
raise ValueError("factorial is undefined for negative numbers")
result = 1
for i in range(2, n + 1):
result *= i
return result
def test_factorial_typical_case():
assert factorial(5) == 120
def test_factorial_zero_case():
assert factorial(0) == 1 # mathematical convention: 0! = 1
def test_factorial_negative_raises():
with pytest.raises(ValueError):
factorial(-3)
All three PASSED: factorial(5) == 120, factorial(0) == 1, factorial(-3) raises ValueError.
Notice what the process caught: writing factorial(0) as an example before finishing the implementation is what surfaces the question "does my loop naturally return 1 for n=0, or does it need a special case?" A for i in range(2, n + 1) loop naturally returns the initial result = 1 unmodified when n = 0, since the range is empty, so no special case is needed here. That is exactly the kind of fact you only know you have to check because you wrote the example first, rather than something you would necessarily verify by writing only the mainline test.
Trade-offs and pitfalls
- Two or three examples establish the contract; they are not a substitute for the fuller systematic techniques (boundary value analysis, equivalence partitioning) that belong in the rest of the suite. This practice catches the most damaging early omissions cheaply, it does not achieve coverage on its own.
- Picking three examples that are all typical,
factorial(3),factorial(4),factorial(6), defeats the purpose entirely. The value depends specifically on choosing at least one genuine corner case for the function's domain, which requires actually thinking about that domain, not picking arbitrary numbers. - For a function with a large or messy domain, parsing arbitrary CSV (comma-separated values) input for instance, two or three examples chosen at the start can anchor you to a too-narrow view of "corner case." Treat the initial set as a living list you add to as more real-world messiness turns up, not a one-time exercise.
- This practice front-loads the decision but not necessarily the implementation. A team can decide
factorial(-3)should raise, write the test, and then never actually add the guard clause; the examples only help if they are run and enforced, not merely written down and forgotten.
Discuss limitations and blind spots of property-based testing (PBT), such as difficulty modeling stateful multi-service interactions, non-deterministic IO, and complex performance invariants. For each limitation propose complementary testing techniques and describe how a Solutions Architect should combine them to build robust test coverage.
Sample Answer
Direct answer
Property-based testing (PBT), the technique of asserting general invariants and letting a framework generate many random inputs rather than hand-writing individual example-based cases, has three structural blind spots: it struggles to model stateful interactions across multiple services, it cannot meaningfully generate or reason about non-deterministic I/O, and it does not naturally express complex performance invariants. Each has a complementary technique that covers the gap, and a senior answer's job is knowing which combination to reach for rather than treating PBT as a universal replacement for other testing styles.
Structured elaboration
Stateful multi-service interactions. Classic PBT (a pure function with generated inputs, checked against a property) models a single component's behavior well, but a distributed system's correctness often depends on the interleaving of operations ACROSS services with independent state and independent failure modes, which is a much larger and less tractable generation space than "random valid inputs to one function." Stateful PBT extensions exist (modeling a sequence of commands against an abstract model of the system and checking the real system matches at each step) but scale poorly once more than one or two services are involved, because the state space to explore grows with the product of each service's own state space. Complementary techniques: contract testing (each service's interface is tested against a shared, versioned contract independent of the other services' actual behavior) narrows the cross-service surface to just the interface; and chaos/fault-injection testing at the system level (deliberately injecting latency, partial failures, or partitions between real running services) exercises the actual interleavings PBT cannot economically generate.
Non-deterministic I/O. A property test wants a pure, repeatable relationship between generated input and expected output; a network call, wall-clock read, or unordered concurrent write breaks that repeatability, since the same generated input can legitimately produce different observable results on different runs. PBT frameworks handle SOME non-determinism by controlling it explicitly (a fixed seed for a random-number generator used inside the code under test, a mocked clock), but true external non-determinism (a real network's latency and ordering) isn't something a property assertion can pin down. Complementary techniques: dependency injection of the non-deterministic source (inject a fake clock/network so the "non-determinism" becomes just another generated input PBT CAN control) where feasible, and for the cases where it genuinely cannot be pinned down, invariant-based monitoring in production or in a longer-running integration environment (assert the invariant continuously over real traffic rather than trying to reproduce it from a generated seed).
Complex performance invariants. "This function's output is correct" is a property PBT expresses naturally; "this function's p99 latency stays under a bound as load scales" is not, because performance is an aggregate, environment-dependent property across many calls, not a single input/output relationship, and asserting a specific latency number in a test is explicitly the kind of unreliable, environment-dependent claim a rigorous test suite avoids. Complementary techniques: load/performance testing tools that measure aggregate behavior under controlled load (and assert on RELATIVE regressions or algorithmic complexity, e.g. "doubling input size should not more than double comparison count," rather than absolute wall-clock numbers), and benchmark-based regression tracking over time in a controlled environment rather than as a pass/fail unit-test assertion.
Worked example
A payment-processing change touches three services (an order service, a payment gateway adapter, and a ledger service) and also changes a hot-path calculation function. PBT is the right tool for the calculation function alone: generate a wide range of amounts, currencies, and rounding-edge values and assert the calculation is associative and matches a reference decimal implementation to the cent. It is the wrong tool, on its own, for whether the order service, gateway adapter, and ledger correctly agree after a gateway timeout followed by a retry, since that depends on the actual interleaving of network calls across three independently-deployed services; a contract test asserts the gateway adapter's retry behavior matches its documented interface in isolation, and a chaos test that injects a real timeout between the gateway adapter and the ledger service, run against actual deployed instances (or a realistic staging topology), is what actually exercises the interleaving. Neither the calculation's correctness property nor the cross-service chaos scenario substitutes for the other; a Solutions Architect combining them treats PBT as the tool for the pure computational core and reaches for contract tests and chaos/fault injection specifically at the service boundaries PBT cannot economically reach, rather than trying to stretch one technique to cover every layer.
Trade-offs and pitfalls
The common mistake is treating PBT's blind spots as reasons to avoid it rather than reasons to scope it correctly: PBT genuinely finds edge cases in pure logic that example-based tests miss (a well-known, real strength), and abandoning it because it cannot cover the whole system throws away that strength unnecessarily. The opposite mistake, forcing PBT to cover stateful multi-service behavior via ever more elaborate model-based state machines, tends to produce a test suite that is slow, flaky, and hard to debug when it fails, because the failure could be in the real system, the abstract model, or the generator itself, and untangling which one takes real effort; past a certain system complexity, the return on that effort is lower than building a focused contract test plus a focused chaos scenario. The senior framing is to match each technique to the shape of the risk it is actually good at finding, not to pick one technique as the team's default and stretch it everywhere.
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.
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.