Skip to main content
backend2 novembre 202516 min de lecture

Tests unitaires en 2026 : plus aucune excuse

L'outillage a rattrapé son retard. L'IA écrit les scaffolds de tests, les frameworks sont rapides, et il n'y a plus aucune bonne raison de livrer du code non testé.

testingvitestjest
Tests unitaires en 2026 : plus aucune excuse

Il y a trois ans, les excuses pour ne pas écrire de tests étaient au moins plausibles. Les frameworks de test étaient lents. Configurer les mocks était fastidieux. Ecrire les tests prenait plus de temps qu'écrire le code. Le retour sur investissement n'était pas évident pour une startup de 3 personnes sprintant vers le product-market fit. Je n'étais pas d'accord avec ces excuses, mais je les comprenais.

En 2026, ces excuses ont disparu. Vitest exécute une suite de tests complète dans le temps qu'il fallait à Jest pour démarrer. Les outils d'IA génèrent des fichiers de test complets à partir d'une signature de fonction. TypeScript attrape des catégories entières de bugs à la compilation, réduisant ce qui a réellement besoin d'être testé. Docker Compose lance de vraies bases de données pour les tests d'intégration en quelques secondes. L'écart entre "pas de tests" et "bien testé" n'a jamais été aussi petit.

Le paysage du testing moderne

L'écosystème de testing s'est consolidé autour de quelques excellents outils, et la fragmentation qui transformait autrefois la configuration des tests en projet de recherche a largement disparu.

Vitest vs Jest : la décision est prise

Pour tout nouveau projet, Vitest est le choix par défaut. Non pas parce que Jest est mauvais — Jest est un framework solide et éprouvé — mais parce que Vitest est mesurément meilleur dans chaque dimension qui compte pour le développement moderne.

Vitesse : Vitest utilise esbuild pour la transformation et exécute les tests dans des worker threads avec un support natif ESM. Dans mes projets, la même suite de tests s'exécute 3 à 5 fois plus vite sous Vitest que sous Jest. Une suite qui prenait 45 secondes sous Jest se termine en 12 secondes sous Vitest.

Configuration : Vitest réutilise votre config Vite. Si vous utilisez déjà Vite pour votre build (et en 2026, c'est le cas de la plupart des projets), il n'y a aucune configuration supplémentaire. Comparez avec les moduleNameMapper, transform, transformIgnorePatterns de Jest, et l'inévitable débogage de pourquoi tel package ESM ne fonctionne pas.

Compatibilité : Vitest implémente une API compatible Jest. describe, it, expect, beforeEach, afterEach, vi.fn(), vi.mock() — tout fonctionne comme prévu. Migrer de Jest à Vitest est essentiellement un chercher-remplacer de jest.fn() en vi.fn().

// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
    },
  },
});

Quand rester sur Jest : Si vous avez une grande suite de tests Jest existante (500+ tests) et aucun problème immédiat, la migration n'est pas urgente. Jest continue de recevoir des mises à jour et fonctionne très bien. Mais pour les nouveaux projets ou les projets avec moins de 100 tests, Vitest est le choix évident.

La pyramide des tests en pratique

La pyramide des tests traditionnelle (beaucoup de tests unitaires, moins de tests d'intégration, peu de tests E2E) reste correcte dans le principe, mais les frontières ont bougé.

Les tests unitaires vérifient les fonctions individuelles, les méthodes utilitaires et la logique métier pure. Ils doivent être rapides (moins de 10ms chacun), n'avoir aucune dépendance externe et tester le comportement plutôt que l'implémentation.

Les tests d'intégration vérifient que les composants fonctionnent ensemble — routes API avec requêtes base de données, méthodes de service avec appels d'API externes, composants React avec leur gestion d'état. Ils sont plus lents (100-500ms chacun) mais attrapent des bugs que les tests unitaires manquent.

Les tests E2E vérifient des workflows utilisateur complets à travers l'application réelle. Ce sont les plus lents (5-30 secondes chacun) et les plus fragiles, mais ils attrapent des bugs que rien d'autre ne détecte. Des outils comme Playwright ont rendu les tests E2E significativement plus fiables que l'ère Selenium.

Le ratio que je vise : 70 % unitaires, 20 % intégration, 10 % E2E. Les chiffres exacts comptent moins que d'avoir une représentation à chaque niveau.

Quoi tester et quoi ignorer

Tout le code n'a pas besoin de la même rigueur de test. Savoir où investir l'effort de test est aussi important que savoir comment écrire les tests.

Toujours tester

La logique métier et les règles du domaine. Si le code implémente une règle métier — calcul de prix, vérification de permission, validation de données, transition de machine à états — il a besoin de tests. Ce sont les règles qui, si elles sont fausses, coûtent de l'argent ou créent des vulnérabilités de sécurité.

// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  if (!discount) return subtotal;

  if (discount.type === 'percentage') {
    return subtotal * (1 - discount.value / 100);
  }

  if (discount.type === 'fixed') {
    return Math.max(0, subtotal - discount.value);
  }

  return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';

describe('calculateOrderTotal', () => {
  const items: OrderItem[] = [
    { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
    { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
  ];

  it('calculates subtotal without discount', () => {
    expect(calculateOrderTotal(items)).toBe(30.97);
  });

  it('applies percentage discount', () => {
    const discount = { type: 'percentage' as const, value: 10 };
    expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
  });

  it('applies fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 5 };
    expect(calculateOrderTotal(items, discount)).toBe(25.97);
  });

  it('does not go below zero with fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 50 };
    expect(calculateOrderTotal(items, discount)).toBe(0);
  });

  it('handles empty items array', () => {
    expect(calculateOrderTotal([])).toBe(0);
  });
});

Les fonctions de transformation de données. Toute fonction qui transforme des données d'une forme à une autre — mappers de réponses API, serializers de formulaires, parsers CSV — a besoin de tests avec des entrées représentatives et des cas limites.

Les chemins de gestion d'erreurs. Que se passe-t-il quand l'API renvoie une 500 ? Quand l'entrée est null ? Quand le fichier n'existe pas ? Les chemins d'erreur sont là où les bugs se cachent parce que les développeurs testent le happy path manuellement et supposent que le chemin d'erreur fonctionne.

Les fonctions utilitaires. Formateurs de chaînes, helpers de date, utilitaires de tableau, fonctions de validation. Ils sont utilisés partout, et un bug dans une fonction utilitaire se multiplie à travers le codebase.

Ignorer ou tester légèrement

Les wrappers directs de framework. Si votre fonction est un wrapper fin autour d'une méthode de framework bien testée, la tester teste le framework, pas votre code. Un composant React qui rend <h1>{title}</h1> n'a pas besoin d'un test vérifiant que le h1 s'affiche.

Les fichiers de configuration. Configuration statique, constantes, définitions de types — ceux-ci n'ont pas besoin de tests. TypeScript les valide déjà à la compilation.

Le code de liaison avec les bibliothèques tierces. Du code qui appelle stripe.charges.create() avec des paramètres de votre modèle de données n'a pas besoin d'un test unitaire qui mocke Stripe et vérifie que vous l'avez appelé. Il a besoin d'un test d'intégration qui vérifie le flux de facturation de bout en bout.

Stratégies de mocking

Le mocking est l'endroit où les tests passent de "vérifier le comportement" à "tester les détails d'implémentation." L'objectif est de mocker le moins possible tout en gardant les tests rapides et déterministes.

L'approche par injection de dépendances

Au lieu de mocker des modules, passez les dépendances en paramètres. Cela rend le test naturel et évite la magie de vi.mock() qui couple les tests à la structure des modules.

// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
  return {
    async createUser(data: CreateUserInput): Promise<User> {
      const user = await db.users.create({ data });
      await emailClient.send({
        to: user.email,
        template: 'welcome',
        data: { name: user.name },
      });
      return user;
    },
  };
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';

describe('createUserService', () => {
  it('creates user and sends welcome email', async () => {
    const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
    const db = {
      users: { create: vi.fn().mockResolvedValue(mockUser) },
    };
    const emailClient = {
      send: vi.fn().mockResolvedValue(undefined),
    };

    const service = createUserService(db as any, emailClient as any);
    const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });

    expect(result).toEqual(mockUser);
    expect(emailClient.send).toHaveBeenCalledWith({
      to: 'jane@example.com',
      template: 'welcome',
      data: { name: 'Jane' },
    });
  });
});

Pas d'appels vi.mock(), pas de chaînes de chemin de module, pas de setup dépendant de l'ordre. Le test est explicite sur ce qui est réel et ce qui est faux.

Quand utiliser vi.mock()

Le mocking au niveau module est approprié quand vous ne pouvez pas contrôler l'injection de dépendances — typiquement quand vous testez des composants React qui importent des modules directement ou quand vous testez du code qui utilise des globales spécifiques à l'environnement.

// When you genuinely need module mocking
vi.mock('./api-client', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));

MSW pour le mocking d'API

Pour les tests qui font des requêtes HTTP, Mock Service Worker (MSW) intercepte les requêtes réseau au niveau du service worker. C'est supérieur au mocking de fetch ou axios car cela teste votre vrai code de client HTTP.

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('https://api.example.com/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
      email: 'test@example.com',
    });
  }),

  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 'order-1', ...body, status: 'created' },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Génération de tests assistée par IA

Les outils d'IA ont véritablement changé l'économie de l'écriture de tests. Générer le scaffold initial — le boilerplate, les cas happy path basiques, les cas limites standards — est exactement le genre de travail répétitif et basé sur des patterns que les LLM gèrent bien.

Ce que l'IA fait bien

  • Générer des fichiers de test à partir de signatures de fonctions et de définitions de types
  • Suggérer des cas limites auxquels vous n'auriez pas pensé (tableaux vides, valeurs null, nombres aux limites)
  • Ecrire le boilerplate répétitif de setup/teardown
  • Créer des objets mock qui correspondent aux shapes d'interface
  • Générer des cas de test paramétrés pour les fonctions avec beaucoup de combinaisons d'entrées

Ce que l'IA fait mal

  • Comprendre le contexte métier ("ceci devrait échouer car les utilisateurs ne peuvent pas commander après minuit" est de la connaissance domaine)
  • Tester les interactions d'état complexes entre plusieurs appels de fonction
  • Ecrire des tests d'intégration significatifs qui exercent les vraies frontières du système
  • Décider quoi tester et quoi ne pas tester
  • Ecrire des tests qui vérifient le comportement plutôt que l'implémentation

Le workflow pratique

Mon workflow avec les tests assistés par IA :

  1. Ecrire la fonction ou le module.
  2. Demander à l'IA de générer un fichier de test avec les cas basiques.
  3. Revoir et corriger les tests générés — les tests générés par IA testent souvent les détails d'implémentation plutôt que le comportement.
  4. Ajouter les cas spécifiques au domaine que l'IA a manqués.
  5. Exécuter les tests et vérifier qu'ils attrapent réellement des bugs en cassant temporairement le code.

L'IA fait l'étape 2 (qui était la partie fastidieuse) et je me concentre sur les étapes 3-5 (qui nécessitent du jugement). Cela réduit le temps d'écriture des tests d'environ 50-60 % pour le code standard d'utilitaires et de services.

Tester les composants React

Le testing de composants a considérablement évolué. Le passage du shallow rendering d'Enzyme à la philosophie de test centrée sur l'utilisateur de React Testing Library a changé ma façon de penser les tests de composants.

Quoi tester dans les composants

  • Le composant s'affiche-t-il correctement avec différentes props ?
  • Les interactions utilisateur (clic, saisie, sélection) déclenchent-elles le comportement attendu ?
  • Le composant gère-t-il les états de chargement, d'erreur et vide ?
  • Le rendu conditionnel fonctionne-t-il correctement ?

Quoi ne pas tester dans les composants

  • Les valeurs d'état internes (testez ce que l'utilisateur voit, pas ce que React suit)
  • Les détails d'implémentation (quelle fonction a été appelée, combien de re-rendus se sont produits)
  • Le style (utilisez les tests de régression visuelle pour cela)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';

describe('OrderSummary', () => {
  const defaultProps = {
    items: [
      { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
      { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
    ],
    onCheckout: vi.fn(),
  };

  it('displays order items with prices', () => {
    render(<OrderSummary {...defaultProps} />);

    expect(screen.getByText('Burger')).toBeInTheDocument();
    expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
    expect(screen.getByText('Fries')).toBeInTheDocument();
    expect(screen.getByText('$4.99')).toBeInTheDocument();
  });

  it('displays total', () => {
    render(<OrderSummary {...defaultProps} />);
    expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
  });

  it('calls onCheckout when checkout button is clicked', async () => {
    const user = userEvent.setup();
    render(<OrderSummary {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /checkout/i }));
    expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
  });

  it('shows empty state when no items', () => {
    render(<OrderSummary {...defaultProps} items={[]} />);
    expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
  });

  it('disables checkout button during loading', () => {
    render(<OrderSummary {...defaultProps} isLoading={true} />);
    expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
  });
});

A noter : pas de getByTestId sauf si nécessaire, pas de vérification d'état interne, pas de vérification de classes CSS. Les tests se lisent comme une description de ce qu'un utilisateur voit et fait.

Tester les routes API

Les tests de routes API sont des tests d'intégration qui vérifient que votre handler HTTP fonctionne correctement avec ses middleware, sa validation et son formatage de réponse.

// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';

describe('POST /api/orders', () => {
  let authToken: string;

  beforeAll(async () => {
    await db.migrate.latest();
    await db.seed.run();
    // Get auth token for test user
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'testpassword' });
    authToken = loginResponse.body.token;
  });

  afterAll(async () => {
    await db.destroy();
  });

  it('creates an order with valid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'prod-1', quantity: 2 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'pending',
      items: expect.arrayContaining([
        expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
      ]),
    });
  });

  it('returns 401 without auth token', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: 'prod-1', quantity: 2 }] });

    expect(response.status).toBe(401);
  });

  it('returns 400 with invalid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [] });

    expect(response.status).toBe(400);
    expect(response.body.errors).toBeDefined();
  });

  it('returns 400 when product does not exist', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'nonexistent', quantity: 1 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(400);
    expect(response.body.message).toContain('not found');
  });
});

Tester les opérations base de données

Tester les opérations base de données nécessite une vraie base de données. Mocker les requêtes SQL teste le mock, pas la requête. Utilisez une base de données de test qui est recréée pour chaque exécution de tests.

// database.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient({
  datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});

beforeEach(async () => {
  // Clean tables in dependency order
  await prisma.orderItem.deleteMany();
  await prisma.order.deleteMany();
  await prisma.user.deleteMany();
});

describe('Order repository', () => {
  it('creates order with items and calculates total', async () => {
    const user = await prisma.user.create({
      data: { email: 'test@example.com', name: 'Test User' },
    });

    const order = await prisma.order.create({
      data: {
        userId: user.id,
        items: {
          create: [
            { productName: 'Burger', price: 12.99, quantity: 2 },
            { productName: 'Fries', price: 4.99, quantity: 1 },
          ],
        },
        total: 30.97,
      },
      include: { items: true },
    });

    expect(order.items).toHaveLength(2);
    expect(order.total).toBe(30.97);
    expect(order.userId).toBe(user.id);
  });

  it('enforces unique email constraint', async () => {
    await prisma.user.create({
      data: { email: 'duplicate@example.com', name: 'User 1' },
    });

    await expect(
      prisma.user.create({
        data: { email: 'duplicate@example.com', name: 'User 2' },
      })
    ).rejects.toThrow();
  });
});

Configuration de la base de test en CI

# In GitHub Actions
services:
  postgres:
    image: postgres:16
    env:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: test_db
    ports:
      - 5432:5432

Avec Prisma, exécutez npx prisma migrate deploy avant les tests pour appliquer le schéma. Avec du SQL brut, exécutez les scripts de migration. La base de test démarre vierge pour chaque exécution CI.

Les métriques de couverture qui comptent

La couverture de code est un signal utile, mais c'est une terrible cible.

Le piège de la couverture

Optimiser pour 100 % de couverture mène à des tests qui existent pour couvrir des lignes non couvertes plutôt que pour vérifier un comportement. J'ai vu des tests comme celui-ci :

// This test exists purely for coverage
it('should have a default export', () => {
  expect(module).toBeDefined();
});

Ce test ne vérifie rien de significatif. Il augmente le chiffre de couverture tout en ajoutant zéro confiance.

Des cibles de couverture significatives

Au lieu d'un seuil de couverture global, j'utilise des cibles différentes pour différentes catégories de code :

Catégorie Cible Justification
Logique métier / Domaine 90%+ Les bugs ici coûtent de l'argent
Routes API / Contrôleurs 80%+ Frontières d'intégration
Fonctions utilitaires 95%+ Largement utilisées, fort impact
Composants UI 70%+ Bugs visuels attrapés par les E2E
Configuration / Setup Pas de cible Statique, casse rarement

Configurez les seuils de couverture par répertoire :

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        'src/domain/**': { statements: 90, branches: 85 },
        'src/api/**': { statements: 80, branches: 75 },
        'src/utils/**': { statements: 95, branches: 90 },
      },
    },
  },
});

L'alternative du test de mutation

Si les chiffres de couverture semblent creux, le test de mutation fournit une évaluation plus honnête. Des outils comme Stryker introduisent de petits changements (mutations) dans votre code et vérifient que vos tests les attrapent. Une mutation qui survit (les tests passent toujours) indique un trou dans votre suite de tests.

npx stryker run

Le test de mutation est lent (il exécute votre suite de tests entière pour chaque mutation) mais révélateur. Je l'exécute mensuellement plutôt qu'à chaque commit.

Le développement piloté par les tests en pratique

Je pratique le TDD de manière sélective, pas dogmatique. Il fonctionne exceptionnellement bien pour certains types de code et ajoute de la friction pour d'autres.

Où le TDD brille

Les fonctions pures avec des spécifications claires. Si vous connaissez les entrées et les sorties attendues avant d'écrire le code, écrire le test en premier est naturel et productif.

Les corrections de bugs. Avant de corriger un bug, écrivez un test qui le reproduit. Puis corrigez le code jusqu'à ce que le test passe. Cela garantit que le bug reste corrigé.

Le refactoring. Ecrivez des tests pour le comportement existant avant de refactorer. Les tests servent de filet de sécurité, vérifiant que le code refactoré produit des résultats identiques.

Où le TDD ajoute de la friction

Le code exploratoire. Quand vous cherchez comment quelque chose devrait fonctionner — expérimenter avec une API, prototyper une UI, explorer une structure de données — écrire les tests d'abord vous ralentit. Ecrivez le code, stabilisez l'interface, puis ajoutez les tests.

Les composants UI. Les tests Testing Library pour les composants React sont mieux écrits après que le composant existe, car la sortie rendue du composant (sur laquelle les tests font des assertions) n'est pas connue tant que vous ne l'avez pas construit.

Le cycle Red-Green-Refactor

Quand je fais du TDD, je suis le cycle classique strictement :

  1. Red : Ecrire un test qui échoue (car le code n'existe pas encore).
  2. Green : Ecrire le code minimum pour faire passer le test. Ne pas optimiser.
  3. Refactor : Nettoyer le code en gardant tous les tests verts.

La discipline de l'étape 2 — écrire le code minimum — est la plus importante et la plus fréquemment violée. La tentation d'écrire "la vraie implémentation" immédiatement annule le but du TDD, qui est de laisser les tests guider le design de manière incrémentale.

L'investissement produit des intérêts composés

Chaque test que vous écrivez a une durée de vie mesurée en années. Un test écrit aujourd'hui attrapera des régressions dans le refactoring de la semaine prochaine, l'ajout de fonctionnalité du mois prochain et la mise à jour de framework de l'année prochaine. Les 10 minutes que vous passez à écrire un test économisent 2 heures de débogage six mois plus tard, mais vous ne voyez jamais cette économie explicitement — vous n'avez simplement jamais le bug.

La partie la plus difficile du testing n'est pas l'outillage ni les techniques. C'est la discipline d'écrire le test maintenant, quand le code est frais dans votre esprit, plutôt que de l'ajouter au backlog où il meurt silencieusement. L'excuse de l'outillage a disparu. L'excuse de la vitesse a disparu. La seule excuse qui reste est la priorisation, et livrer du code testé ne devrait pas être négociable.

DU

Danil Ulmashev

Full Stack Developer

Intéressé par une collaboration ?