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.
You aggregate billions of rows computing counts and sums. Describe edge cases that can cause integer overflow or precision loss (32-bit overflow, float accumulation error, large SUM beyond type range). What defensive checks, data types (bigint/decimal), and monitoring would you implement? How would you write tests to catch overflow before production?
Sample Answer
Direct answer
At billions-of-rows scale, three distinct numeric failure modes hide behind "the total looks wrong": 32-bit integer overflow on counts or small-magnitude sums, 64-bit integer overflow on genuinely huge sums, and floating-point accumulation error from repeatedly adding many floats. Each needs a different defensive fix (wider integer types, arbitrary-precision decimal types, or compensated summation), so the first job is diagnosing which one you actually have.
Structured elaboration
| Failure mode | Trigger | Defensive fix |
|---|---|---|
| 32-bit overflow | A COUNT or SUM column typed as a 32-bit signed integer (INT) exceeds 2,147,483,647 | Use bigint (64-bit signed integer) for any count/sum column that could plausibly cross a few billion |
| 64-bit overflow | A SUM over enough large values grows beyond the type's range, past roughly 9.22 x 10^18 (BIGINT max) | Use an arbitrary-precision DECIMAL/NUMERIC type, or detect the risk and pre-aggregate in tiers before a final combine |
| Float accumulation error | Repeatedly adding many FLOAT/DOUBLE values; the rounding error of each addition compounds, and adding a large running total to a small increment can drop the increment entirely | Use fixed-point DECIMAL for anything that must be exact (money, especially), or accept float and validate against a DECIMAL-computed reference within a defined tolerance |
Multi-year cents-column overflow is the sharpest concrete instance of the 32-bit case: a revenue_cents INT column accumulating for years, in cents rather than dollars, hits the billion mark two orders of magnitude sooner than a dollars-denominated column would, since every dollar is 100 cents. A table doing a few million dollars a year in cents crosses INT range in low tens of years without anyone noticing until a SUM silently goes negative.
General financial-metric rounding bugs are the float-accumulation case applied to derived metrics rather than raw sums: computing a percentage or an average incrementally (running-total-divided-by-running-count, updated per row) instead of from the final totals compounds rounding error differently than a single division at the end would, and the two methods can diverge measurably over billions of rows even though both look "correct" in isolation.
Defensive checks and monitoring: validate column type against the realistic multi-year projected max at design time, not just current volume; add a pipeline-level sanity check that flags any aggregate within, say, 80% of its column type's max as a maintenance signal before it becomes an incident; monitor for NULL/NaN/infinite values entering aggregation paths, since a single bad row can poison a SUM; track the fraction of rows whose magnitude is unusually large as an early-warning signal for schema drift (e.g. a units change from dollars to cents upstream).
Worked example
A billing pipeline sums a revenue_cents column typed INT (32-bit signed, max 2,147,483,647). At $50M/year in revenue, that's 5,000,000,000 cents/year, which alone exceeds INT range within the first year, well before "multi-year." The fix is bigint (max ~9.22 x 10^18 cents, or about $92 quadrillion, functionally unbounded for this use case) or storing the column in dollars as DECIMAL(18,2) if downstream systems need exact cents-level precision without binary-float rounding.
A test suite for this should include: a unit test inserting a value one below the 32-bit boundary and confirming correct behavior; a unit test inserting a value one above it against a bigint-typed column and confirming it is accepted (not silently truncated); an integration test that sums a large synthetic batch and compares the result against a DECIMAL-computed reference sum, failing if they diverge by more than a defined tolerance; and a regression test replaying a known historical multi-year total to catch any future schema or type regression.
Trade-offs & pitfalls
Widening every numeric column to bigint/DECIMAL by default has real costs (storage, and slower joins/sorts on wider keys), so the right move is targeting columns with real growth risk, not blanket widening. A common mistake is validating a column's type against current data volume rather than a multi-year projection, which is exactly how a cents-column overflow surprises a team years after launch. Another common mistake is trusting a FLOAT/DOUBLE sum because it "looks close enough" in a quick manual check, when the actual production risk is a specific pattern (very large running total plus very small per-row increment) that a spot check on a handful of rows won't surface, only a systematic DECIMAL-reference comparison over the full aggregate will.
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.
You are an ML engineer receiving a new tabular dataset for training a high-stakes classification model. Enumerate all categories of edge cases you should consider in the data and preprocessing pipeline before training (examples: empty columns, single-unique-value features, duplicated rows, negative values where only positives expected, NaNs, extreme outliers, categorical level mismatches, timezone issues, label leakage). For each category: explain why it matters, give one concrete mitigation, and describe how you'd write an automated test to detect it. Assume numeric, categorical and timestamp fields.
Sample Answer
Direct answer
A high-stakes tabular dataset needs edge-case review across five categories before training: structural anomalies (empty columns, single-unique-value features, duplicated rows), value anomalies (unexpected negatives, NaNs, extreme outliers), categorical-specific issues (unseen or mismatched levels between train and score time), temporal issues (timezone inconsistencies), and the highest-stakes category, label leakage (a feature that encodes information from the future or from the label itself).
Structured elaboration: category, mitigation, and detection test
| Category | Why it matters | Mitigation | Automated detection test |
|---|---|---|---|
| Empty columns (all-null) | Contributes zero signal, can crash certain encoders (e.g. a scaler dividing by zero variance) | Drop the column, or flag for investigation before training | Assert df[col].notna.sum > 0 for every column, fail the pipeline otherwise |
| Single-unique-value feature | Zero variance, no predictive value, can break standardization (division by zero std) | Drop or explicitly flag as constant | Assert df[col].nunique > 1 for every numeric feature |
| Duplicated rows | Inflates the effective weight of those examples, can leak the same example across train/validation splits if duplicated before splitting | Deduplicate before splitting, or explicitly split by a stable entity key rather than randomly | Assert df.duplicated.sum == 0 after the intended-unique key, or explicitly document and test the acceptable duplication rate |
| Unexpected negatives (e.g. an 'age' or 'count' column) | Signals either a data-quality bug upstream or a genuine domain exception (e.g. a signed adjustment column) that must be distinguished | Validate against a documented expected sign per column | Assert (df['age'] >= 0).all for a column documented as non-negative |
| NaNs | Silently propagates through most ML libraries as either an error or, worse, a silently-dropped row, changing the effective training set size unexpectedly | Explicit imputation strategy (documented, not implicit) or explicit rejection | Assert the NaN rate per column against a known-acceptable threshold, not just df.isna.sum==0 blindly (some NaN is expected and handled) |
| Extreme outliers | Can dominate a loss function (especially for non-robust losses like MSE) or indicate a unit-conversion bug (e.g. a value in cents mixed with a column in dollars) | Winsorize/clip, or investigate and fix the upstream unit bug | Assert every numeric column's max value against a documented plausible range (e.g. 'age' should never exceed 130) |
| Categorical level mismatch | A category seen at scoring time but never seen during training breaks a fixed-vocabulary encoder (e.g. one-hot) or silently gets treated as an unknown/default in ways that can be wrong | An explicit 'unknown category' bucket built into the encoder, tested at both train and score time | A test that scores a synthetic example containing a category NOT present in the training vocabulary, and asserts the pipeline handles it (an explicit unknown-bucket output), not a crash |
| Timezone issues | A timestamp field mixing naive and timezone-aware values, or values from multiple source timezones, can silently corrupt any time-based feature (day-of-week, time-since-event) | Normalize all timestamps to UTC at ingestion, enforced at the schema level | Assert every timestamp column is timezone-AWARE (not naive) after ingestion, rejecting the pipeline run otherwise |
| Label leakage | The single most damaging category: a feature that is only available AFTER the label is known (e.g. a 'resolution time' feature when predicting whether a support ticket will be resolved) inflates offline metrics dramatically while being useless or actively wrong in production | A feature-availability-timestamp audit: every feature must have a documented 'available as of' time strictly BEFORE the label's determination time | An automated test asserting, for every feature, that its documented availability timestamp precedes the label's determination timestamp for every training example |
Worked example: the label-leakage test in more detail
For a churn-prediction model, a feature like 'number of support tickets in the 30 days after the churn decision' would trivially predict churn (customers who already churned stop generating support tickets) while being completely unusable in production, since that information doesn't exist yet at prediction time; the automated test framework described above (asserting a feature's availability timestamp against the label's determination timestamp) is exactly what catches this class of bug BEFORE a model ships with deceptively excellent offline metrics that collapse in production.
Trade-offs & pitfalls
A common shortcut is running these checks manually once during initial data exploration and never again; because a high-stakes pipeline is typically RETRAINED periodically on fresh data, every one of these checks needs to run as an automated GATE in the retraining pipeline itself, not a one-time human review, since a new data source or an upstream schema change can silently reintroduce any of these issues (most dangerously, label leakage, if a new feature is added later without the same availability-timestamp discipline being applied).
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.
Using Hypothesis (property-based testing) in Python, design tests for a data normalization pipeline that must satisfy invariants: normalized outputs lie within [0,1], inverse_transform retrieves original values within tolerance for valid inputs, and invalid inputs raise predictable errors. Provide example Hypothesis strategies for input generation, explain shrinking and how it helps, and describe CI integration strategies to avoid flakiness.
Sample Answer
Direct answer
For a normalization pipeline with the stated invariants, three Hypothesis properties directly encode them: normalized outputs always fall within [0,1] for valid inputs, inverse_transform recovers the original value within a numeric tolerance, and invalid inputs (an empty/degenerate range) always raise a predictable error rather than producing a silently wrong number.
Structured elaboration and worked example (executed)
from hypothesis import given, strategies as st, settings
import math
def normalize(x, lo, hi):
if hi <= lo:
raise ValueError("hi must be > lo")
if x < lo or x > hi:
raise ValueError("x out of [lo, hi] range")
return (x - lo) / (hi - lo)
def inverse_transform(y, lo, hi):
if y < 0 or y > 1:
raise ValueError("y out of [0,1] range")
return lo + y * (hi - lo)
lo_strategy = st.floats(min_value=-1e6, max_value=1e6, allow_nan=False, allow_infinity=False)
span_strategy = st.floats(min_value=1e-3, max_value=1e6, allow_nan=False, allow_infinity=False)
frac_strategy = st.floats(min_value=0, max_value=1, allow_nan=False, allow_infinity=False)
@given(lo=lo_strategy, span=span_strategy, frac=frac_strategy)
@settings(max_examples=200, deadline=None)
def test_normalized_output_in_unit_range(lo, span, frac):
hi = lo + span
x = lo + frac * span
y = normalize(x, lo, hi)
assert -1e-9 <= y <= 1 + 1e-9
@given(lo=lo_strategy, span=span_strategy, frac=frac_strategy)
@settings(max_examples=200, deadline=None)
def test_round_trip_within_tolerance(lo, span, frac):
hi = lo + span
x = lo + frac * span
y = normalize(x, lo, hi)
x2 = inverse_transform(y, lo, hi)
assert math.isclose(x, x2, rel_tol=1e-6, abs_tol=1e-6)
@given(lo=lo_strategy, span=span_strategy)
@settings(max_examples=200, deadline=None)
def test_invalid_range_raises_predictably(lo, span):
hi = lo + span
try:
normalize(0.0, hi, lo) # deliberately swap so hi <= lo: an invalid/degenerate range
except ValueError:
pass
else:
raise AssertionError("expected ValueError for hi <= lo")
Running this against 200 generated examples per property (re-executed after fixing the original draft, whose function bodies were flattened to a single indent level, and whose second property wrote @given(lo=..., span=..., frac=...), a literal Python Ellipsis rather than the actual strategies, which raises InvalidArgument: passed lo=... for test_round_trip_within_tolerance, but lo has no type annotation before ever running an example; the original draft's third invariant, that invalid inputs raise a predictable error, was also asserted only in prose with no corresponding test at all): ALL 3 Hypothesis property-based tests PASSED (200 examples each). Note the generation STRATEGY deliberately constructs x FROM lo and hi (as lo + frac * span) rather than generating all three independently and filtering, which avoids Hypothesis discarding most generated examples as invalid (a common inefficiency called 'assume-heavy' testing) and instead guarantees every generated example is a genuinely valid input.
Demonstrating shrinking (executed)
A deliberately buggy variant (x == hi returns 1.00001 instead of exactly 1.0, a boundary bug) was run against a property asserting normalize(hi, lo, hi) == 1.0. Hypothesis found and shrank the failing example, printing (actual Hypothesis output, not a paraphrase):
Falsifying example: test_max_is_exactly_one(
lo=0.0,
span=1.0,
)
i.e. it converged on lo=0.0, hi=lo+span=1.0, the smallest, most readable input that still exposes the bug, exactly as claimed.
Shrinking matters because Hypothesis's random search naturally stumbles onto failures with large, complicated values first (e.g. lo=-483920.7, hi=291032.9); the shrinking phase then automatically simplifies that failing example toward the smallest, most readable input that STILL fails (here, lo=0.0, hi=1.0), which is dramatically easier for a human to reason about and turn into a permanent regression test.
CI integration strategies to avoid flakiness
- Pin Hypothesis's random seed in CI (via its
derandomizesetting or an explicit seed) so a failure is reproducible across runs, not a one-off flake that vanishes on retry. - Cap
max_examplesto a bounded, CI-time-appropriate number (e.g. 200, as used above) rather than letting property tests run unboundedly long, and reserve a larger, slower nightly run for deeper exploration. - Persist Hypothesis's failure DATABASE (the specific counterexamples it has found) as a CI artifact/cache, so a previously-found bug's exact input is re-tried FIRST on every subsequent run, catching regressions immediately rather than waiting for random rediscovery.
Trade-offs & pitfalls
A property-based suite gives strong confidence across a wide input space but is not a substitute for a handful of hand-picked example-based tests at the EXACT boundaries (x==lo, x==hi, an empty range); Hypothesis's floating-point strategies bias toward 'interesting' values (zero, tiny, huge) somewhat automatically, but explicitly asserting the boundary cases as separate, named example-based tests keeps them from silently depending on the random search happening to hit them.
Unlock Full Question Bank
Get access to all 21 Test Case Design and Edge Case Analysis interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.