Map the Risks and Edge Cases
The overview set the target: earn justified confidence that Snip — a URL shortener with two endpoints, POST /shorten and GET /:code — actually works. But confidence is not a mood you talk yourself into. It is the shadow cast by a set of tests, and tests are only as good as the risks they were written to catch. So before we write any test, we do the thing that separates a tester from a typist: we sit with the feature and ask, methodically, what can break?
This page produces one artifact and only one: a written risk register for Snip. It is a plain list of the specific ways the feature can fail, ranked by how much they matter. Everything on the next page — Design the Test Cases — is derived from this list. If a risk is not on the register, no test will be written for it, and the bug that risk represents will ship. That is the whole reason this page comes first.
Start from the inputs and the boundaries
Section titled “Start from the inputs and the boundaries”The tester’s mindset from earlier in the book gives us a procedure, not an inspiration. We do not stare at Snip and hope failures occur to us; we walk its inputs and its boundaries and read the failures off the list. Every operation receives arguments and touches resources, and each of those is a place a value can be at an extreme, absent, malformed, or in contention. Walk the surface and the risks fall out.
Snip’s surface is small, which is exactly why it is a good teaching case — the whole attack surface fits on one screen.
POST /shorten input: a JSON body { "url": "<the long URL>" } effect: generate a short code, store code -> url, return the code stores: writes one row to the database
GET /:code input: a short code in the path effect: look up the code, redirect (302) to the stored URL stores: reads one row from the databaseTwo inputs — a URL string and a short code — and two storage operations. We now walk each input to its edges.
Walking the URL input on POST /shorten
Section titled “Walking the URL input on POST /shorten”Take the one input, url, and ask the boundary questions the tester-mindset Part drilled: what is the empty case, the malformed case, the extreme case, the duplicate case?
- Empty URL — the body is
{ "url": "" }, or theurlkey is missing entirely. Does Snip reject it with a clean400, or store an empty string and later redirect users to nowhere? - Non-URL string — the value is
"hello world"or"javascript:alert(1)". A string is not a URL. Does Snip validate the scheme and shape, or shorten literal garbage — and does it shorten a dangerous scheme it will later redirect a browser to? - Giant URL — the value is 50,000 characters long. Where is the limit? If there is no limit, a single request can bloat a row, blow a column size, or exhaust memory. If there is a limit, the interesting test is one byte on each side of it.
- Unicode / IDN URL —
https://пример.рф/путьor a URL with emoji in the path. Does normalization, percent-encoding, and storage round-trip the exact bytes, or does the redirect come back mangled? - Duplicate URL — the same long URL is shortened twice. Does Snip return the same code both times (dedup), or mint a new code each time? Both are valid designs — but the feature must pick one, and the test pins the choice so it can’t silently drift.
Walking the code input on GET /:code
Section titled “Walking the code input on GET /:code”Now the other input. A short code is a small string drawn from a fixed alphabet at a fixed length, and each of those two facts is a boundary.
- Code collision — two different URLs are assigned the same generated code. Whoever wrote second either overwrites the first mapping or is rejected; either way, a user’s link now points somewhere they never intended. This is the highest-stakes risk on the whole feature.
- Expired or missing code — a
GET /:codefor a code that was never issued, or was deleted, or (if codes expire) has aged out. Does Snip return a clean404, or a500, or worse, redirect somewhere stale? - Off-by-one on code length — if codes are “six base-62 characters,” what happens on a five-character or seven-character code in the path? What about the empty code,
GET /? Length boundaries are where the classic fencepost bug lives. - Wrong-alphabet code — a code containing characters outside the generator’s alphabet (
GET /!!!). It cannot exist in storage, so this is a lookup-miss test in disguise — but it is also an injection surface if the code is concatenated into a query.
Notice that we generated all of this by walking two inputs to their edges. No inspiration required. The edge-cases field guide is the checklist; the feature is the thing we run through it.
Why walking the surface beats brainstorming
Section titled “Why walking the surface beats brainstorming”It is worth pausing on why the walk works, because the instinct — sit down and brainstorm ways the feature could fail — is the thing to unlearn. Brainstorming enumerates the failures you can remember, which is the small set you have personally been burned by. The walk enumerates the failures the structure admits, whether or not you have met them before. The difference is the difference between a pilot trusting their memory before takeoff and a pilot reading the checklist: the checklist catches the item you would have forgotten precisely on the day it matters.
Concretely, the procedure is mechanical enough to hand to someone else:
for each endpoint: for each input it receives: ask the boundary questions: what is the EMPTY value? (empty url, empty code) what is the MALFORMED value? (not-a-url, wrong-alphabet code) what is the EXTREME value? (50k-char url, 7-char code) what is the DUPLICATE value? (same url twice, colliding code) for each resource it touches: ask the dependency questions: what if it is ABSENT? (database down) what if it is FULL? (storage full) what if two callers RACE? (concurrent shortens)Run any feature through that grid and the risks fall out in the same order every time, for every author. That reproducibility is the point: a risk register produced by a checklist is auditable — a reviewer can check you walked every input — in a way that a brainstormed list never is.
The edge cases the field guide adds
Section titled “The edge cases the field guide adds”The input walk above finds the risks visible from the door. The field guide adds the ones that only appear once the system is running under load — the failures that never show up on a developer’s quiet laptop and detonate in production.
Concurrency: two shortens racing on one code
Section titled “Concurrency: two shortens racing on one code”Snip generates a code, checks it is free, and writes it. Between the check and the write, a second request can generate the same code, see it free too, and write it. This is a time-of-check-to-time-of-use race, and it is the mechanism behind the code-collision risk above. On a single-threaded local test it is invisible; with two concurrent POST /shorten calls it is a real, reproducible bug. The concurrency-and-races page is the map here, and the repo’s own concurrency test is the model for provoking it — hammer one shared store from many threads and assert nothing was lost:
// rust/kvlite/tests/concurrency.rs — the shape of a race test:// many threads write, then assert the exact final count.let handles: Vec<_> = (0..THREADS) .map(|t| { let store = store.clone(); // same map, new handle thread::spawn(move || { for i in 0..PER_THREAD { store.set(format!("t{t}-k{i}"), /* value */ 0).unwrap(); } }) }) .collect();for h in handles { h.join().unwrap(); }assert_eq!(store.len().unwrap(), THREADS * PER_THREAD); // no write lostThe lesson we borrow for Snip: the correct test does not just check that two concurrent shortens return; it asserts that they produced two distinct codes and two intact mappings — the exact-count guarantee, applied to codes.
Resources: storage full and database down
Section titled “Resources: storage full and database down”POST /shorten writes a row. Two resource failures live behind that one line:
- Storage full — the disk or the table’s quota is exhausted; the write fails. Does Snip return the code as if the write succeeded (a lie that loses the mapping), or does it surface a
503and store nothing? - Database down — the connection is refused or times out mid-request, on either endpoint. A read that can’t reach the database must not masquerade as a
404(“code not found”) when the truth is “I couldn’t look” — those are different failures and deserve different responses.
These are the resources, network, and trust cases: the operation’s dependencies are absent or degraded, and the test’s job is to assert the feature declines gracefully rather than corrupting or lying.
Under the hood — where each Snip risk lives in the request
Section titled “Under the hood — where each Snip risk lives in the request”The risks are not a grab-bag; they line up with the stages a Snip request passes through, which is exactly why walking the surface is exhaustive rather than lucky. A POST /shorten request, at the level of the machine, does roughly this — and each stage admits its own family of failures:
1. parse the JSON body <- empty url, missing key, malformed body 2. validate the url string <- non-URL, dangerous scheme, giant, unicode 3. generate a candidate code <- (pure computation, but see stage 4) 4. check the code is free <- RACE window opens here... 5. write code -> url <- ...and closes here; also storage-full, db-down 6. return the code <- must not lie about a write that failedRead top to bottom, every risk on the register maps to exactly one stage. The input-validation risks (R3, R4, R7, R8) are stage-1 and stage-2 gates — they should reject early, before any storage is touched. The collision (R1) is the unique hazard of the gap between stage 4 and stage 5, the classic check-then-act window. The resource failures (R9, R10) live at stage 5, where the request has committed to a write. Knowing where a risk lives tells you what correct handling looks like: reject at the door (stages 1–2), close the race with a single atomic write or a unique constraint (stage 4–5), or surface the failure honestly instead of returning success (stage 5–6). The register is not just a list of failures; read this way, it is a map of where in the request each one has to be defended.
Classify by likelihood and impact
Section titled “Classify by likelihood and impact”We now have perhaps a dozen risks. If we spread testing effort evenly across them, we waste it — a low-likelihood, low-impact typo case gets the same attention as the collision that silently corrupts a user’s link. Testing effort is finite; it must flow to where the expected cost is highest. So we score each risk on two axes and let the score, not the mood of the moment, decide the depth of testing.
- Likelihood — how often will real traffic actually hit this condition? Duplicate URLs are common; a five-character code from an honest client is rare (but an attacker will send it on purpose, which raises its likelihood back up).
- Impact — if it breaks, how bad is the damage? A
500on a malformed code is annoying; a code collision that redirects a user to a stranger’s URL is a trust-destroying, possibly-a-security-incident failure.
The product of the two is the priority. High-impact risks get tested even at low likelihood, because their expected cost is dominated by the impact term.
The scoring is not meant to be precise — “medium likelihood” is a judgement, not a measurement. What matters is that the two axes are considered separately before they are combined, because the two common estimation mistakes pull in opposite directions. Judge by likelihood alone and you over-test the frequent-but-harmless cases (every malformed body) while ignoring the rare-but-catastrophic ones (the collision). Judge by impact alone and you gold-plate against disasters that essentially never happen while shipping the everyday 400 handling untested. Scoring both and multiplying is the cheapest available correction for both mistakes at once.
Walk one risk through the two axes to see the reasoning:
Risk: code collision (R1) likelihood: medium — rare per request, but the birthday math (below) makes it a certainty at scale, and an attacker can force it deliberately. impact: HIGH — a user's link silently redirects to a stranger's URL: broken trust, possible security incident, and no error surfaced to anyone. => medium x HIGH = P1. Test it thoroughly, including the concurrent race, even though a single request rarely triggers it.Compare that to R8 (unicode round-trip): medium likelihood, but low impact — a mangled non-ASCII path is annoying, not dangerous — so it lands at P2 and gets one solid round-trip test rather than a battery of variations. Same likelihood, different impact, different depth of testing. That is the axis doing its job.
The risk register
Section titled “The risk register”Here is the artifact. It is deliberately plain — a table any teammate can read — and it is the direct input to the next page.
RISK REGISTER — Snip URL shortener
id endpoint risk likelihood impact priority-- -------- ---------------------------- ---------- ------ --------R1 shorten code collision (race) medium HIGH P1R2 get expired / missing code high med P1R3 shorten empty / missing url high med P1R4 shorten non-URL / dangerous scheme med HIGH P1R5 shorten duplicate url (dedup policy) high low P2R6 get off-by-one code length low med P2R7 shorten giant url (size limit) low med P2R8 shorten unicode / IDN round-trip med low P2R9 both database down low HIGH P1R10 shorten storage full low med P2R11 get wrong-alphabet / injection low HIGH P1Read the priority column, not the row order: the P1s are the collision, the missing-code miss, the input-validation pair, the two dependency failures, and the injection surface — the risks whose impact is high even when their likelihood is not. The P2s are real and will still be tested, but with less depth and fewer boundary variations. That ranking is the entire value of the register: it tells the next page where to spend.
Keep the happy path visible too, because it anchors everything: shorten a valid URL → get a code → GET that code → 302 to the original URL. One happy path; eleven named unhappy paths. That asymmetry — success is a point, failure is a space — is the register drawn to scale.
The register is a living document, not a monument
Section titled “The register is a living document, not a monument”Do not treat the eleven rows as complete. A register is honest precisely because it admits it can’t be complete: there is always a twelfth risk you didn’t think of. What makes it useful is not exhaustiveness but two properties — it is ranked, so effort flows correctly, and it is written down, so it can be reviewed, argued with, and grown. When a bug escapes to production later (and one will — that is File the Bug You Found), the first move is to add the missed risk as a new row, so the same class of failure can’t escape twice. A register that grows after every incident is a register that is doing its job.
There is also a discipline in not over-listing. If every conceivable typo becomes a P1 row, the ranking loses meaning and the suite drowns in low-value tests. The register earns its keep by being ruthless about the priority column: a handful of P1s that genuinely matter, a tail of P2s tested with less depth, and an explicit decision — recorded, not implied — that some vanishingly-rare, low-impact cases are out of scope for now. Writing “out of scope” down is itself a form of coverage: it turns a silent omission into a reviewed choice.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? Because tests are written for risks, and un-enumerated risks get no tests — so a feature’s real coverage is decided before any test code is typed, at the moment you choose what to worry about.
- What problem does it solve? It converts a fuzzy feature (“a URL shortener”) into a concrete, ranked list of specific failure modes, turning “test it well” from a vibe into a finite, checkable to-do list.
- What are the trade-offs? It costs upfront thinking time and can never be proven complete — there is always a risk you didn’t list. You trade the illusion of exhaustiveness for a prioritized, honest starting point.
- When should I avoid it? Never skip it entirely, but scale it: for a throwaway script whose worst failure is “it didn’t run,” a formal register is overkill. Match the ceremony to the cost of the failure.
- What breaks if I remove it? You test whatever happens to occur to you — which is always the happy path and the failures you were personally burned by — and the high-impact risks you never named ship untested, exactly like the collision and the leap-second boundary above.
Check your understanding
Section titled “Check your understanding”- What single artifact is this page’s whole output, and why must it come before writing any tests?
- Walk the
urlinput onPOST /shortento its boundaries and name at least four distinct risks you find. - Why are code collision and database down both marked P1 even though their likelihood is only medium or low?
- Explain the time-of-check-to-time-of-use race in Snip’s code generation, and describe what a good test would assert about it.
- The register scores every risk on two axes. Name them, and explain why their product — not either one alone — sets the testing priority.
Show answers
- The one output is a written risk register — a ranked list of Snip’s specific failure modes. It must come first because every test on the next page is derived from it: a risk that is never enumerated is never tested, so the register decides the suite’s real coverage before any test code exists.
- Walking
urlto its edges yields: empty / missing URL, non-URL or dangerous-scheme string, giant URL (size-limit boundary), unicode / IDN round-trip, and duplicate URL (dedup policy). Any four of these is correct. - Priority is likelihood × impact, and both risks have high impact: a collision silently redirects a user to a stranger’s URL (a trust/security failure), and a database-down handled wrong lies to the caller or corrupts state. High impact makes the expected cost high even at modest likelihood, so both are P1.
- Snip generates a code, checks the store to confirm it’s free, then writes it. Between the check and the write, a second concurrent request can generate the same code, also see it free, and write it — so two URLs end up on one code. A good test provokes it with concurrent shortens and asserts two distinct codes and two intact mappings, i.e. no write was lost or overwritten — the exact-count guarantee from the repo’s concurrency test.
- The axes are likelihood (how often real traffic hits the condition) and impact (how bad the damage is if it breaks). Their product sets priority because a rare-but-catastrophic risk (low likelihood, high impact) and a common-but-trivial one (high likelihood, low impact) can have very different expected costs; only the product captures that, so effort flows to the highest expected cost rather than being spread evenly.