Section 2 · Unit Tests That Matter
Edge Cases and Table-Driven Tests
20 min3 min readCode playground
Lesson at a glance
Find the inputs that expose bugs with equivalence classes and boundary values, then cover them compactly with table-driven tests using test.each.
- 1The function under test
- 2Equivalence classes and boundaries
- 3Table-driven tests with test.each
- 4Where edge cases come from
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Most bugs don’t live in the middle of the input range. Nobody’s code breaks on “$12.34”. It breaks on “”, on “0”, on “12.5” read as twelve dollars and five cents, on " 7.10 " pasted with a trailing space. Finding those inputs on purpose is the skill this lesson teaches.
The function under test
Splitwise-lite’s amount field accepts what people type and turns it into integer cents:
function parseAmount(input) {
const text = String(input).trim();
if (!/^\d+(\.\d{1,2})?$/.test(text)) {
throw new Error(`Not a valid amount: "${input}"`);
}
const [whole, fraction = ''] = text.split('.');
const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'));
if (cents === 0) throw new Error('Amount must be greater than zero');
return cents;
}
for (const input of ['12', '12.5', ' 7.10 ', '0.01']) {
console.log(JSON.stringify(input), '->', parseAmount(input));
}
for (const input of ['', '0', '-5', '12.345', 'ten']) {
try { parseAmount(input); } catch (e) { console.log(JSON.stringify(input), '->', e.message); }
}Equivalence classes and boundaries
You can’t test every string, and you don’t need to. Equivalence partitioning means grouping inputs the code should treat the same way, and testing one representative of each group:
- whole numbers:
'12' - one decimal:
'12.5'(the dangerous one: is it 1250 or 1205?) - two decimals:
'12.50' - surrounded by spaces:
' 7.10 ' - invalid text:
'ten','','-5'
Boundary value analysis then adds the inputs at the edges between groups, because that is where < and <= get mixed up and where {1,2} becomes {1,3}. For each limit, test just below, on, and just above:
Here the boundaries are zero (rejected) versus one cent (accepted), and two decimals (accepted) versus three (rejected). A checklist I run through for any input:
- Empty and blank:
'',' ' - Zero, one, many:
'0','0.01','12345.67' - Each limit, from both sides: two versus three decimals
- Wrong kinds: letters, a minus sign, a comma (
'1,000') - Formatting noise: leading or trailing spaces, a trailing dot (
'12.')
Table-driven tests with test.each
Writing a separate test(...) for each of those is noisy. test.each takes a table and produces one named test per row:
import { describe, test, expect } from 'vitest';
import { parseAmount } from './parse-amount.js';
describe('parseAmount', () => {
test.each([
['12', 1200],
['12.5', 1250],
['12.50', 1250],
['0.01', 1],
[' 7.10 ', 710],
])('parses %s as %i cents', (input, cents) => {
expect(parseAmount(input)).toBe(cents);
});
test.each(['', '0', '0.00', '-5', '12.505', 'ten', '1,000'])(
'rejects %s',
(input) => {
expect(() => parseAmount(input)).toThrow();
},
);
});%s and %i are filled from each row, so the report reads parses 12.5 as 1250 cents, and a failure names the exact input. For wider tables, rows can be objects and the name can use $field:
test.each([
{ input: '12.5', cents: 1250 },
{ input: '0.01', cents: 1 },
])('parses $input as $cents cents', ({ input, cents }) => {
expect(parseAmount(input)).toBe(cents);
});The real win is maintenance. When a user reports that '12.' crashes the form, the regression test is one new row, not a new block of code.
Where edge cases come from
You don’t find edge cases by staring at the code. Three sources do most of the work:
- The spec, read adversarially. “Up to two decimals” immediately suggests two and three. “Must be positive” suggests zero. Every limit in a requirement is a boundary to test from both sides.
- Real users. People paste amounts from bank statements with spaces and currency signs. People in Germany type a comma as the decimal separator, so
'12,50'is not a typo for them; decide whether you reject it with a helpful message or accept it, and write the row either way. - Bug reports. Every bug that reached production is an edge case your table missed. Add the row first, watch it fail, then fix the code. The table becomes a record of everything that ever went wrong.
How many rows are enough? One per class, plus both sides of each boundary, plus one row per past bug. For parseAmount that is about fifteen rows, a few milliseconds of runtime and a very good night’s sleep. If you find yourself wanting thousands of random inputs, look at property-based testing libraries such as fast-check, which generate inputs and shrink failures to the smallest example; for most app code, a hand-picked table is clearer.
In the exercise, build a table that exposes four realistic parsing bugs. Next up: functions that throw and functions that return promises.
Key takeaways
- Group inputs into classes that the code should treat the same way, then test one typical value per class plus the edges between classes.
- Bugs cluster at boundaries, so test just below, on and just above every limit (zero, the maximum length, the decimal limit).
test.eachturns a table of inputs and expected outputs into one named test per row, so adding a case is one line.- Name each row with
%splaceholders or$fieldso a failure tells you which input broke.