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.
Binary search is simple but prone to subtle bugs. Enumerate edge cases you would test for a classic binary_search(arr, target) implementation: empty array, one element, two elements, target at boundaries, not present, repeated elements, and mid calculation overflow. Provide specific inputs that would reveal a bug if mid is computed as (low+high)/2 in a 32-bit signed integer implementation.
Sample Answer
Direct answer
Beyond the standard empty/single/two-element/target-at-boundaries/not-present/duplicates cases, binary search has one classic implementation-specific bug: computing the midpoint as (low + high) / 2 overflows a fixed-width signed integer once low + high exceeds the type's maximum, silently producing a negative or wrapped midpoint; the fix is low + (high - low) / 2, which can never overflow because the intermediate value stays within the array's index range.
Structured elaboration and worked example (executed; the original draft's while/if/elif/else body was flattened to a single indent level and did not parse as Python at all, fixed below)
def binary_search_safe(arr, target):
low, high = 0, len(arr) - 1
while low <= high:
mid = low + (high - low) // 2 # overflow-safe
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
Edge cases and results, all executed and passing:
- Empty array:
binary_search_safe([], 5) == -1 - One element (found and not found):
binary_search_safe([7], 7) == 0,binary_search_safe([7], 3) == -1 - Two elements, target at boundaries:
binary_search_safe([1,5], 1) == 0,binary_search_safe([1,5], 5) == 1 - Target at first/last index of a larger array: confirmed on
[1,3,5,7,9,11,13] - Not present:
binary_search_safe([1,3,5,7,9,11,13], 4) == -1 - Repeated elements:
binary_search_safe([2,2,2,2,2], 2)returns SOME correct index (any is acceptable unless the contract specifies first/last occurrence, which needs its own explicit test if required)
Mid-calculation overflow, demonstrated concretely
Python integers never overflow, so the overflow bug can't reproduce natively in Python; it was simulated explicitly as it would behave in a 32-bit signed language (Java int, C int):
low, high = 1_500_000_000, 1_500_000_000 # low+high = 3,000,000,000 > INT32_MAX (2,147,483,647)
correct_mid = low + (high - low) // 2 # = 1,500,000,000
# simulated 32-bit signed wraparound of (low+high)//2:
Executed result: low+high = 3000000000 (exceeds INT32_MAX = 2147483647); the overflow-safe formula correctly computes mid = 1500000000; the simulated 32-bit-signed-wraparound formula instead produces mid = -647483648, a NEGATIVE index. In a real Java or C implementation using (low + high) / 2 on int, this specific input (a large array with both low and high above roughly 1.07 billion, achievable in an array with more than ~2.1 billion elements, or more realistically whenever low and high are both large due to a huge search space) would compute a negative or otherwise invalid midpoint and crash with an out-of-bounds access or return an incorrect result, a bug that famously shipped in real production binary-search implementations (documented in Java's own Arrays.binarySearch history) for years before being fixed.
Trade-offs & pitfalls
This bug is specifically invisible on small test arrays: any array under roughly a billion elements never drives low + high past INT32_MAX, so a test suite exercising only 'reasonable-sized' arrays (which is the overwhelming majority of real test suites) will never catch it; the only reliable way to test for it is to reason about the arithmetic directly (as done here) rather than trying to construct an actual multi-gigabyte array, which is why this is one of the rare cases where an ANALYTICAL proof of the formula's safety is the practical test, not an executed one at full scale.
Explain floating-point comparison pitfalls in software, including rounding and representation differences. Provide test strategies and code-level best practices an SDET should apply when writing assertions that compare floats in unit and integration tests, including examples of relative and absolute epsilon checks.
Sample Answer
Direct answer
Floating-point numbers cannot exactly represent most decimal fractions (0.1 in binary floating point is a repeating fraction, just as 1/3 is in decimal), so comparing floats with strict equality (==) is unreliable; the fix is to compare within a tolerance, using a RELATIVE tolerance for large-magnitude values and an ABSOLUTE tolerance as a floor for values near zero, since either one alone fails in a different regime.
Structured elaboration and worked example (executed)
import math
def approx_equal(a, b, rel_tol=1e-9, abs_tol=1e-12):
return math.isclose(a, b, rel_tol=rel_tol, abs_tol=abs_tol)
print("0.1 + 0.2 == 0.3 ->", 0.1 + 0.2 == 0.3)
print("0.1 + 0.2 =", repr(0.1 + 0.2))
print("approx_equal(0.1+0.2, 0.3) ->", approx_equal(0.1+0.2, 0.3))
Running this:
0.1 + 0.2 == 0.3 -> False
0.1 + 0.2 = 0.30000000000000004
approx_equal(0.1+0.2, 0.3) -> True
The strict-equality check is False even though the values are 'the same' for any practical purpose, because 0.1 and 0.2 cannot be represented exactly in binary floating point and their sum accumulates a tiny representation error.
Why relative tolerance alone fails near zero (executed)
a, b = 1e-300, 2e-300
print("math.isclose(a,b) default (rel only) ->", math.isclose(a, b))
print("math.isclose(a,b, abs_tol=1e-12) ->", math.isclose(a, b, abs_tol=1e-12))
Actual output:
math.isclose(a,b) default (rel only) -> False
math.isclose(a,b, abs_tol=1e-12) -> True
Two numbers that are both astronomically small but differ by a factor of 2 (1e-300 vs 2e-300) fail a relative-tolerance-only check, correctly by relative-difference logic, but this is almost always NOT what a test author actually wants: near zero, tiny absolute differences are usually noise, not a meaningful failure, which is why an absolute tolerance floor is needed as a companion check.
Why absolute tolerance alone fails for large numbers (executed)
c, d = 1e15, 1e15 + 100
print("abs diff:", abs(c - d))
print("relative diff:", abs(c - d) / max(abs(c), abs(d)))
print("math.isclose(c, d, rel_tol=1e-9) ->", math.isclose(c, d, rel_tol=1e-9))
print("abs(c-d) < 0.01 ->", abs(c - d) < 0.01)
Actual output:
abs diff: 100.0
relative diff: 9.999999999999e-14
math.isclose(c, d, rel_tol=1e-9) -> True
abs(c-d) < 0.01 -> False
An absolute-tolerance-only check (e.g. abs(a - b) < 0.01) would FAIL this case, flagging two numbers that differ by only about one part in ten trillion as unequal, purely because their magnitude is large; relative tolerance correctly recognizes this as an insignificant difference.
Code-level best practices
- Use a library function (
math.isclosein Python,assertAlmostEqual/an epsilon-based custom matcher elsewhere) rather than hand-rollingabs(a-b) < 0.0001, since a hardcoded absolute epsilon silently breaks at both extremes shown above. - Always pass BOTH
rel_tolandabs_tolexplicitly rather than relying on library defaults, and chooseabs_tolbased on the smallest meaningful magnitude your domain actually produces (a physics simulation and a financial percentage calculation have very different notions of 'negligible'). - Never use exact equality on any float that has passed through at least one arithmetic operation (addition, division, an accumulated sum); exact equality is only safe for a float that was directly assigned a literal and never recomputed.
Trade-offs & pitfalls
A tolerance that is too loose can mask a genuine regression (a calculation that is now systematically off by a small but real amount gets silently accepted), while a tolerance that is too tight reintroduces flaky test failures from ordinary floating-point noise across platforms or numeric library versions; the tolerance value itself is a design decision that belongs in code review, not a default nobody revisits.
Design tests for a paginated API that supports both offset/limit and cursor-based pagination. Include edge cases such as empty result sets, page boundary items, items added or deleted between page requests, limit=0 or >max, and duplicated or missing items across pages. Describe integration tests, consistency checks, and how to simulate concurrent writes during pagination in tests.
Sample Answer
Direct answer
Offset/limit and cursor-based pagination fail differently under concurrent writes, so testing "pagination" as one thing misses the point: offset/limit pagination is vulnerable to items shifting position between page requests (a delete before the current offset skips an item on the next page, an insert duplicates one), while cursor-based pagination avoids that specific failure by design but has its own edge cases around cursor staleness and boundary-item ordering. A test suite needs to cover both approaches' shared edge cases (empty results, limit boundaries) and each approach's DISTINCT failure mode under concurrent modification.
Structured elaboration
| Edge case | Offset/limit behavior | Cursor-based behavior |
|---|---|---|
| Empty result set | Returns an empty page and a total count of zero (if the API returns a total); the client must not treat this as an error | Returns an empty page with no next cursor; the client's "has more" signal must correctly read as false, not merely "cursor is null" being misinterpreted as an error |
limit=0 | Ambiguous: could mean "return zero items" (edge case, technically valid) or should be rejected as invalid input; a test suite must assert the API's DOCUMENTED choice, not just that it does not crash | Same ambiguity; a cursor-based API must additionally decide whether a zero-limit request still advances or returns a cursor at all |
limit exceeds the maximum allowed | Server must clamp to the max (and document that it clamps) or reject with an explicit error; silently accepting an unbounded limit risks an unbounded response size and query cost | Same requirement; test that the returned page size, not just the request, actually respects the clamp |
| Items added between page requests | An insert before the current offset shifts every subsequent item's position by one, causing the client's next offset-based request to SKIP the item that shifted into the position it already saw, a duplicate is comparatively rare, a skip is the common failure | Not vulnerable in the same way: a cursor anchored to a specific item's position (rather than a numeric offset) is unaffected by inserts elsewhere in the set, this is cursor-based pagination's core advantage and should be the specific claim a test proves, not merely asserted |
| Items deleted between page requests | A delete before the current offset shifts subsequent items backward, causing the client's next request to SKIP an item it has not seen yet (the item that shifted into an already-consumed offset) | If the specific item the cursor was anchored to is deleted, the API must define and the client must handle what happens next (skip to the next item after the deleted anchor, or return an explicit "cursor invalid" error); untested, this is where cursor-based pagination's own edge case hides |
| Page boundary items (ties on the sort key) | Two rows with an identical sort-key value can be split across a page boundary in a different relative order on each request if the underlying query has no deterministic tiebreaker | Same risk: a cursor built from a non-unique sort key can duplicate or skip a tied row across pages; the fix for both approaches is the same, always include a unique tiebreaker column (e.g. primary key) in the sort order, never sort on a non-unique column alone |
| Duplicated or missing items across pages | Symptom of the insert/delete-shift problems above, or of the missing-tiebreaker problem; the test that actually catches this is fetching all pages of a KNOWN dataset and asserting the reassembled full set has no duplicates and no gaps against the known input | Same test approach applies; the assertion is identical even though the underlying cause differs by pagination style |
Worked example: simulating a concurrent write during pagination
A test with a known 10-row dataset sorted by created_at, page size 3, using offset/limit: fetch page 1 (rows 1-3, offset 0). Before fetching page 2, delete row 2. Fetch page 2 with offset=3: because row 2 shifted every subsequent row's position back by one, what the client receives at offset=3 is now the ORIGINAL row 5, and the original row 4 (which the client has not yet seen) was silently skipped, having shifted into the offset-3 slot only to be immediately passed over as the client moves to offset=6 next. Reassembling all pages after fetching them all in sequence and diffing against the known 10-row set (minus the deleted row) will show exactly one row missing (the original row 4), which is the assertion the test should make, not merely "no error was thrown," since this failure mode produces no error at all.
Trade-offs and pitfalls
The most common wrong turn is testing cursor-based pagination and offset/limit pagination with the exact same test suite and assuming a pass on one implies correctness on the other; the concurrent-modification edge case specifically does NOT generalize between them, and a suite that only tests them identically will never catch the offset-based skip-on-delete failure, because the cursor-based implementation genuinely does not have that bug. A second pitfall is never testing with a non-unique sort key, since a demo dataset with obviously distinct timestamps hides the tie-breaking bug that only appears once two rows genuinely share a sort-key value, common in real data (e.g. many rows created within the same millisecond under load). A third is simulating concurrent writes only as "before fetching page 1" rather than genuinely between sequential page requests, which is the actual race window a client experiences in production.
Write unit tests in Python using pytest for the following function signature: def normalize_username(s: str) -> str. The function should trim whitespace, lower-case the string, and replace consecutive internal spaces with a single underscore. Provide 5 test cases including edge, empty, and unicode inputs.
Sample Answer
Direct answer
A normalize_username test suite needs at least five cases: whitespace trimming, case folding, collapsing multiple internal spaces to a single underscore, an empty-string input, and a unicode input, each verifying one specific transformation the function claims to perform.
Structured elaboration and worked example (executed)
import re
import pytest
def normalize_username(s: str) -> str:
s = s.strip().lower()
s = re.sub(r' +', '_', s)
return s
@pytest.mark.parametrize("input_s,expected", [
(" Alice Smith ", "alice_smith"), # leading/trailing whitespace + internal space
("", ""), # empty string
("BOB", "bob"), # case-only
("multi internal spaces", "multi_internal_spaces"), # consecutive spaces collapse to ONE underscore
("Café Müller", "café_müller"), # unicode: accents preserved, only case+space normalized
])
def test_normalize_username(input_s, expected):
assert normalize_username(input_s) == expected
Running pytest -v: 5 passed in 0.15s (all 5 parametrized cases PASSED).
Why each case matters
- Leading/trailing whitespace + internal space in one case: confirms
.stripand the internal-space collapse both apply, and specifically that the OUTER whitespace does not itself become a leading/trailing underscore (a common bug: stripping after substitution instead of before would turn" Alice Smith "into"_alice_smith_"). - Empty string: the trivial identity case; confirms the function doesn't throw on an empty input, which a naive regex-only implementation with no length guard could plausibly do depending on the regex engine, though this simple implementation happens to handle it safely.
- Case-only: isolates the
.lowerbehavior from the whitespace logic, so a failure here specifically points at case-folding, not spacing. - Multiple internal spaces: this is the case most likely to be under-tested; a naive
.replace(' ', '_')(not a regex with+) would produce"multi___internal___spaces"(one underscore per space) instead of the collapsed single-underscore form, so this test specifically distinguishes a regex-based collapse from a naive single-character replace. - Unicode input: confirms accented characters are preserved through
.lower(Python's.loweris unicode-aware by default, correctly lowercasing 'É' to 'é'), rather than being stripped, mangled, or requiring a separate ASCII-only code path.
Trade-offs & pitfalls
The unicode test above uses .lower, which is adequate for accented Latin characters but is a WEAKER transform than .casefold for languages with more complex case-folding rules (e.g. German 'ß', which .casefold maps to 'ss' but .lower leaves unchanged); if the real system needs to treat visually-distinct usernames as the same account across such languages, the test suite should include a .casefold-specific case (like 'ß' vs 'ss') to pin down which behavior is actually intended, since the two functions genuinely disagree on some real-world inputs.
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.
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.