Skip to content

Écrire un test table-driven snippet

Le test table-driven, c'est une seule boucle sur des cas : entrée, attendu, nom.

Le test table-driven, c'est une seule boucle sur des cas : entrée, attendu, nom. Tous les écosystèmes y ont convergé — le for-range de Go sur une slice anonyme, pytest.mark.parametrize, it.each de Vitest — parce qu'un nouveau cas s'ajoute sans toucher à la logique. Le piège, c'est le nommage des échecs : une boucle qui se contente d'un assert nu rapporte « échec à la ligne 12 » pareil pour chaque cas ; le nom propre du cas doit voyager jusqu'à l'assertion.

Recette exécutable · 12 langages
Testing & QAtestingtable-drivenunit-testsparametrizetdd

Every language

12 langages, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
import { describe, expect, it } from 'vitest';

const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));

describe('clamp', () => {
  it.each([
    [5, 0, 10, 5],   // in range
    [-3, 0, 10, 0],  // below
    [99, 0, 10, 10], // above
  ])('clamp(%i, %i, %i) → %i', (n, lo, hi, want) => {
    expect(clamp(n, lo, hi)).toBe(want);
  });
});

it.each printf-formats the title per row (%s strings, %i ints, %j JSON) — that title IS the failure name: the report reads 'clamp(99, 0, 10) → 5', not a line number every row shares. describe.each is the same mechanism one level up (a suite per row); a plain for-loop around expect() has no title to format.

TSTypeScript
import { expect, it } from 'vitest';

const clamp = (n: number, lo: number, hi: number): number =>
  Math.min(hi, Math.max(lo, n));

const cases = [
  [5, 0, 10, 5],   // in range
  [-3, 0, 10, 0],  // below
  [99, 0, 10, 10], // above
] as const; // rows stay 4-tuples — without this the table is number[][]

it.each(cases)('clamp(%i, %i, %i) → %i', (n, lo, hi, want) => {
  expect(clamp(n, lo, hi)).toBe(want);
});

as const keeps each row a 4-tuple — without it the table widens to number[][] and a row with the wrong arity fails at runtime, not compile time (append `satisfies ReadonlyArray<readonly [number, number, number, number]>` to pin the arity in the type). The %i title is still what vitest prints when the row fails.

GoGo
import "testing"

func clamp(n, lo, hi int) int { return min(hi, max(lo, n)) } // min/max: Go 1.21+ builtins

func TestClamp(t *testing.T) {
	tests := []struct {
		name      string
		n, lo, hi int
		want      int
	}{
		{"in range", 5, 0, 10, 5},
		{"below", -3, 0, 10, 0},
		{"above", 99, 0, 10, 10},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) { // the subtest name IS the row's name
			if got := clamp(tt.n, tt.lo, tt.hi); got != tt.want {
				t.Errorf("clamp(%d, %d, %d) = %d, want %d", tt.n, tt.lo, tt.hi, got, tt.want)
			}
		})
	}
}

t.Run(tt.name, ...) turns each row into a subtest: a failure prints '--- FAIL: TestClamp/below' and 'go test -run TestClamp/below' re-runs exactly that row. Asserting bare inside the loop reports one anonymous file:line for every case — the name field must travel into t.Run, never just into the error message.

RsRust
#[test]
fn clamp_cases() {
    let cases: Vec<(i32, i32, i32, i32)> = vec![
        (5, 0, 10, 5),   // in range
        (-3, 0, 10, 0),  // below
        (99, 0, 10, 10), // above
    ];
    for (n, lo, hi, want) in cases {
        assert_eq!(n.clamp(lo, hi), want, "clamp({n}, {lo}, {hi})");
    }
}

std has no parametrize — the loop over a Vec of tuples is the whole idiom, and assert_eq!'s trailing message slot ('clamp({n}, {lo}, {hi})') is the only thing that identifies the failing row; the message is formatted on failure only. #[should_panic] is the tool for must-error cases; the rstest crate's #[rstest]/#[case] adds per-row subtests without the loop. Note n.clamp(lo, hi) panics when lo > hi.

PHPPHP
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

function clamp(int $n, int $lo, int $hi): int
{
    return max($lo, min($hi, $n));
}

final class ClampTest extends TestCase
{
    public static function cases(): \Generator // public static — non-negotiable
    {
        yield 'in range' => [5, 0, 10, 5];
        yield 'below' => [-3, 0, 10, 0];
        yield 'above' => [99, 0, 10, 10];
    }

    #[DataProvider('cases')]
    public function testClamp(int $n, int $lo, int $hi, int $want): void
    {
        self::assertSame($want, clamp($n, $lo, $hi));
    }
}

DataProvider methods MUST be public static — a visibility slip fails every row with a 'data provider not found' error, not a test failure. The yield key ('below') becomes the test's name in the output; numeric keys degrade to 'testClamp with data set #0'. A generator streams big tables that a returned array would materialize.

PyPython
import pytest


def clamp(n: int, lo: int, hi: int) -> int:
    return min(hi, max(lo, n))


@pytest.mark.parametrize('n, lo, hi, want', [
    (5, 0, 10, 5),    # in range
    (-3, 0, 10, 0),   # below
    (99, 0, 10, 10),  # above
])
def test_clamp(n, lo, hi, want):
    assert clamp(n, lo, hi) == want

pytest auto-derives each row's id from its values — test_clamp[99-0-10-10] — and that id is both the failure name and the -k selector ('pytest -k 99' runs one row). Pass ids=['in range', ...] (or ids=some_function) when the values don't read as names; the ids must stay in row order.

C#C#
using System;
using Xunit;

public class ClampTests
{
    [Theory] // Fact runs once; Theory runs once per row
    [InlineData(5, 0, 10, 5)]   // in range
    [InlineData(-3, 0, 10, 0)]  // below
    [InlineData(99, 0, 10, 10)] // above
    public void Clamps(int n, int lo, int hi, int want)
        => Assert.Equal(want, Math.Clamp(n, lo, hi));
}

[Theory] vs [Fact] is the whole split: a Fact runs once, a Theory runs once per data row — and xUnit names each row by formatting the arguments into the method name (Clamps(n: 99, lo: 0, hi: 10, want: 10)), which is the failure name and the IDE filter. InlineData takes compile-time constants only; [MemberData]/[ClassData] for computed tables.

JvJava
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ClampTest {
    static int clamp(int n, int lo, int hi) {
        return Math.max(lo, Math.min(hi, n));
    }

    @ParameterizedTest(name = "clamp({0}, {1}, {2}) → {3}")
    @CsvSource({
            "5,  0, 10,  5",  // in range
            "-3, 0, 10,  0",  // below
            "99, 0, 10, 10"   // above
    })
    void clamps(int n, int lo, int hi, int want) {
        assertEquals(want, clamp(n, lo, hi));
    }
}

The name attribute carries the row: JUnit's default display name is 'clamps(int,int,int,int) [1]' — an argument-less index that tells you nothing about the case. {0}..{3} splice the actual arguments into the title. @CsvSource rows are strings (fine for scalars); @MethodSource replaces it when rows need real objects.

SwSwift
import XCTest
import Testing

func clamp(_ n: Int, _ lo: Int, _ hi: Int) -> Int { min(hi, max(lo, n)) }

final class ClampTests: XCTestCase {
    func testClamp() {
        let cases: [(name: String, n: Int, lo: Int, hi: Int, want: Int)] = [
            ("in range", 5, 0, 10, 5),
            ("below", -3, 0, 10, 0),
            ("above", 99, 0, 10, 10),
        ]
        for c in cases {
            XCTAssertEqual(clamp(c.n, c.lo, c.hi), c.want,
                "case '\(c.name)': clamp(\(c.n), \(c.lo), \(c.hi))")
        }
    }
}

// Swift Testing — the modern form: one test per row, named by its arguments
@Test(arguments: [
    (5, 0, 10, 5), (-3, 0, 10, 0), (99, 0, 10, 10),
])
func clamps(_ n: Int, _ lo: Int, _ hi: Int, want: Int) {
    #expect(clamp(n, lo, hi) == want)
}

XCTest has no parameterization: one test method looping over labeled tuples, and XCTAssertEqual's message: argument is the only failure naming — thread the case's own label into it or every failure reads the same. Swift Testing (the successor, default in new templates) fixes this: @Test(arguments:) runs a separate test per row and names each by its argument values.

KtKotlin
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.MethodSource

data class ClampCase(val n: Int, val lo: Int, val hi: Int, val want: Int)

class ClampTest {
    companion object {
        @JvmStatic // required — @MethodSource needs a static factory
        fun cases() = listOf(
            ClampCase(5, 0, 10, 5),   // in range
            ClampCase(-3, 0, 10, 0),  // below
            ClampCase(99, 0, 10, 10), // above
        )
    }

    @ParameterizedTest(name = "{0}") // the data-class toString names the row
    @MethodSource("cases")
    fun clamps(c: ClampCase) = assertEquals(c.want, c.n.coerceIn(c.lo, c.hi))
}

One data class per row beats @CsvSource's stringly columns — fields are typed, and the data class's generated toString IS the display name (ClampCase(n=99, lo=0, hi=10, want=10)), so name = '{0}' needs no manual format string. Same JUnit 5 annotations as Java; @JvmStatic on the companion factory is the Kotlin-only requirement.

RbRuby
def clamp(n, lo, hi)
  [lo, [n, hi].min].max
end

RSpec.describe '#clamp' do
  [
    [5, 0, 10, 5],    # in range
    [-3, 0, 10, 0],   # below
    [99, 0, 10, 10],  # above
  ].each do |n, lo, hi, want|
    describe "clamp(#{n}, #{lo}, #{hi})" do # fresh group per row
      subject { clamp(n, lo, hi) }
      it { is_expected.to eq want }
    end
  end
end

The nested describe per row is what makes subject safe: subject defined once on a shared outer group is overwritten by every loop iteration, so every example would see the LAST row's subject. The interpolated describe string ('clamp(99, 0, 10)') names the failure and rspec --example selects one row. minitest has no row-name machinery at all — loop inside one test method and pass assert_equal's message arg (assert_equal want, got, msg) naming the inputs.

ZigZig
const std = @import("std");

fn clamp(n: i32, lo: i32, hi: i32) i32 {
    return @min(hi, @max(lo, n));
}

test "clamp" {
    const Case = struct { name: []const u8, n: i32, lo: i32, hi: i32, want: i32 };
    const cases = [_]Case{
        .{ .name = "in range", .n = 5, .lo = 0, .hi = 10, .want = 5 },
        .{ .name = "below", .n = -3, .lo = 0, .hi = 10, .want = 0 },
        .{ .name = "above", .n = 99, .lo = 0, .hi = 10, .want = 10 },
    };
    for (cases) |tc| {
        std.debug.print("clamp case: {s}\n", .{tc.name}); // names the row pre-assert
        try std.testing.expectEqual(tc.want, clamp(tc.n, tc.lo, tc.hi));
    }
}

The case array is comptime-known — a new row edits the table, never the loop. Zig prints no per-row name when expectEqual fails, so std.debug.print the case first: the last name printed before the error is the failing row (and the printed inputs double as the repro). One test block per case, with a comptime-generated name via inline for, is the stronger alternative.