---
title: 'Test Design Techniques'
source: 'https://academia.sh/en/courses/testing-fundamentals/test-design-techniques'
course: 'Quality and Testing Fundamentals'
language: en
updated: '2026-08-23T14:25:18+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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.

```js
// 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.
