Tests fondamentaux - 02 - Tests unitaires avec bun test

Écrire des tests unitaires avec le test runner natif de Bun. Setup, assertions, describe/it, beforeEach et couverture.

02 - Tests unitaires avec bun test

Ce que tu vas apprendre

  • Lancer des tests avec bun test sans aucune config
  • Structurer tes tests avec describe, it, beforeEach
  • Maitriser les assertions : toBe, toEqual, toThrow et 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 preferent test(), 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 du describe
  • afterAll : une fois apres tous les tests du describe

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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.