Lesson 09 / 12
Test Design Techniques
Systematic ways to select a finite check set from an infinite input space: equivalence classes, boundary value analysis, and the decision table that counts condition combinations exhaustively.
Contents
So far, the inputs to the checks were chosen as examples: 3 days, 4 days, 100 days. The reason for the choice was intuition, and intuition is not repeatable — when two people test the same function, two different sets come out, and what either one covers stays unclear.
This lesson turns input selection into a justifiable process. Three techniques are covered: grouping inputs that behave the same into classes, probing the edges of the classes, and counting condition combinations exhaustively.
The Input Space Is Infinite, the Check Count Is Finite
The boundary established in the first lesson becomes operative here: a test can show the presence of a defect, not its absence, because an infinite input set cannot be tried with a finite number of inputs. So the question to ask should not be “was everything tried” but “what do the ones that were tried represent.”
Test design techniques establish this representation relationship. Each technique divides the input space by a criterion and justifies selecting from the partitions. Because the criterion is written down, why particular inputs were chosen, and what was left out, is written down too.
The loan decision is used as the example. The decision depends on three rules: the book is not lent if it is reserved for someone else, it is not lent if the member’s debt has reached the limit, and it is not lent if the member’s active loan count has reached their type’s limit.
// decision.mjs — the three-rule implementation of the loan decision export const LOAN_LIMIT = { student: 10, member: 5 }; export const DEBT_LIMIT = 20; export function loanDecision({ memberType, activeLoans, debt, reservedForAnother }) { if (!(memberType in LOAN_LIMIT)) throw new Error(`unknown member type: ${memberType}`); if (!Number.isInteger(activeLoans) || activeLoans < 0) throw new Error('invalid active loan count'); if (reservedForAnother) return 'reserved'; if (debt > DEBT_LIMIT) return 'debt'; if (activeLoans >= LOAN_LIMIT[memberType]) return 'limit'; return 'granted'; }
Equivalence Classes
An equivalence class is a set of inputs the program handles the same way. The assumption is: if one member of a class exposes a defect, the other members of the same class do too. If this assumption holds, one representative per class is enough.
The debt value is split into two classes: those below the limit and those that reach it. The active loan count is split into two the same way. Reservation status is already two-valued.
// classes.mjs — one representative from each equivalence class import { loanDecision } from './decision.mjs'; const base = { memberType: 'member', activeLoans: 2, debt: 8, reservedForAnother: false }; const REPRESENTATIVES = [ ['debt class: below limit', { debt: 8 }, 'granted'], ['debt class: above limit', { debt: 35 }, 'debt'], ['loan class: below limit', { activeLoans: 2 }, 'granted'], ['loan class: above limit', { activeLoans: 7 }, 'limit'], ['reserved: no', { reservedForAnother: false }, 'granted'], ['reserved: yes', { reservedForAnother: true }, 'reserved'], ]; let passed = 0; for (const [name, change, expected] of REPRESENTATIVES) { const observed = loanDecision({ ...base, ...change }); if (observed === expected) passed += 1; else console.log(` deviation: ${name} — expected ${expected}, observed ${observed}`); } console.log(`equivalence classes: ${passed}/${REPRESENTATIVES.length} representatives matched`);
equivalence classes: 6/6 representatives matched
All six representatives matched. The payoff of the technique shows here: instead of tens of thousands of possible debt values, two values were tried, and it became written down that every class was covered.
Two kinds of division are made when splitting classes. A valid partition collects the inputs the program must accept, an invalid partition collects the ones it must reject — a negative loan count and an unknown member type are invalid partitions. Invalid partitions are tried one at a time; if two invalid values are combined in the same check, it is common for the first rejection to shadow the second and leave the defect invisible.
Boundary Value Analysis
The equivalence class assumption weakens in one place: at the edge of the classes. A large share of defects cluster in comparison operators, and an operator error shows up only at the edge — a representative chosen from the middle of the class cannot see it.
Boundary value analysis produces three values for each class edge: one below the threshold, the threshold itself, and one above it. The values are not derived by hand but from the declared thresholds; when a threshold changes, the checks change with it automatically.
// boundary.mjs — values derived from the edges of the classes import { loanDecision, LOAN_LIMIT, DEBT_LIMIT } from './decision.mjs'; const neighbors = (threshold) => [threshold - 1, threshold, threshold + 1]; const base = { memberType: 'member', activeLoans: 2, debt: 8, reservedForAnother: false }; const CHECKS = [ ...neighbors(DEBT_LIMIT).map((debt) => ({ name: `debt=${debt}`, input: { debt }, expected: debt >= DEBT_LIMIT ? 'debt' : 'granted', })), ...neighbors(LOAN_LIMIT.member).map((activeLoans) => ({ name: `activeLoans=${activeLoans}`, input: { activeLoans }, expected: activeLoans >= LOAN_LIMIT.member ? 'limit' : 'granted', })), ]; let deviations = 0; for (const d of CHECKS) { const observed = loanDecision({ ...base, ...d.input }); if (observed !== d.expected) { deviations += 1; console.log(` deviation: ${d.name} — expected ${d.expected}, observed ${observed}`); } } console.log(`boundary values: ${CHECKS.length} checks, ${deviations} deviation${deviations === 1 ? '' : 's'}`);
deviation: debt=20 — expected debt, observed granted boundary values: 6 checks, 1 deviation
Six checks, one deviation. A member with debt at exactly 20 units is still lent the book; the rule says “reaching the limit” while the implementation says “exceeding the limit.” This defect would not have been visible with any representative chosen from the middle of the classes, and indeed it was not visible in the previous set.
How many values should be tried? A three-value boundary (one below, the threshold, one above), as above, catches every operator error. A two-value boundary (the threshold and one above) is cheaper and finds most of the same defects. The choice is made according to the risk the threshold carries — three values are preferred for money, authority, and duration thresholds.
Decision Table
Equivalence classes and boundary values handle inputs one at a time. When rules affect one another, this is not enough: which condition combinations were tried and which were skipped stays invisible.
A decision table writes conditions as rows and combinations as columns, and states which action each combination produces. Three conditions mean eight combinations; when the count is done by a program, none of them is skipped.
// decision-table.mjs — counting all combinations of three conditions and merging rules const CONDITIONS = ['reserved', 'debt', 'loan']; function action([reserved, debt, limit]) { if (reserved) return 'reserved'; if (debt) return 'debt'; if (limit) return 'limit'; return 'granted'; } const combinations = []; for (let i = 0; i < 2 ** CONDITIONS.length; i += 1) { const row = CONDITIONS.map((_, j) => Boolean((i >> (CONDITIONS.length - 1 - j)) & 1)); combinations.push({ row, action: action(row) }); } console.log(`full count: ${combinations.length} combinations (${CONDITIONS.join(' / ')})`); for (const b of combinations) { console.log(` ${b.row.map((v) => (v ? 'Y' : 'N')).join(' ')} -> ${b.action}`); } const groups = new Map(); for (const b of combinations) { if (!groups.has(b.action)) groups.set(b.action, []); groups.get(b.action).push(b.row); } console.log(`merged rule count: ${groups.size}`); for (const [act, rows] of groups) { const rule = CONDITIONS.map((_, j) => { const values = new Set(rows.map((s) => s[j])); return values.size === 1 ? ([...values][0] ? 'Y' : 'N') : '-'; }); const coverage = rule.reduce((n, k) => n * (k === '-' ? 2 : 1), 1); console.log(` ${rule.join(' ')} -> ${act.padEnd(8)} ${rows.length} combination${rows.length === 1 ? '' : 's'}, rule covers ${coverage} combination${coverage === 1 ? '' : 's'}`); }
full count: 8 combinations (reserved / debt / loan) N N N -> granted N N Y -> limit N Y N -> debt N Y Y -> debt Y N N -> reserved Y N Y -> reserved Y Y N -> reserved Y Y Y -> reserved merged rule count: 4 N N N -> granted 1 combination, rule covers 1 combination N N Y -> limit 1 combination, rule covers 1 combination N Y - -> debt 2 combinations, rule covers 2 combinations Y - - -> reserved 4 combinations, rule covers 4 combinations
Eight combinations collapsed into four rules. The - mark shows a don’t care
condition: once the book is reserved for someone else, debt and loan count do not change
the result, because the reservation check comes before the others. The correctness of the
merge is confirmed in the last column — the number of combinations a rule covers must
equal the number of combinations in its group.
The real payoff of the decision table is making the rules’ priority visible. What response should a member with debt at the limit and a reserved book get? The table forces this question to be asked; if the specification never wrote it down, the gap surfaces while drawing the table. The check count is also determined in a stable way: four checks are enough for four rules, eight checks are unnecessary.
As the condition count grows, the combination count doubles; ten conditions mean one thousand and twenty-four combinations. At this point exhaustive counting is dropped, and combinations where each condition alone determines the result are selected instead. The selection criterion changes; the table itself does not.
The Division of Labor Among Techniques
The three techniques answer the same question from different places, and none replaces the others.
Equivalence classes shrink the input space and write down what is being represented. Boundary values catch defects at the edges of the classes — in the example, only this technique found the one defect. The decision table ensures condition combinations are counted exhaustively and makes priority gaps in the specification visible.
Sequential behavior has its own technique: when a record moves from state to state, what needs to be tried is not values but transitions. This technique is taken up in the final lesson.
Summary
- Test design techniques take input selection out of intuition and turn it into a justifiable process; because the criterion is written down, what was left out is written down too.
- An equivalence class is the set of inputs the program handles the same way; the one-representative-per-class assumption holds in the middle of the class and weakens at its edge.
- Invalid partitions are tried one at a time; when combined, the first rejection shadows the second.
- Boundary value analysis tries one below the threshold, the threshold, and one above it; in the example, only this technique found the defect that a member with debt at exactly the limit was still lent the book.
- The decision table counts condition combinations exhaustively, lowers the check count by merging don’t-care conditions, and makes priority gaps in the specification visible.
Next Step
The techniques determined which inputs to try. Being able to explain what was tried to someone else is a separate matter: what state to start in, which steps to follow, what to observe? An automated check carries this information inside the code and only tells it to whoever reads the code. The next lesson gives this information a written form and shows the move from that form to a runnable test.
To keep your progress and take notes, Log in
My notes
Log in to take notes.