C.W.K.
Stream
Lesson 02 of 03 · published

Parametrized Tests with test.each

~14 min · vitest-advanced, parametrized, test.each

Level 0Test Curious
0 XP0/32 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"One test, many rows. Adding a case is one line, not ten."

The Problem Parametrized Tests Solve

You're testing a function that maps inputs to outputs — a parser, an arithmetic helper, a validator, a formatter. The natural way to test it is a table:

  • sum(1, 2)3
  • sum(-1, 1)0
  • sum(0, 0)0
  • sum(1.5, 2.5)4

Without parametrization, that's four it() blocks with nearly identical bodies. Adding a fifth case means adding ten lines. With test.each (also exposed as it.each), the table is the data and the test definition runs once per row.

Two Syntaxes — Array of Arrays vs Tagged Template

Vitest exposes two equivalent ways to express the table:

Array of arrays — terse, programmatic, easy to compute:

test.each([[1, 2, 3], [-1, 1, 0]])('sum(%i, %i) = %i', (a, b, expected) => { ... });

Tagged template — reads like a spreadsheet, better for many columns or readability:

test.each`
  a    | b    | expected
  ${1} | ${2} | ${3}
  ${-1}| ${1} | ${0}
`('sum($a, $b) = $expected', ({ a, b, expected }) => { ... });

Pick by the test's center of gravity: numbers and simple values → array form; named columns with clear semantics → template form.

Generated Test Names — Use Them

The first argument to test.each is a template string with printf-style placeholders (%i, %s, %o for object) or template $name references. The generated name is what shows up in the test report. Good names turn five lines of failure output into a story:

"sum(-1, 1) = 0" failed: expected 0 but received -2

versus the bad-name version:

"sum works" failed: expected 0 but received -2

The first tells you exactly which row broke. The second forces you to read the test body.

Parametrized tests are right when the cases differ only in data. If your cases differ in setup, mocks, or assertion shape, that's not a table — that's three separate tests pretending to be one. The signal: when you start branching inside the body based on the row, stop and split.

When NOT to Parametrize

  • When you have two cases. Two it blocks are clearer than a two-row table.
  • When the test bodies diverge structurally — different mocks per case, different assertions per case. Forcing them into one table makes the test harder to read.
  • When the inputs are large or complex (deep objects, fixtures). The template loses its 'spreadsheet at a glance' quality.

Code

Array form — minimal ceremony·typescript
// Array form — terse, easy to compute table contents
import { describe, test, expect } from 'vitest';
import { sum } from './math';

describe('sum (parametrized)', () => {
  test.each([
    [1, 2, 3],
    [-1, 1, 0],
    [0, 0, 0],
    [1.5, 2.5, 4],
    [Number.MAX_SAFE_INTEGER, 1, Number.MAX_SAFE_INTEGER + 1],
  ])('sum(%i, %i) = %i', (a, b, expected) => {
    expect(sum(a, b)).toBe(expected);
  });
});
Tagged template form — reads as a table·typescript
// Tagged template form — reads like a spreadsheet
import { describe, test, expect } from 'vitest';
import { formatCurrency } from './format';

describe('formatCurrency (table)', () => {
  test.each`
    amount     | currency | expected
    ${0}       | ${'USD'} | ${'$0.00'}
    ${1}       | ${'USD'} | ${'$1.00'}
    ${1234.5}  | ${'USD'} | ${'$1,234.50'}
    ${-99.99}  | ${'USD'} | ${'-$99.99'}
    ${1000}    | ${'EUR'} | ${'€1,000.00'}
    ${1000}    | ${'JPY'} | ${'¥1,000'}
  `(
    'formatCurrency($amount, $currency) returns $expected',
    ({ amount, currency, expected }) => {
      expect(formatCurrency(amount as number, currency as string)).toBe(expected);
    }
  );
});
Programmatic rows — when the cases come from a data source·typescript
// Generating rows programmatically — when the table itself comes from data
import { describe, test, expect } from 'vitest';
import { isValidEmail } from './validation';

const validEmails = [
  'simple@example.com',
  'user.name+tag@example.co.uk',
  'a@b.io',
];

const invalidEmails = [
  '',
  'no-at-sign',
  '@no-local-part.com',
  'no-domain@',
  'spaces in@email.com',
];

describe('isValidEmail', () => {
  test.each(validEmails)('accepts "%s"', (email) => {
    expect(isValidEmail(email)).toBe(true);
  });

  test.each(invalidEmails)('rejects "%s"', (email) => {
    expect(isValidEmail(email)).toBe(false);
  });
});
describe.each — same suite, multiple targets·typescript
// describe.each — when whole describe blocks differ by parameter
import { describe, test, expect } from 'vitest';
import { createReducer } from './reducer';

// Run the same suite for every reducer variant.
describe.each([
  ['counter', createReducer('counter'), 0],
  ['toggle', createReducer('toggle'), false],
  ['list', createReducer('list'), []],
])('%s reducer', (name, reducer, initial) => {
  test('returns the initial state for an unknown action', () => {
    expect(reducer(initial as any, { type: 'UNKNOWN' })).toEqual(initial);
  });

  test('throws on a null action', () => {
    expect(() => reducer(initial as any, null as any)).toThrow();
  });
});

External links

Exercise

Rewrite the divide tests from track 2's assertion lesson as a single test.each table. Include the happy paths (10/2, 7/2, 0/10) and the throw case (5/0 — use a row where expected is the string 'throws' and switch on it inside the body, OR use a separate test.each for just the throw rows). Compare the readability and line count to the original.
Hint
If you find yourself branching inside the body to handle the throw case differently, that's a sign — split into one parametrized test for the happy paths and one for the error cases.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.