Skip to content

Overview — Choosing Test Cases with Intent

Every earlier Part of this book has circled one question: how do you earn justified confidence that software works? The tester’s mindset taught you to hunt for where things break; the levels and types taught you where in the system to look. This Part answers the question that comes next and is harder than it sounds: given a specific function, screen, or API, which exact inputs do you actually run?

That is the discipline of test design. It sits between “I should test this” and “here is the test I wrote,” and it is where most of the leverage in testing lives. Choose your cases well and a dozen tests can flush out bugs that ten thousand random inputs would miss. Choose them badly — or at random — and you can run tests all day and still ship the defect that takes production down.

Start with the fact that makes test design necessary at all: the input space of almost any real program is, for practical purposes, infinite.

Take a function that adds two 32-bit integers. That is about as simple as software gets. Yet each argument has 2³² possible values, so the pair has 2³² × 2³² ≈ 1.8 × 10¹⁹ combinations. At a billion test cases per second it would take roughly 584 years to run them all — for addition. Add a third parameter, a string, or a timestamp, and the number stops being large and becomes meaningless.

If you accept that number, one conclusion is forced on you: testing is sampling. You will only ever run a vanishingly small fraction of the possible inputs. The entire question of test design is which fraction — and that is a question you get to answer well or badly.

The core problem: find the edge cases without testing every case

Section titled “The core problem: find the edge cases without testing every case”

Restated sharply, the job is this:

Find the inputs that break the software without running every input.

Two facts make this tractable. First, bugs are not spread evenly across the input space. They cluster — at the edges of ranges, at the transitions between states, at the surprising combinations of options, at the values a programmer forgot existed. Second, huge swathes of the input space are redundant: for a given branch of logic, thousands of inputs are treated identically, so one of them tests the branch as well as all of them.

Put those two facts together and a strategy appears. Skip the redundant middle. Aim deliberately at the seams where bugs pile up. That is what every technique in this Part is a concrete, repeatable way to do.

A natural first instinct is: if I can’t test everything, I’ll test a random handful. Random inputs feel unbiased and easy to generate. But as a strategy for finding bugs, random selection is quietly wasteful.

input axis for "age >= 18" (bug lives exactly at 18)
0 18 130
|---------|--------------------------------------------|
reject | accept
^
the ONE value that distinguishes age > 18 from age >= 18
random darts: x x x x x x x x
(almost never land on 18 — the off-by-one survives)

If the defect is an off-by-one at age 18, a random integer between 0 and 130 hits it with probability roughly 1 in 131. You could run hundreds of random cases and miss it. Worse, random selection gives you no idea what you covered: after a hundred passes you cannot say which behaviours you exercised or which you skipped, so you cannot say when to stop. Effort spent without coverage you can name is effort you cannot bank as confidence.

Systematic test design fixes both problems. It chooses cases with intent — each case exists to exercise a specific behaviour or catch a specific class of bug — and it comes with known coverage, so you can point at what you tested and defend the claim. (Random and generated inputs are not worthless, by the way; property-based and fuzz testing put them to excellent use. That is a complement to the designed cases here, covered later in types of testing.)

The techniques in this Part fall into two families, distinguished by what they reason about.

Input-space techniques reason about the values a program accepts. They ask: how do I carve an infinite set of inputs into a small set of representatives that still covers everything that matters? Equivalence partitioning, boundary value analysis, and pairwise/combinatorial testing all live here.

Behavior techniques reason about the program’s logic and lifecycle. They ask: given these rules or these states, what combinations and sequences must I exercise? Decision tables (for tangled if/else rules) and state-transition testing (for anything with modes, sessions, or workflows) live here.

TEST DESIGN
|
+-------------------+--------------------+
| |
INPUT-SPACE techniques BEHAVIOR techniques
"which values?" "which logic / sequences?"
| |
equivalence partitioning decision tables
boundary value analysis state-transition testing
pairwise / combinatorial
| |
+--------------- error guessing ---------+
(experience aimed at both)

Most of the two families are not either/or. Real testing weaves them together, which is why this Part ends with a page on combining techniques rather than declaring a winner.

Read these in order. Each page teaches one technique, shows a worked example, and names the specific kind of bug it is built to catch.

#PageTechnique it teachesThe bug it catches
2Equivalence PartitioningSplit inputs into classes that behave alike; test one per classWhole categories of input silently mishandled (e.g. negatives, empties)
3Boundary Value AnalysisTest values on and around the edges of each classOff-by-one, wrong operator (< vs <=), overflow at the limit
4Decision TablesEnumerate combinations of conditions and their required actionsMissing or contradictory rules in tangled business logic
5State-Transition TestingModel states and events; test valid and invalid transitionsIllegal transitions, stuck states, actions allowed in the wrong mode
6Pairwise and Combinatorial TestingCover every pair of option values without the full cross-productBugs triggered by a specific combination of settings/flags
7Error Guessing and Experience-Based TestingAim tests using known failure patterns and intuitionThe weird inputs specs forget: nulls, huge values, emoji, leap days
8Combining Techniques in PracticeLayer the techniques on one real feature, budgeting effortGaps left when any single technique is used alone
900Revision — Test Design Techniques RecapConsolidates the whole Part

Here is the one idea to carry through every page that follows. Confidence is not earned by quantity of tests — a million random cases prove almost nothing you can name. It is earned by testing the places where software is most likely to break, and being able to say, precisely, which of those places you covered.

Each technique in this Part is a lens on “where does this break?”: partitioning finds forgotten categories, boundary analysis finds the edges, decision tables find the missing rules, state machines find the illegal sequences, pairwise finds the toxic combinations, and error guessing finds what the spec never imagined. Test design is how you turn the mindset from Part one — reason about where things break — into a small, deliberate, defensible set of cases.

  1. Roughly how long would it take to exhaustively test a two-argument 32-bit integer function at a billion cases per second, and what conclusion does that force about testing in general?
  2. State the core problem of test design in one sentence, using the words edge cases and without.
  3. Give two concrete reasons random test selection is a poor strategy for finding known bug patterns.
  4. Name the two families of techniques in this Part and the different question each one reasons about.
  5. Using the book’s throughline, explain why “we ran a million random inputs” is weaker evidence of correctness than “we tested every boundary and every equivalence class.”
Show answers
  1. About 584 years (2³² × 2³² ≈ 1.8 × 10¹⁹ combinations at 10⁹/s). It forces the conclusion that exhaustive testing is impossible even for trivial code, so all testing is sampling — the only question is which sample.
  2. Test design is finding the edge cases that break software without running every possible input.
  3. First, random inputs rarely land on the specific values where bugs cluster (an off-by-one at a single boundary is almost never hit by chance). Second, random selection gives you no known coverage — you can’t say what you exercised, so you can’t say when you’re done or defend the confidence.
  4. Input-space techniques (equivalence partitioning, boundary value analysis, pairwise/combinatorial) reason about which values to feed in. Behavior techniques (decision tables, state-transition testing) reason about which logic paths and event sequences to exercise.
  5. Confidence comes from covering where software is most likely to break, and from being able to name what you covered. A million random inputs prove almost nothing you can point to; a set of designed cases with known coverage lets you say exactly which categories, edges, rules, and transitions you exercised — that nameable coverage is what turns testing into justified confidence.