EduFlow
Testing JavaScript Apps
0%

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.

  1. 1The function under test
  2. 2Equivalence classes and boundaries
  3. 3Table-driven tests with test.each
  4. 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:

JavaScript
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:

Two number lines. Amount: 0 is rejected, 0.01 is the smallest accepted value. Decimal places: two are accepted, three are rejected. Amount rejected accepted '0' '0.01' Decimals 0, 1 or 2 accepted 3+ rejected '12.50' '12.505'
Test each side of every boundary, not only the middle

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:

src/parse-amount.test.js
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:

JavaScript
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:

  1. 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.
  2. 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.
  3. 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.each turns a table of inputs and expected outputs into one named test per row, so adding a case is one line.
  • Name each row with %s placeholders or $field so a failure tells you which input broke.

Further reading

Check your understanding

3 questions

Question 1 of 3

parseAmount accepts up to two decimal places. Which set of inputs best tests that rule?

Question 2 of 3

What are the names of the tests this produces?

JavaScript
test.each([
  ['12', 1200],
  ['0.5', 50],
])('parses %s as %i cents', (input, cents) => {
  expect(parseAmount(input)).toBe(cents);
});
Question 3 of 3

A form’s amount field rejects zero. Which bug does a test with input '0' catch that '1' and '-1' miss?

Practice

Code playground

A table that breaks parseAmount

parseAmount(input) turns what a user typed into integer cents. Its rules are in the subject’s comment.

The starter has two test.each tables that all pass but catch nothing interesting: every row sits safely inside a class. Add rows (and tables if you like) until your suite catches four hidden bugs: one accepts three decimals, one reads '12.5' as 1205 cents, one accepts zero, and one forgets to trim spaces.

// Parses what a user typed in the amount field and returns integer cents.
// Accepts: digits, optionally a dot and 1 or 2 decimals, surrounding spaces.
//   "12" -> 1200, "12.5" -> 1250, " 7.10 " -> 710
// Throws an Error for: empty input, zero, negatives, letters,
// commas and more than 2 decimals.
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;
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real parseAmount
  • Catches the version that accepts three decimals
  • Catches the version that reads ‘12.5’ as 1205 cents
  • Catches the version that accepts a zero amount
  • Catches the version that does not trim spaces