Overview — A Field Guide to the Edges
The first Part of this book gave you the eyes: the adversarial habit of asking “how could this break?” and a small checklist of categories where software fails. This Part turns that habit into a field guide. For each family of input and state that reality hands your code, we walk the boundaries systematically and turn every one we find into a named, permanent test.
The happy path — the case you designed the feature around — is the one place you are guaranteed to test, because it is the thing you were picturing while you wrote the code. That is exactly why it earns you almost no confidence. The bugs are not on the road you paved. They are at the edges you never looked at: the empty list, the value one past the maximum, the second identical request, the timezone that has no 2:30 a.m. tonight. This guide is a tour of those edges, category by category.
Everything in this Part is a specialisation of one habit you already met earlier: the adversarial mindset. What changes here is that we stop treating “think of how it could break” as a talent and start treating it as a procedure with a fixed input — a category — and a fixed output — a named test. If the earlier Part convinced you that a tester’s real job is disproof rather than confirmation, this Part hands you the specific edges to disprove against.
Keep one promise in mind as you read: nothing here asks you to be more imaginative than the next engineer. It asks you to be more systematic. The senior tester who “always finds the bug” is rarely running a richer imagination — they are running a checklist they internalised so long ago it feels like instinct. This Part externalises that checklist so you can run it deliberately today instead of absorbing it by osmosis over a decade.
What is an edge case, really?
Section titled “What is an edge case, really?”An edge case is an input or a program state sitting at the boundary of what your code assumes. Your code was written against a picture in your head: a list with a few items, a positive number, a string of ordinary letters, one request at a time. That picture has an implicit interior (the cases you imagined) and a boundary (where the picture stops). An edge case is any real-world value that lands on or just past that boundary — a place your assumptions quietly expire.
This is why the happy path is never enough for justified confidence. A passing happy-path test tells you the code works inside the author’s picture. But every bug is an assumption the author never wrote down, and unwritten assumptions live precisely at the edges of the picture — the very region the happy path steps around. Confidence that ignores the boundary is confidence about the one region that was never in doubt.
Notice the self-referential trap here. The reason you tested the happy path is the same reason it earns no confidence: you tested it because you could picture it, and you could picture it because your assumptions hold there. The test and the assumption share an origin, so the test can only ever confirm what you already believed. Edges break that loop — they are, by definition, the inputs your picture did not include, which is exactly why a test at the edge can tell you something you did not already know.
The author's mental picture of the input ┌───────────────────────────────────────┐ │ │ │ happy path lives HERE │ ← tested for free, │ (a few items, a normal string, │ earns little │ a positive number, calm) │ │ │ └───────────────────────────────────────┘ ▲ edge cases live on and just past THIS line ▲ empty · one · huge · zero · negative · max+1 duplicate · out-of-order · concurrent · absentThe recurring lens: “how would I attack this?”
Section titled “The recurring lens: “how would I attack this?””The whole Part runs on one move, and it is the same move a security researcher makes when handed an unfamiliar system. You stop thinking like the author who hopes it works and start thinking like an attacker who wants it to fail. The lens has three steps, and you apply it to one category at a time:
- Pick a category. Numbers. Strings. Collections. Time. Absence. Concurrency. Resources and trust. Each is a family of inputs with its own set of boundaries.
- Enumerate its boundaries. For the chosen category, list the extremes and degenerate cases mechanically — not creatively. Numbers have zero, negative, and max. Collections have sizes 0, 1, and huge. Every category has a short, known set of edges, and this guide hands you each list.
- Turn each boundary into a test. Every edge you enumerate becomes an input to a test with an explicit expected result. Enumeration without tests is just anxiety; the payoff is a concrete assertion for each boundary.
The reason this feels like attacking is that you are deliberately hunting the inputs the author would never send. The author sends [3, 1, 2]; you send []. The author sends "Alice"; you send a string with a null byte, an emoji, and '; DROP TABLE. Same lens, different category, every time — and the guide’s whole promise is that this move never changes, only the category it points at.
The word attack is not metaphorical decoration. A defender has to imagine the specific way the author’s picture stops matching reality, and an attacker gets paid to do exactly that. Adopting the attacker’s stance is a deliberate reframe of your own job: for the length of this Part you are not the person who built the feature and wants it to work — you are the person paid to make it fall over, using only inputs the type system already permits. Every category below is a menu of legal-but-hostile inputs, and enumeration is how you order from it without needing a flash of insight.
Under the hood — why enumeration beats inspiration
Section titled “Under the hood — why enumeration beats inspiration”A common objection to boundary testing is that it feels mechanical, even boring, next to the “creative” work of imagining exotic failures. That is precisely its strength. Inspiration does not scale and does not survive a tired Friday afternoon; a checklist does. The whole design of this Part is to convert a skill that looks like intuition (“this senior engineer just knows where bugs hide”) into a procedure a junior engineer can run on their first day.
The mechanism is that most bugs are not exotic. They are the same three or four boundaries — empty, one, max, max + 1, absent, repeated — showing up in a new costume. When you have a fixed list of boundaries per category, finding the bug stops depending on whether you happened to think of it and starts depending on whether you worked through the list. That is the difference between an art you either have or lack and an engineering discipline you can teach, review, and require in a checklist.
inspiration model enumeration model ───────────────── ───────────────── "think hard about how vs. "for each category, walk its this might break" known boundary list, test each" │ │ depends on the tester's depends on doing the work — mood, experience, luck repeatable, reviewable, teachableYou still need judgment — to decide which categories a given function even touches, and which boundaries carry real risk versus which are theoretically-present-but-harmless. But judgment spent choosing from a list is cheaper and more reliable than judgment spent inventing the list from a blank page every time.
There is a second, quieter payoff to working from a list: it makes the gap visible. When you enumerate a category’s boundaries and one of them has no corresponding test, you have found a hole in your own confidence — explicitly, on paper, before a user finds it for you. Inspiration cannot show you what you failed to imagine; a checklist can, because the missing checkbox is right there. That is why the categories in this Part double as an audit tool: run any function past all seven and the untested cells light up.
The mental model of a boundary
Section titled “The mental model of a boundary”Underneath every category is the same tiny structure. Once you see it, enumeration becomes automatic rather than inspired:
- Every value has neighbors. For a number, the interesting neighbors are
0, a negative, and the type’s max (andmax + 1, where it overflows). For a length limit of 255, the neighbors are254,255,256. The bug is almost always at a neighbor, not in the middle. - Every collection has degenerate sizes. A list, set, map, string, or result set is “a few things” in your head, but its real edges are size 0 (empty), size 1 (no second element for your
joinor your “and” separator), and size huge (does it fit in memory? does the query time out?). - Every operation has a first, last, and repeat case. The first insert into an empty table, the last item before the buffer is full, and the second identical call all exercise code the middle iterations never touch — initialization, termination, and idempotency.
value : ... negative ── 0 ── 1 ... max ── max+1(overflow) size : 0(empty) ── 1(singleton) ── many ── huge sequence: first ── middle ── last ── repeat(again)Three rows. Almost every edge case in this guide is one cell of that grid, applied to one category.
Read the grid as a coordinate system, not a to-do list. Any concrete edge case is “row × category”: value × numbers gives you max + 1 (overflow); size × collections gives you the empty list; sequence × operations gives you the second identical write (idempotency). A skilled tester, handed an unfamiliar function, silently sweeps every relevant cell — and the reason it looks effortless is that the grid has only three rows and a short menu of categories, so the whole sweep is a dozen questions, not a thousand.
Two of those cells deserve early warning because they are the ones authors forget most reliably. The empty cell (size 0) breaks code that assumed “there is at least one” — the join that needs a separator, the average that divides by count, the “first result” that indexes position zero. The repeat cell (the second identical operation) breaks code that assumed “this happens once” — the payment charged twice on a retry, the insert that violates a uniqueness constraint the second time, the event handler that fires on every reconnect. Neither is exotic. Both are one cell of the grid, and both are in the guide because the happy path structurally cannot reach them.
A useful discipline is to read each grid row as a question you ask out loud about the function under test. For the value row: “what does this do at zero, at negative, at the maximum, and one past it?” For the size row: “what does this do with nothing, with exactly one, and with more than fits comfortably?” For the sequence row: “what does this do the first time, the last time, and the second identical time?” You will find that phrasing them as questions surfaces missing behaviour that a silent scan glides over — the function that has an answer for “many” but visibly stammers at “one.”
Not every cell applies to every function, and forcing an irrelevant one wastes a test. A pure integer-doubling routine has no size row and no sequence row worth testing; a stateless formatter has no repeat case. Part of the skill this Part builds is recognising which rows a given function actually exposes — a decision you make once, quickly, before enumerating the cells that remain. The grid is a filter as much as a generator: it tells you what to test and, just as usefully, gives you a defensible reason for what you chose not to.
The standard: every edge becomes a named regression test
Section titled “The standard: every edge becomes a named regression test”This guide is not a collection of war stories. It is a machine for producing tests. The rule for the whole Part:
When you find an edge, you do not just fix it — you name it and freeze it as a regression test.
A regression test is a test whose entire job is to fail loudly if a specific bug ever comes back. The name matters: parses_two_thirty_am_on_spring_forward_night teaches the next reader what edge exists in a way that test_dates_3 never will. Each boundary you enumerate, each bug you catch, leaves behind one such test with a descriptive name and a clear expected value. Do this and the guide’s output is a suite that encodes, permanently, everything you learned about where this code lives dangerously.
Concretely, the workflow for every edge in this Part is the same four steps, and the test is the deliverable — not the fix:
1. Pick a category → collections 2. Enumerate a boundary → size 0 (empty input) 3. Write the test → get_on_empty_store_returns_none() 4. Name + freeze it → the name states the edge; the assert states the expected behaviour at itThe naming rule is not cosmetic. A well-named boundary test is documentation that cannot rot, because CI runs it on every commit — if the description in the name ever stops matching the behaviour, the build goes red. Compare the two habits directly:
test_kvlite_1() get_on_empty_store_returns_none() ─────────────── ──────────────────────────────── tells you nothing when it tells you exactly which edge fails; you must read the body regressed, from the failure line to learn which edge broke alone — the name is the specFor real code, the kvlite server tests in rust/kvlite/tests/server.rs and the concurrency tests in rust/kvlite/tests/concurrency.rs are exactly this discipline applied to a key-value store: a GET on a key that was never SET is the absence edge; two clients writing the same key at once is the repeat/concurrency edge. Each becomes one named test, and the guide’s later pages generate that kind of test for every category in turn.
The reason “freeze it as a regression test” is the standard — rather than just “fix the bug” — is that a fix without a test decays. The next refactor, the next contributor who does not know the edge exists, the next merge that reverts a subtle line: any of them can silently reintroduce the bug, and nothing will notice until a user does. The test is the only artifact that remembers. It converts a one-time discovery into a permanent guarantee, and it does so for free on every future commit. A war story teaches the person who heard it; a named regression test teaches the build server, forever.
Roadmap for this Part
Section titled “Roadmap for this Part”Read these in order. Each page picks one category, enumerates its boundaries, and hands you the tests they generate — the lens applied end to end, one family of inputs at a time. The categories are sequenced from the most concrete (the numeric and textual values a function receives) toward the most environmental (the unreliable world the code runs in), so each page can lean on the boundary vocabulary the earlier ones established.
Every row of the table below is the same shape: a category, and the edges it hides. Read the “category it attacks” column as a promise — by the end of that page, you will be able to hand any function in that category its full boundary list and know what a correct answer at each edge looks like.
| # | Page | The category it attacks |
|---|---|---|
| 2 | Numbers — Zero, Negatives, Overflow, and Floats | Numeric values: zero, negatives, integer overflow, and the traps of floating-point equality and rounding |
| 3 | Strings — Empty, Huge, Unicode, and Injection | Text: the empty and giant string, Unicode and combining characters, and untrusted input that becomes injection |
| 4 | Collections — Empty, One, Many, Duplicates, Order | Sizes 0/1/many, duplicate elements, and the ordering your code silently assumed |
| 5 | Dates and Times — Timezones, DST, Leap Years, Epoch | Time: timezones, daylight-saving gaps, leap years and seconds, and epoch boundaries like 2038 |
| 6 | Null, Optional, and the Absence of a Value | Absence: null vs. missing vs. empty vs. default, and the difference between “no value” and “zero” |
| 7 | Concurrency — Interleavings, Deadlock, and TOCTOU | State under simultaneity: race interleavings, deadlock, and time-of-check-to-time-of-use gaps |
| 8 | Resources, Network, State, and Trust Boundaries | The unreliable world: full disks, dropped packets, partial writes, and every boundary where trust ends |
| 900 | Revision — The Edges, One More Time | A prose recap that ties every category back to the one lens |
Two categories in the table deserve a note on why they come last. Concurrency and resources and trust are the hardest because their edges are not in the input you pass but in the world the code runs in — the interleaving of two threads, the disk that fills mid-write, the packet that never arrives, the caller who is lying to you. These edges cannot be enumerated by staring at a single function signature; they require reasoning about state, timing, and adversaries. Placing them at the end lets every earlier page’s boundary vocabulary do the heavy lifting first, so that when we reach the genuinely hard cases you already own the habit of pick-enumerate-test.
The thread
Section titled “The thread”One question runs through every page, and it is the attacker’s question aimed at one category at a time:
For this category of input or state, where are the boundaries — and what does the code do when I hand it one?
The numbers, strings, and collections pages attack the values your functions receive. The dates and absence pages attack the two domains — time and nothingness — where intuition fails hardest. The concurrency and resources and trust pages attack the world around the code: simultaneity, unreliable hardware, and untrusted input. Different categories, one move — pick, enumerate, test — and one output: a named regression test for every edge you find.
If you take one thing from this Part before reading a single category page, take this: the edges are finite and knowable per category, so finding them is not a matter of being clever but of being systematic. The guide’s job is to hand you the boundary list for each family of input; your job is to walk it, hand your code the hostile value, and freeze the answer as a named test. When you have done that across all seven categories, the region where “I think it works” quietly turns into “it breaks here, and I have a test proving it no longer does” is exactly the region where justified confidence is earned.
One last framing before you dive in. This Part deliberately spends most of its energy outside the happy path, and that can feel inverted — you will write more tests for the empty list and the overflow than for the ordinary case. That is not paranoia; it is where the risk is. The ordinary case is defended by the fact that you built the feature for it and use it constantly; the edges are defended by nothing but the tests you are about to write. Spending your effort where the risk concentrates is the whole economic argument of the book, and in this Part the risk concentrates, without exception, at the boundary.
Check your understanding
Section titled “Check your understanding”- Define an edge case in terms of a boundary. Why does testing the happy path alone fail to earn justified confidence?
- State the three-step lens this Part runs on, and explain why it feels like “attacking” the code rather than confirming it.
- Give the three-row mental model of a boundary (value, size, sequence) and one concrete example from each row.
- What is the standard this guide sets for every edge you discover, and why does the name of the resulting test matter?
- The Y2K bug is described as pure boundary thinking. Which cell of the value/size/sequence grid does it occupy, and what byte-level bug is it identical to?
Show answers
- An edge case is an input or state at the boundary of what the code assumes — the line where the author’s mental picture of the input stops. The happy path lives inside that picture, so it is the one region never in doubt; unwritten assumptions (and therefore bugs) live at the boundary the happy path steps around, so happy-path confidence is confidence about the wrong region.
- Pick a category, enumerate its boundaries, turn each boundary into a test. It is “attacking” because you deliberately hunt the inputs the author would never send — the empty list, the
max + 1, the second identical request — thinking like an opponent trying to make it fail rather than an author hoping it works. - Value: every value has neighbors — e.g.
0, a negative, andmax/max+1for a number. Size: every collection has degenerate sizes — e.g.0(empty),1(singleton), and huge for a list. Sequence: every operation has a first/last/repeat — e.g. the second identical call testing idempotency. - Every edge found becomes a named regression test — frozen so the specific bug fails loudly if it ever returns. The name matters because a descriptive name (e.g.
parses_two_thirty_am_on_spring_forward_night) teaches the next reader what edge exists, turning the suite into documentation of where the code lives dangerously. - It occupies the value row, cell
max + 1(overflow): a year stored in two digits rolls99 → 00when incremented past its maximum representation. It is identical to a byte overflowing255 → 0— the same “one past the max” boundary a systematic tester enumerates first.