02 - Tests unitaires avec bun test
Ce que tu vas apprendre
- Lancer des tests avec
bun testsans aucune config - Structurer tes tests avec
describe,it,beforeEach - Maitriser les assertions :
toBe,toEqual,toThrowet les autres - Mesurer la couverture de code
Prerequisites
Avoir Bun installe. Avoir lu les articles précédents de cette serie.
Zero config
C'est la que Bun brille. Pas de fichier de configuration. Pas de jest.config.ts. Pas de vitest.config.ts. Tu créés un fichier qui finit par .test.ts, tu lances bun test, ca marche.
bash# Lancer tous les tests du projet
bun test
# Lancer un fichier specifique
bun test src/domain/math.test.ts
# Lancer les tests qui matchent un pattern
bun test --filter "calculateTotal"
Bun cherche automatiquement les fichiers *.test.ts, *.test.tsx, *.spec.ts, *.spec.tsx dans tout ton projet. Pas besoin de lui dire ou chercher.
Un premier test complet
Commençons avec un exemple réel. On a une fonction qui calcule le total d'une commande sur paltemps.fr :
typescript// src/domain/order.ts
export interface OrderItem {
product: string;
price: number;
qty: number;
}
export function calculateTotal(items: OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.qty, 0);
}
export function applyDiscount(total: number, code: string): number {
const discounts: Record<string, number> = {
WELCOME10: 10,
SUMMER20: 20,
VIP50: 50,
};
const percent = discounts[code];
if (!percent) throw new Error(`Code promo inconnu : ${code}`);
return Math.round(total * (1 - percent / 100) * 100) / 100;
}
Et voici les tests :
typescript// src/domain/order.test.ts
import { describe, it, expect } from "bun:test";
import { calculateTotal, applyDiscount } from "./order";
describe("calculateTotal", () => {
it("returns 0 for empty array", () => {
expect(calculateTotal([])).toBe(0);
});
it("calculates single item", () => {
expect(calculateTotal([{ product: "Widget", price: 10, qty: 2 }])).toBe(20);
});
it("calculates multiple items", () => {
const items = [
{ product: "Widget", price: 10, qty: 2 },
{ product: "Gadget", price: 5, qty: 3 },
];
expect(calculateTotal(items)).toBe(35);
});
it("handles zero quantity", () => {
expect(calculateTotal([{ product: "Widget", price: 10, qty: 0 }])).toBe(0);
});
});
describe("applyDiscount", () => {
it("applies WELCOME10 code", () => {
expect(applyDiscount(100, "WELCOME10")).toBe(90);
});
it("applies SUMMER20 code", () => {
expect(applyDiscount(50, "SUMMER20")).toBe(40);
});
it("throws on unknown code", () => {
expect(() => applyDiscount(100, "FAKE")).toThrow("Code promo inconnu");
});
});
Lance avec bun test et tu obtiens un résultat clair en quelques millisecondes.
Structure : describe, it, expect
Trois fonctions. C'est tout.
describe(name, fn): groupe des tests lies. Tu peux les imbriquer.it(name, fn): un test individuel. Certains preferenttest(), c'est un alias.expect(value): l'assertion. Tu chaînes avec un matcher.
typescriptdescribe("MonModule", () => {
describe("quand l'utilisateur est connecte", () => {
it("retourne son profil", () => {
// ...
});
it("affiche ses commandes", () => {
// ...
});
});
describe("quand l'utilisateur n'est pas connecte", () => {
it("retourne 401", () => {
// ...
});
});
});
J'aime bien nommer mes describe comme le nom de la fonction ou du module, et mes it comme des phrases lisibles. Quand un test echoue, tu lis "MonModule > quand l'utilisateur est connecte > retourne son profil" et tu sais exactement ou chercher.
Les matchers que tu vas utiliser tout le temps
typescript// Egalite stricte (===)
expect(2 + 2).toBe(4);
expect(result).toBe(null);
// Egalite profonde (objets, arrays)
expect(user).toEqual({ name: "Alice", age: 30 });
// Verite/faussete
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Nombres
expect(price).toBeGreaterThan(0);
expect(price).toBeLessThanOrEqual(100);
expect(0.1 + 0.2).toBeCloseTo(0.3); // flottants
// Strings
expect(message).toContain("erreur");
expect(email).toMatch(/^[^@]+@[^@]+\.[^@]+$/);
// Arrays
expect(items).toHaveLength(3);
expect(tags).toContain("typescript");
// Exceptions
expect(() => divide(1, 0)).toThrow();
expect(() => validate(null)).toThrow("required");
// Negation
expect(result).not.toBe(0);
expect(list).not.toContain("admin");
Mon conseil : utilise toBe pour les primitives et toEqual pour les objets. La confusion entre les deux est la source de bug numero un dans les tests.
Setup et teardown
Quand plusieurs tests partagent un setup, utilise beforeEach :
typescriptimport { describe, it, expect, beforeEach } from "bun:test";
describe("ShoppingCart", () => {
let cart: ShoppingCart;
beforeEach(() => {
cart = new ShoppingCart();
cart.add({ product: "Widget", price: 10, qty: 1 });
});
it("has one item after adding", () => {
expect(cart.items).toHaveLength(1);
});
it("calculates total", () => {
expect(cart.total()).toBe(10);
});
it("removes item", () => {
cart.remove("Widget");
expect(cart.items).toHaveLength(0);
});
});
Les hooks disponibles :
beforeEach: avant chaque test (le plus utilise)afterEach: apres chaque test (nettoyage)beforeAll: une fois avant tous les tests dudescribeafterAll: une fois apres tous les tests dudescribe
Regle d'or : beforeEach pour repartir d'un état propre. beforeAll pour les trucs coûteux qu'on ne veut faire qu'une fois (connexion DB par exemple).
Tests paramètres
Quand tu veux tester la meme logique avec plusieurs jeux de donnees :
typescriptimport { describe, it, expect } from "bun:test";
import { applyDiscount } from "./order";
describe("applyDiscount", () => {
const cases = [
{ total: 100, code: "WELCOME10", expected: 90 },
{ total: 100, code: "SUMMER20", expected: 80 },
{ total: 200, code: "VIP50", expected: 100 },
];
for (const { total, code, expected } of cases) {
it(`applies ${code} on ${total} => ${expected}`, () => {
expect(applyDiscount(total, code)).toBe(expected);
});
}
});
C'est plus concis qu'écrire trois it quasi identiques. Et quand tu ajoutes un nouveau code promo, tu ajoutes juste une ligne dans le tableau.
Couverture de code
bashbun test --coverage
Ca affiche un rapport directement dans le terminal : pourcentage de lignes, branches et fonctions couvertes par fichier.
Faut-il viser 100% ? Non. 100% de couverture ne veut pas dire zero bug. Ca veut dire que chaque ligne a ete exécutée au moins une fois, pas que chaque cas limite a ete teste.
Mon objectif : 80% sur la logique métier, et je m'en fiche de la couverture sur le boilerplate (configs, entrypoints, types). Couvre bien le code qui peut casser, ignore le code trivial.
Astuce : le pattern Arrange-Act-Assert
Chaque test suit le meme schema :
typescriptit("applies discount correctly", () => {
// Arrange : preparer les donnees
const total = 100;
const code = "WELCOME10";
// Act : executer l'action
const result = applyDiscount(total, code);
// Assert : verifier le resultat
expect(result).toBe(90);
});
Quand un test est long ou confus, vérifié qu'il respecte ce pattern. Si tu as du mal a identifier les trois parties, le test fait probablement trop de choses.
Article précédent : 01 - La pyramide des tests Article suivant : 03 - Mocks, stubs, fakes et spies