Design the Test Cases
The previous page produced a single artifact: a ranked risk register with eleven named ways Snip can fail, R1 through R11. A register, though, is a list of worries, not a list of checks. It says “code collision could break this”; it does not say what to send, what to expect back, or how many variations of the input are worth trying. This page closes that gap.
The output here is a test plan: a table of concrete cases, each written as a (precondition, input, expected output) triple so unambiguous that a machine — or a teammate who has never seen Snip — could run it and grade it. And we want the smallest such table that still covers every risk, because a test suite is code, and every case in it must be read, maintained, and run forever. The whole craft of this page is getting maximum coverage from minimum cases. Three classic techniques do exactly that: equivalence partitioning, boundary-value analysis, and decision tables.
The problem: the inputs are infinite, the tests can’t be
Section titled “The problem: the inputs are infinite, the tests can’t be”Snip’s url field is a string. There are more possible strings than we could test in the lifetime of the universe. So “test every input” is not a plan — it is a fantasy. The real question is: which handful of inputs, if they all pass, justify believing the infinite rest would too?
The answer rests on one assumption that runs through all three techniques:
Software treats inputs in classes. Within a class, the code takes the same branch, so one input from the class exercises the same path as any other. Bugs live at the edges between classes, not scattered uniformly across the infinite middle.
If that assumption holds — and for the vast majority of real code it does — then testing one representative per class, plus the exact edges between classes, catches almost everything, for a cost that stays finite. Everything below is that idea applied three ways.
Equivalence partitioning: collapse infinity into a few classes
Section titled “Equivalence partitioning: collapse infinity into a few classes”Equivalence partitioning takes an infinite input space and cuts it into a small number of equivalence classes — sets of inputs the program should treat identically. You then test one representative from each class. If the program handles the representative correctly, you have evidence it handles the whole class, because the whole class flows down the same branch.
Apply it to the url field on POST /shorten. The infinite space of strings collapses to just four classes:
INPUT SPACE: the `url` field (all possible strings)
┌────────────────────────────────────────────────────────┐ │ class 1: VALID URL "https://example.com/a/b" │ -> 201, returns a code (happy path) ├────────────────────────────────────────────────────────┤ │ class 2: MALFORMED "hello world", "javascript:x" │ -> 400, rejected (risk R4) ├────────────────────────────────────────────────────────┤ │ class 3: EMPTY/MISSING "" or key absent │ -> 400, rejected (risk R3) ├────────────────────────────────────────────────────────┤ │ class 4: OVER-LENGTH a 50,000-char URL │ -> 400 or 413, bounded (risk R7) └────────────────────────────────────────────────────────┘Four classes, four representative tests, and the entire infinite string space is covered — as long as the assumption holds. Notice each class already traces back to a risk from page 2: class 2 is R4 (non-URL / dangerous scheme), class 3 is R3 (empty / missing), class 4 is R7 (giant URL). Class 1, the valid URL, is the happy path the register kept visible. Equivalence partitioning is not a new idea bolted on; it is the register’s risks re-expressed as disjoint input regions, so we can prove we picked exactly one test per region.
A subtlety worth stating: classes come in two flavours. A valid class should be accepted (class 1); an invalid class should be rejected (classes 2–4). Keep at least one representative of each, because the two prove different things — that the feature does its job, and that it declines the jobs it shouldn’t do. A suite with only valid-class tests is the classic “happy-path-only” suite the whole book warns against.
Where partitioning fails, and how to notice
Section titled “Where partitioning fails, and how to notice”The technique is only as good as your partition. If two inputs you lumped into one class actually take different branches, one representative won’t catch a bug in the branch it didn’t hit. The "javascript:alert(1)" case is the trap here: it looks malformed, but if Snip validates only “is this URL-shaped?” and not “is the scheme safe?”, then a dangerous-but-well-formed URL is secretly its own class. That is why R4 is worth two representatives — a plainly-not-a-URL string and a syntactically valid but dangerous scheme. When in doubt, split the class; the cost is one more case, the payoff is one more branch actually exercised.
Boundary-value analysis: test the fenceposts
Section titled “Boundary-value analysis: test the fenceposts”Equivalence partitioning picks a representative from the middle of each class. Boundary-value analysis picks values from the edges, because the edge is where the off-by-one lives. A limit like “codes are 6 characters” is really a comparison in the code — len == 6, or len <= 6, or len < 7 — and those three ways of writing the same intent disagree at exactly one value. That disagreement is the fencepost bug, and only a boundary test finds it.
The rule of thumb: for every numeric limit L, test L-1, L, and L+1. Snip has two such limits, and each becomes three cases.
Boundary 1 — code length (R6)
Section titled “Boundary 1 — code length (R6)”Snip issues 6-character codes, so a valid code path expects length 6. The interesting inputs on GET /:code are the values straddling that limit:
LIMIT: code length = 6
length 5 "a3Xk9" <- L-1 below the valid length -> 404 (never issued) length 6 "a3Xk9Z" <- L the valid length -> 302 if it maps, else 404 length 7 "a3Xk9Zq" <- L+1 above the valid length -> 404 (never issued) length 0 "" (GET /) <- the degenerate boundary -> 404 or route miss, never 500Three fencepost cases plus the empty-code degenerate case, and they trace straight to R6 (off-by-one code length). The point of the pair L-1 and L+1 is that they catch both directions of the mistake: a <= written where < was meant fails on one side, a < written where <= was meant fails on the other. Testing only the exact limit L catches neither.
Boundary 2 — URL length (R7)
Section titled “Boundary 2 — URL length (R7)”Suppose Snip caps stored URLs at 2,048 characters. The boundary trio, plus the middle-of-class over-length case from partitioning, gives:
LIMIT: url length = 2048
length 2047 <- L-1 accepted -> 201, code returned length 2048 <- L accepted -> 201, code returned (the last legal value) length 2049 <- L+1 rejected -> 400/413 (the first illegal value) length 50000 <- far past the edge, from partitioning class 42048 and 2049 are the two cases that actually matter: they sit on opposite sides of one comparison, and a < vs <= slip flips exactly one of them. The 50,000-char case from equivalence partitioning is not redundant with these — it guards against a different failure (unbounded growth, memory blow-up) rather than the fencepost. Partitioning and boundary analysis are complementary: one tests the middle of each class, the other tests the seams.
Decision tables: cover combinations of conditions
Section titled “Decision tables: cover combinations of conditions”Partitioning and boundary analysis handle one input at a time. But POST /shorten has a behavior that depends on two conditions at once: whether the submitted URL is already stored (the dedup policy, R5) and whether the caller requested a custom alias. Testing each condition alone would miss the cases where they interact. A decision table enumerates every combination of conditions and pins the expected action for each — so the interactions can’t hide.
Snip’s two conditions give 2 x 2 = 4 combinations. Written as a table, with the URL either new or a duplicate, and a custom alias either absent or present:
DECISION TABLE — POST /shorten (conditions: R5 dedup + custom alias)
# | url already stored? | custom alias given? | expected action --+---------------------+---------------------+----------------------------------- 1 | no | no | mint a new random code -> 201 2 | no | yes | store under the alias -> 201 | | | (if the alias is free) 3 | yes | no | return the EXISTING code -> 200 | | | (dedup: no new row written) 4 | yes | yes | conflict / alias taken? -> | | | 400/409, a documented choiceRule 4 is the reason the table earns its place. It is the combination no single-condition test would ever generate: a URL that already exists and a caller who wants to name it. What should happen? Maybe the alias wins and re-points; maybe the existing mapping wins and the alias is rejected; maybe it’s a 409 Conflict. There is no “obviously correct” answer — which is precisely why it must be decided and written down as a case, so the behavior is a chosen contract rather than an accident of whichever branch the code happened to fall into. That is the whole value of a decision table: it forces a decision on every corner, including the awkward one everybody would otherwise skip.
Collapsing “don’t-care” rules
Section titled “Collapsing “don’t-care” rules”Real decision tables often have conditions that don’t matter under certain combinations, marked - for “don’t care,” and those rows collapse. Here, if a requested alias is already taken by a different URL, it no longer matters whether the submitted URL is new or a duplicate — the alias conflict dominates:
url stored? | alias given? | alias free? | action ------------+--------------+-------------+--------------------- - | yes | no | 409 Conflict (alias taken)One row now covers two underlying combinations. Collapsing is the decision-table analogue of equivalence partitioning: when a condition is irrelevant, you don’t multiply cases by it. The goal is unchanged — the fewest rows that still distinguish every distinct behavior.
Write every case as a (precondition, input, expected) triple
Section titled “Write every case as a (precondition, input, expected) triple”Techniques generate cases; discipline makes them runnable. A case that says “test duplicate URLs” is a note to self, not a test — it doesn’t say what state the store must be in, what to send, or what “pass” looks like. Every case in the plan is therefore written as an explicit triple:
- Precondition — the state the system must be in before the input (empty store? a code already mapped? the database reachable?). Without it, “expected output” is undefined:
GET /a3Xk9Zreturns 302 or 404 depending entirely on whether that code was pre-loaded. - Input — the exact request: method, path, headers, body. No “a long URL” — the literal string, or a precise generator (
"a" * 2049). - Expected output — the exact observable result: status code, body shape, and any side effect (a row written, no row written). “It works” is not an expected output;
201 with a 6-char code and exactly one new rowis.
Written that way, a case is unambiguous (two people reading it expect the same result) and automatable (the triple maps directly onto an arrange–act–assert test). Here is the plan, every row traced back to a risk from page 2:
TEST PLAN — Snip (each row: precondition | input | expected | risk)
id precondition input expected risk--- -------------------- ----------------------------- -------------------------- --------T1 empty store POST {url:"https://ex.com/a"} 201, 6-char code, 1 new row happy/R1T2 code "a3Xk9Z" mapped GET /a3Xk9Z 302 -> original URL happyT3 empty store POST {url:""} 400, no row written R3T4 empty store POST (no url key) 400, no row written R3T5 empty store POST {url:"hello world"} 400, no row written R4T6 empty store POST {url:"javascript:x"} 400, rejected scheme R4T7 empty store POST {url:"a"*2049} 400/413, no row R7T8 empty store POST {url:"a"*2048} 201, code returned R7 boundT9 empty store GET /a3Xk9 (len 5) 404 R6T10 empty store GET /a3Xk9Zq (len 7) 404 R6T11 empty store GET / (len 0) 404 / route miss, not 500 R6T12 url X already mapped POST {url:X} (duplicate) 200, SAME code, no new row R5T13 code "a3Xk9Z" mapped 2x concurrent POST {url:Y,Z} 2 distinct codes, both kept R1T14 code "a3Xk9Z" mapped GET /!!! (bad alphabet) 404, no query injected R11T15 database unreachable POST {url:"https://ex.com/a"} 503, not a false 201 R9Fifteen cases cover nine of the eleven risks (R8, unicode round-trip, is one solid P2 case we would append; R10, storage-full, folds into the R9 dependency-failure pattern). Read the risk column top to bottom: there is no case that doesn’t trace to a register row, and — more importantly — no P1 register row without a case. That two-way trace is the coverage argument in miniature: every risk has a test, and every test has a reason.
A handful of good cases beats a pile of redundant ones
Section titled “A handful of good cases beats a pile of redundant ones”It is tempting to feel safer with more tests. Resist it. Fifty more POST {url:"https://..."} cases with different-but-valid URLs add zero coverage — they all fall in equivalence class 1 and hit the same branch T1 already hit. They cost real time to run and maintain, and worse, they dilute the signal: a suite of 200 cases where 185 are redundant looks thorough and tests almost nothing at the edges.
REDUNDANT SUITE DESIGNED SUITE 60 valid-URL variations \ T1 one valid URL (class 1) 40 different valid codes | same T3-T8 the invalid classes (R3,R4,R7) 30 more valid bodies | branch T9-T11 the length edges (R6) ... / T12-T15 the combinations (R5,R1,R11,R9) ------------------------ -------------------------------- 200 cases, ~4 branches 15 cases, every branch + every edgeThe designed suite hits more distinct behaviors with an eighth of the cases, because each case was chosen to hit a branch or an edge the others don’t. Coverage is not a function of how many tests you wrote; it is a function of how many distinct paths and boundaries they touch. Equivalence partitioning, boundary analysis, and decision tables are precisely the tools that maximize the second while minimizing the first.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because inputs are infinite and tests are finite, so you need a principled way to choose the few cases that stand in for the many — otherwise you either test at random or try to test everything and finish neither.
- What problem does it solve? It converts a risk register (a list of worries) into a compact, runnable, traceable test plan — maximum distinct branches and edges covered, minimum cases to write and maintain.
- What are the trade-offs? The techniques are only as good as your partitions and your list of conditions: a wrong partition hides a branch, and a forgotten condition drops a decision-table column. They shrink the case count, they do not guarantee completeness — you still have to think.
- When should I avoid it? Never skip it wholesale, but don’t over-formalize a trivial input either: a single boolean flag doesn’t need a four-technique treatment. Match the ceremony to the number and interaction of the inputs.
- What breaks if I remove it? You test by intuition — dozens of redundant valid-URL cases and no boundary or combination cases — so the suite looks big, runs slow, and still misses the off-by-one and the awkward
duplicate-URL + custom-aliascorner that a decision table would have forced you to decide.
Check your understanding
Section titled “Check your understanding”- Equivalence partitioning collapses the infinite
urlinput into four classes. Name them, and give the risk (R-number) each maps to. - For a limit
L, boundary-value analysis testsL-1,L, andL+1. Why are the two neighbours of the limit more valuable than the limit itself for catching an off-by-one? - The decision table for
POST /shortenhas four rows. Which row is the one no single-condition test would generate, and why does the technique insist you decide its behavior explicitly? - Why must every case be written as a
(precondition, input, expected output)triple? Give a concrete Snip example where dropping the precondition makes the expected output undefined. - A colleague adds 50 more valid-URL cases to the suite “to be safe.” Explain, using equivalence classes and coverage, why this adds little and can even hurt.
Show answers
- The four classes are valid URL (happy path / class 1), malformed or dangerous-scheme (R4), empty or missing (R3), and over-length (R7). One representative per class covers the infinite string space because each class flows down the same branch.
- Because a limit is a comparison in code (
<vs<=,< 7vs<= 6), and the two ways of writing it disagree at exactly one value — the neighbour on one side or the other. Testing the exact limitLpasses under both spellings; onlyL-1andL+1distinguish them, so the neighbours are where the off-by-one actually shows. - Row 4 — URL already stored AND a custom alias requested — is the combination no single-condition test produces, since each condition tested alone never sets the other. There is no obviously correct behavior (re-point? reject? 409?), so the table forces you to choose and record it as a case, making it a contract rather than an accident of whichever branch the code fell into.
- Because “expected output” is meaningless without the starting state: the same input can legitimately produce different results depending on it. Example:
GET /a3Xk9Zreturns 302 if the precondition is “that code is mapped” but 404 if the precondition is “empty store.” The triple makes the case unambiguous (everyone expects the same result) and automatable (it maps onto arrange–act–assert). - All 50 fall in equivalence class 1 (valid URL) and hit the same branch T1 already covers, so they add zero new coverage — coverage counts distinct paths and edges, not case count. They also cost run and maintenance time and dilute the signal: a suite that looks thorough (many cases) but touches few branches hides its own gaps. Effort should go to unhit classes, boundaries, and combinations instead.