Skip to content

Combining Techniques in Practice

The last six pages each handed you a lens. Equivalence partitioning collapses an infinite input space into a few classes. Boundary value analysis sharpens the edges of those classes. Decision tables untangle combinational logic. State-transition testing covers behaviour that depends on history. Pairwise tames an explosion of independent parameters. And error guessing fills the gaps the systematic techniques leave behind.

Here is the thing nobody tells you until you’ve shipped a few features: none of these techniques is a strategy on its own. A real feature has a bit of everything — a range here, a combination there, a few independent knobs, a boundary that will hurt if you get it wrong. Point one technique at it and you cover one shape of input while missing the others. This page is about the move that turns a toolkit into a suite: matching each technique to the shape of the input, then layering them on one feature so their coverage stacks instead of overlaps wastefully.

The throughline of the whole book lands hardest here. Intentional test selection earns justified confidence cheaply — but only if you pick the right tool for each part of the feature and can say, out loud, what every test is for.

A decision guide: match the technique to the shape of the input

Section titled “A decision guide: match the technique to the shape of the input”

Before you write a single case, look at the feature and ask what shape each input has. The shape tells you the technique. This is the map:

Shape of the input Reach for
────────────────────────────────────────────────────────────────
An ordered range or continuum ──► Partitioning + BVA
(age, price, length, dates) (classes, then edges)
Output depends on a COMBINATION of ──► Decision table
several conditions (if A and B but C…) (rules → cases)
Behaviour depends on HISTORY / ──► State-transition
what happened before (a workflow) (states, events, edges)
MANY independent parameters, each ──► Pairwise / combinatorial
with a few values, no shared logic (cover all value-pairs)
"What weird thing could break this?" ──► Error guessing
(the gaps the above miss) (experience + attack)

None of these are mutually exclusive. A single feature usually has two or three shapes at once — a range and a combination and a pile of parameters. The skill is not picking one technique; it is decomposing the feature into its shapes and applying the matching technique to each. Do that and coverage becomes deliberate: every test traces back to a shape, and every shape traces back to a technique.

The worked feature: a shipping-cost calculator

Section titled “The worked feature: a shipping-cost calculator”

Let’s make this concrete with one feature and drive every technique through it. The feature is a shipping-cost estimator for an online store. Its signature:

estimate_shipping(
weight_kg, # 0.0 < weight <= 30.0, else rejected
destination, # "domestic" | "eu" | "international"
speed, # "standard" | "express"
membership, # "none" | "plus" | "prime"
is_gift # bool — adds gift wrapping
) -> price | Rejected

The business rules:

  • Weight above 30 kg or at/below 0 is rejected — parcel service won’t carry it.
  • Base cost depends on destination and weight.
  • express adds a surcharge; international + express is only offered for plus/prime members (otherwise rejected).
  • prime members get free standard domestic shipping; plus members get 50% off standard shipping.
  • is_gift adds a flat wrapping fee, always.

Read those rules again and you can already see the shapes: weight is a range. The destination × speed × membership interaction is a combination. And once you add is_gift and the membership tiers, you have several independent-ish parameters. That is the decomposition. Now we layer.

Start with the one continuous input: weight_kg. Partitioning gives us classes of uniform behaviour:

weight_kg partitions
──────────────────────────────────────────────
(-∞, 0] invalid — rejected
(0, 30] valid — priced
(30, +∞) invalid — rejected

One representative from each class — say -2, 12.5, 45 — proves the calculator accepts the valid band and rejects outside it. Three cases, and the whole continuum of weights is represented. That is partitioning doing its job: breadth for almost nothing.

But partitioning quietly assumes every value inside (0, 30] behaves the same. We know from the previous pages that the assumption breaks exactly at the edges — so we layer BVA on top.

The valid band (0, 30] has two edges: a lower edge at 0 (exclusive) and an upper edge at 30 (inclusive). Apply the three-value approach at each, respecting the type’s granularity — weight is a decimal, so “just above/below” moves by the smallest meaningful step (here, 0.01 kg):

lower edge (0, exclusive): 0.00 (reject) 0.01 (accept) ...
upper edge (30, inclusive): 29.99 (accept) 30.00 (accept) 30.01 (reject)

That yields the boundary values 0.00, 0.01, 29.99, 30.00, 30.01. Each one targets a specific bug the middle value 12.5 can never reveal:

ValueTargets
0.00weight > 0 written as weight >= 0 — accepting a zero-weight parcel
0.01confirms the valid band genuinely opens just above zero
29.99confirms the band holds near the top
30.00weight < 30 off-by-one that rejects a valid 30 kg parcel
30.01weight <= 30 written as <= 31 — accepting an over-limit parcel

Layers 1 and 2 together handle the range shape completely: partitioning found the edges, BVA hit them precisely. Neither alone was enough — partitioning would ship the off-by-one at 30.00; BVA alone had no principled way to know where 30 even was.

Now the tangle: the rules where the price depends on a combination of destination, speed, and membership. Prose hides contradictions; a decision table exposes them. We build one for the trickiest cluster — whether the order is even offered, and which discount applies:

Conditions R1 R2 R3 R4 R5 R6
────────────────────────────────────────────────────────────────────
destination = international Y Y Y - - -
speed = express Y Y N - - -
membership in {plus, prime} N Y - - - -
destination = domestic - - - Y Y Y
speed = standard - - - Y Y Y
membership = prime - - - Y N N
membership = plus - - - - Y N
────────────────────────────────────────────────────────────────────
Actions
Reject (not offered) X
Price: intl express X
Price: intl standard X
Price: free (prime domestic std) X
Price: 50% off (plus domestic std) X
Price: full standard X

Each column is one test case, and each is justified by a rule, not by intuition. Rule R1 alone catches the “international express for a non-member” rejection — a case a careless suite would forget because it sits at the intersection of three conditions. The table also earns its keep before any test runs: filling it in is when you discover the spec never said what happens for international + standard + prime. Ambiguity surfaced at design time is a bug caught for free.

Layer 4 — Pairwise the remaining parameters

Section titled “Layer 4 — Pairwise the remaining parameters”

We still haven’t systematically exercised the interaction between all the knobs — including is_gift, which is independent of everything else. Testing every combination is the explosion pairwise exists to defuse:

weight-class (3) × destination (3) × speed (2) × membership (3) × is_gift (2)
= 3 × 3 × 2 × 3 × 2 = 108 full combinations

108 cases for one estimator is more than the feature is worth. Pairwise cuts it down by covering every pair of values at least once — enough to catch any bug that needs just two parameters to interact, which empirically is the overwhelming majority. A pairwise generator reduces those 108 to roughly 9–12 cases:

# | weight | destination | speed | membership | is_gift
──┼────────┼───────────────┼──────────┼────────────┼────────
1 | valid | domestic | standard | none | false
2 | valid | eu | express | plus | true
3 | valid | international | express | prime | false
4 | low | domestic | express | prime | true
5 | high | eu | standard | none | false
6 | valid | international | standard | plus | false
7 | valid | eu | standard | prime | true
8 | valid | domestic | express | none | true
9 | valid | international | express | none | false ← reject
...

Every value of every parameter appears, and every pair (say international + express, or prime + is_gift=true) shows up in at least one row. Row 9 even re-catches the R1 rejection from the decision table — which brings us to the interesting part.

Where the techniques overlap — and where each uniquely earns its place

Section titled “Where the techniques overlap — and where each uniquely earns its place”

You may have noticed the layers touch the same feature more than once. Row 9 of the pairwise set re-checks a rejection the decision table already covered. That overlap is not waste — it is defence in depth — but it does raise the honest question: does every technique earn its place, or are some redundant?

Here is the audit, per shape:

  • Partitioning uniquely guarantees no class of weight is unrepresented. Pairwise picks weight values too, but only as a parameter — it has no notion of “the invalid-low class must appear.” Drop partitioning and you might test three valid weights and zero rejections.
  • BVA uniquely hits 30.00 versus 30.01. No other technique here even looks at that granularity — pairwise treats weight as three coarse buckets. Drop BVA and the off-by-one ships.
  • The decision table uniquely proves the logic is right for each rule, including the “international + express + non-member = reject” intersection. Pairwise might stumble onto that combination, but it doesn’t guarantee the three-way condition, and it can’t tell you the resulting price is correct per rule.
  • Pairwise uniquely covers interactions the table ignores — e.g. is_gift combined with every membership tier — cheaply, without you enumerating them by hand.
range combination interaction
edges of conditions of many params
│ │ │
Partition+BVA Decision table Pairwise
└────────────────┴────────────────┘
covers overlapping middle,
but each owns a corner nothing else does

The overlap is in the middle of the diagram; the value is in the corners. Remove any one technique and a corner of the feature goes untested — a corner that, by the logic of each earlier page, is exactly where a bug likes to live.

You will rarely get to write all 24 cases before a deadline. So layering comes with a priority order, driven by risk — the product of likelihood a bug lives here and cost if it ships:

  1. Highest-risk boundaries first. The 30.00 upper edge and the 0.00 lower edge. Off-by-ones are the most common defect and the boundary is where they hide. If money or safety rides on the edge, it is case number one.
  2. High-risk combinations next. The decision-table rules that reject or charge differently — R1 (international express non-member reject) and R4 (prime free shipping). A wrong price is a direct revenue or trust bug.
  3. One representative per partition. Cheap breadth so no whole class is dark. 12.5 kg, a rejection, and a normal happy path.
  4. Pairwise interactions, as budget allows. Add rows until you run out of time; because each row covers many new pairs, the coverage curve is steep early and flat late — so even a truncated pairwise set is worth a lot.
  5. Error guessing on whatever’s left. weight = NaN, destination = "Domestic" (wrong case), a negative-zero, an emoji in a field. The experience-based net over the systematic gaps.

The rule of thumb: spend the first hour on the edges and the combinations that cost the most when wrong. Everything else is a lower-risk addition to an already-defensible core.

The final discipline is what separates a suite you can defend from one that merely exists: state what each test targets. A suite is auditable only if a reviewer can read it and see the intent behind every case. In practice that means naming and commenting tests after the shape and technique they serve. Here is the shipping suite expressed as runnable checks, each annotated with its job:

# --- Layer 1: partitioning (weight classes) ---
assert estimate(-2, "domestic", "standard", "none", False) is REJECTED # invalid-low class
assert estimate(12.5, "domestic", "standard", "none", False) == price # valid class
assert estimate(45, "domestic", "standard", "none", False) is REJECTED # invalid-high class
# --- Layer 2: BVA (upper edge of valid band) ---
assert estimate(29.99, "domestic", "standard", "none", False) != REJECTED # just below 30
assert estimate(30.00, "domestic", "standard", "none", False) != REJECTED # ON edge (off-by-one)
assert estimate(30.01, "domestic", "standard", "none", False) is REJECTED # just above 30
# --- Layer 3: decision table (rule R1 — intl express non-member) ---
assert estimate(5, "international", "express", "none", False) is REJECTED # R1: not offered
# --- Layer 3: decision table (rule R4 — prime free domestic standard) ---
assert estimate(5, "domestic", "standard", "prime", False) == 0.0 # R4: free shipping
# --- Layer 4: pairwise (is_gift interacts with prime discount) ---
assert estimate(5, "domestic", "standard", "prime", True) == GIFT_FEE # free + wrap

Read top to bottom, this suite narrates its own coverage. A teammate can see that the range is partitioned, the upper edge is boundary-tested, the rejection and discount rules are individually justified, and the gift-wrap interaction is exercised. Nothing is accidental; every line answers “what would break if this were removed?” with a specific bug. That is what “auditable, not accidental” means — coverage you can point at, not coverage you hope you have.

Compare this to a suite of ten checks all near 12.5 kg, domestic, standard, none — comfortable middle values that pass no matter how the logic bends. It has more green checkmarks and less confidence, because the checkmarks aren’t aimed anywhere. The layered suite has fewer cases and more confidence, because each case is aimed at a place bugs live.

Every technique in this Part is a way of spending a limited test budget where defects concentrate rather than where they are convenient. Combining them is the same idea one level up: decompose a feature into its shapes, apply the matching technique to each, layer them so their coverage stacks, prioritize the highest-risk edges and combinations first, and annotate every test with its intent.

Do that and you get the promise the book keeps making — justified confidence earned cheaply. Not confidence from a huge suite, not confidence from luck, but confidence you can defend case by case, because you chose each one on purpose. Intentional selection is the whole game; combining techniques is how you play it on a real feature.

  1. The decision guide maps input shapes to techniques. For each of these inputs, name the shape and the matching technique: an order total valid over $0–$500; a user_role × action × resource permission check; a five-step checkout wizard; and eight independent feature-flag toggles.
  2. In the shipping example, partitioning already picks a weight from the valid band. Why do we still need boundary value analysis on top of it — what does BVA catch that the partition representative cannot?
  3. The pairwise set and the decision table both covered the “international + express + non-member = reject” case. Is that overlap wasteful? Explain what each technique uniquely guarantees about that case.
  4. You have one hour to test the shipping calculator. Which cases do you write first and why? State the risk principle you are applying.
  5. What does it mean to make coverage “explicit,” and why is a suite of ten passing tests clustered near a comfortable middle value less trustworthy than a suite of six deliberately layered ones?
Show answers
  1. Order total $0–$500 is an ordered rangepartitioning + BVA. user_role × action × resource is a combination of conditionsdecision table. The five-step wizard is history-dependent behaviourstate-transition testing. Eight independent toggles are many independent parameterspairwise/combinatorial.
  2. Partitioning picks one representative (e.g. 12.5 kg) and assumes the whole valid band behaves identically — but that assumption breaks at the edges. BVA tests 30.00 versus 30.01 (and 0.00 versus 0.01), catching the off-by-one and <-versus-<= mistakes at the band’s edges that a comfortable middle value can never reveal.
  3. Not wasteful — it is defence in depth, but each technique earns its place differently. The decision table uniquely guarantees the three-way condition (international and express and non-member) is tested and that the resulting action (reject) is correct per the rule. Pairwise uniquely guarantees every pair of parameter values appears cheaply, but it does not guarantee any specific three-way interaction, nor verify the rule’s outcome — it may cover the case only by luck.
  4. First: the highest-risk boundaries (30.00/30.01, 0.00/0.01) because off-by-ones are the most common defect and live at the edge; then the high-risk combinations — decision-table rules that reject or change the price (revenue/trust bugs). The principle is risk = likelihood × cost: spend scarce time where a bug is both likely and expensive.
  5. Making coverage explicit means annotating each test with the shape and technique it targets, so a reviewer can trace every case to a reason and see the suite is intentional, not accidental. Ten tests clustered at a comfortable middle value all pass regardless of how the logic bends — they aren’t aimed at where bugs live — so they yield green checkmarks without confidence. Six deliberately layered tests each target a specific failure mode (an edge, a rule, an interaction), so they buy defensible confidence for fewer cases.