Tests unitaire: comment les faires correctement et ce qu'il faut éviter

Parjuliendu11
Publié le6 avril 2026
Mis à jour le6 avril 2026
📖10 min de lecture
Image d'illustration; source: pexels
tests unitaires
developpement
clean code
msw
mock

Les tests unitaires sont essentiels lors du développement logiciel, avoir une batterie de tests unitaires automatisé permet de s'assuer de bon fonctionnement de l'application et d'évier les régressions. Cependant il existe de mauvaises pratiques qui nuisent clairement à leurs utilités, laissant passer des bugs et ralentissant le développement

Tests unitaires : bonnes pratiques, pièges à éviter et stratégies efficaces

Les tests unitaires sont indispensables lors du développement logiciel. Ils permettent de garantir le bon fonctionnement de votre application, d'éviter les régressions et d'apporter une réelle confiance dans le code que vous déployez en production.

Cependant, leur mise en place comporte de nombreux pièges. Mal utilisés, ils peuvent ralentir le développement, augmenter la charge mentale et, paradoxalement, réduire la confiance dans votre code.

Les bonnes pratiques des tests unitaires

  • Ajouter une abstration pour certains modules, notamment tous ce qui n'est pas de notre contrôle/logique
  • Éviter de tout mock
  • Ne pas mock fetch mais préféré utiliser des outils comme `msw` pour intercepter les requêtes HTTP
  • Pour les autres services préférer les stubs

Les pièges des tests unitaires

Lorsque l'on entend "tests unitaires", beaucoup imaginent qu'il faut tester chaque morceau de code de manière totalement isolée. Cette approche mène souvent à :

  • Une sur-ingénierie du code
  • Un abus de mocks
  • De nombreux tests avec peu de valeur
  • Une fragilité accrue des tests

Le problème du "tout mock"

J'ai moi-même longtemps utilisé des mocks partout... avant de réaliser que mes tests apportaient très peu de valeur.

Les conséquences :

  • Des tests fragiles face aux changements
  • Une fausse confiance dans le code
  • Des tests qui ne vérifient pas réellement le comportement

Certes, les tests sont rapides, mais cela ne suffit pas.

👉 Mieux vaut moins de tests, mais fiables, que des centaines de tests inutiles.

Exemple problématique

const fetch = jest.spyOn(window, 'fetch')

describe('Login.vue', () => {
    it("Should login user", () => {
        fetch.mockResolvedValueOnce({
            user: {email:"test@test.com"},
            token: "<TOKEN>"
        })

        const {store} = render(LoginView)

        expect(store.state.auth.user).toEqual({email:"test@test.com"})
    })
})

On peut déjà se poser une question essentielle : quelle est la valeur réelle de mon test ? Que m’apporte-t-il actuellement avec le mock de fetch ?

Il permet de vérifier que le formulaire est correctement rempli lorsque l’utilisateur effectue une action, qu’il est bien soumis lors du clic sur le bouton de validation, et que les données de l’utilisateur ainsi que le token sont correctement enregistrés dans le store à la réception de la réponse.

C’est déjà un bon point, mais il est possible d’aller plus loin afin d’apporter davantage de valeur à ce test.

Solution

Ajouter une couche d'abstraction

Créer un wrapper autour de fetch permet :

  • Une meilleure lisibilité
  • Un point central de gestion
  • Un découplage fort
// Interface
abstract class IClientAPI {
  post<I, O>(path: string, body: I): Promise<O>;
  get<O>(path: string): Promise<O>;
}

// Implémentation : client de base pour l'app
class ClientAPI implements IClientAPI {
  private hostname = "";
  private abortControllers = []

  private callAPI<I>(
    url: string,
    method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT",
    body?: I
  ) {
    try {
      // Exemple avant le fetch: log pour déboggage et utilisation du système AbortController pour chaque requête au même endroits
      writeLogMessage(`[FETCH][${method}] ${url}`, "http");

      const controller = new AbortController();

      this.abortControllers.push(controller);

      const signal = controller.signal;

      signal.onabort = () => {
        writeLogMessage(`[FETCH][${method}] ${url} - Aborted`, "http");
      };

      const token = localStorage.getItem(AUTH_TOKEN_KEY_STORAGE);

      const result = await fetch(url, {
        body: body ? JSON.stringify(body) : undefined,
        headers: {
          Authorization: token ? `Bearer ${token}` : "",
          "Content-Type": "application/json"
        },
        method,
        signal,
      });
      const data = result.status !== 204 ? await result.json() : {};
      if (!result.ok) {
        // Gestion de la réponse négative
      }

      return data;
    } catch (error: any) {
     // Gestion des erreurs
    }
  }

  get(path: string) {
    let url = `${this.hostname}${path}`;
    return await this.callAPI(url, "GET");
  }
}

Puis lors de vos tests vous pouvez créer un stub de ce service qui respect le contrat IClientAPI mais qui envoie une réponse contrôlé

class ClientAPIStub implements IClientAPI {
  post<I, O>(path: string, body: I){
    // Simulation de 1 seconde d'attentes
    await new Promise((resolve) => setTimeout(resolve, 1000))

    return {
      user: {email:"test@test.com"},
      token: "<TOKEN>"
    }
  }
}

describe('Unit tests for pages/Login.vue', () => {
    it("Should login user in store and redirect to home page", () => {
        const login = {
            username: "test01",
            password: "123456"
        }

        const {store} = render(LoginView, {
          [clientAPISymbol]: new ClientAPIStub()
        })

        // Remplit le formulaire et soumet
      
        expect(store.state.auth.user).toEqual({email:"test@test.com"})
        expect(store.state.auth.token).toEqual("<TOKEN>")
    })
})

👉 Cela permet ensuite d'injecter un stub ou une vraie implémentation.

Avantages:

  • Possibilité d’ajouter de la logique avant ou après l’appel à l’API à un seul endroit. En cas de changement, il suffira de modifier ce point central plutôt que tous les fichiers utilisant fetch.
  • Meilleure lisibilité: au lieu de répéter un fetch() avec ses différentes options, vous pouvez abstraire cette logique et la simplifier via un appel comme .get('/users').
  • Découplage facilité: en ajoutant une couche d’abstraction et en utilisant une interface, vous pouvez remplacer (“swap”) le service plus facilement.

À vrai dire, cela ne change pas grand-chose pour les tests. Vous appliquez surtout une règle de code propre: éviter de mocker directement fetch. Cela permet également de ne pas gérer manuellement le mock et son état, souvent source d’erreurs par exemple lorsque window.fetch n’est pas réinitialisé correctement, ce qui peut impacter les autres tests.

Il existe toutefois une étape supplémentaire à associer à celle-ci pour aller plus loin.

Tester avec un vrai serveur HTTP (MSW)

Même lorsque l’on utilise fetch avec une couche d’abstraction et des stubs, on reste encore loin d’un test fidèle et précis. En effet, on ne couvre pas l’ensemble du processus réel d’une requête HTTP.

Or, des imprévus peuvent survenir lors de la transmission de la requête. C’est pourquoi il est pertinent de compléter avec des tests à ce niveau, afin de se rapprocher davantage des conditions réelles d’exécution.

Au lieu de mocker fetch, utilisez MSW (Mock Service Worker) :

Cette approche permet de disposer d’un véritable serveur HTTP capable de capturer les requêtes envoyées vers une URL donnée. Vous pouvez alors contrôler les réponses retournées, y compris simuler des erreurs HTTP afin de tester le comportement de votre code dans ces situations.

Cela vous permet d’élargir votre spectre de tests, de couvrir un spectre plus large de cas, et ainsi de renforcer votre niveau de confiance dans votre application.

MSW peut également être utilisé en phase de développement via un service worker. Cela s’avère particulièrement utile lorsque votre API n’est pas encore disponible, ou même pour certains cas de test.

Dans cet exemple, nous allons toutefois nous concentrer sur son utilisation dans le cadre des tests.

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


describe('Unit tests for pages/Login.vue', () => {
    it("Should login user in store and redirect to home page", () => {
        const handlers = [
           http.get(`http://localhost:3333/auth/login`, () =>
              HttpResponse.json({
                 user: {email:"test@test.com"},
                  token: "<TOKEN>"
              })
          )
        ]

        setupServer(...handlers)
      
        const login = {
            username: "test01",
            password: "123456"
        }

        const {store} = render(LoginView, {
          [clientAPISymbol]: new ClientAPIStub()
          [clientAPISymbol]: new ClientAPI() // On peut utiliser le vrai service ici
        })

        // Remplit le formulaire et soumet
      
        expect(store.state.auth.user).toEqual({email:"test@test.com"})
        expect(store.state.auth.token).toEqual("<TOKEN>")
    })

    it("Should show error message if API return 401 code", () => {
      const handlers = [
        http.get(
          `http://localhost:3333/auth/login`,
          () => new HttpResponse.json(null, { status: 401 })
        ),
      ];

      setupServer(...handlers)
    });
})

Avantages:

  • Simulation réaliste des requêtes HTTP
  • Gestion des erreurs (401, 500...)
  • Tests beaucoup plus fiables

Tests de base de données: 2 approches

1. Stub / In-memory avec inversion de dépendance

Vous souhaitez tester votre CRUD sans avoir à lancer une instance de base de données, car cela est trop contraignant ? Dans ce cas, vous pouvez utiliser des stubs.

Nous allons nous appuyer sur une bonne pratique issue du clean code, notamment au cœur de l’architecture hexagonale (pour ne citer qu’elle). Il s’agit toutefois d’un principe universel, qui devrait être appliqué dès que cela est pertinent.

Concrètement cette approche est particulièrement utile pour les tests: elle permet de remplacer l’implémentation réelle de l’accès à la base de données par une version in-memory, où toutes les données sont stockées en mémoire (RAM).

Principe clé:

Dépendre d'abstractions plutôt que d'implémentations

Avantages:

  • Rapide
  • Simple
  • Utile pour isoler la logique

Cela permet:

  • De remplacer facilement une implémentation
  • De simplifier les tests
  • De rendre le code maintenable

⚠️ Note : Cette approche peut s’appliquer à absolument tous vos services, et pas uniquement à la base de données.

Exemple:

// interfaces/API/UserRepository.ts
export interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

// impl/API/MariaDbUserRepository.ts
import { createPool, Pool } from "mariadb";
import { UserRepository, User } from "./UserRepository";

export class MariaDbUserRepository implements UserRepository {
  private pool: Pool;

  constructor() {
    this.pool = createPool({
      host: "localhost",
      user: "root",
      password: "password",
      database: "app_db",
    });
  }

  async save(user: User): Promise<void> {
    const conn = await this.pool.getConnection();
    try {
      await conn.query(
        "INSERT INTO users (id, name) VALUES (?, ?)",
        [user.id, user.name]
      );
    } finally {
      conn.release();
    }
  }

  async findById(id: string): Promise<User | null> {}
}

// impl/API/InMemoryUserRepository.ts
import { UserRepository, User } from "./UserRepository";

export class InMemoryUserRepository implements UserRepository {
  private users = new Map<string, User>();

  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }
}

// services/API/UserService.ts
import { UserRepository, User } from "./UserRepository";

export class UserService {
  constructor(private userRepository: UserRepository) {}

  async createUser(user: User): Promise<void> {
    // logique métier possible ici
    await this.userRepository.save(user);
  }

  async getUser(id: string): Promise<User | null> {
    return this.userRepository.findById(id);
  }
}

// main.ts

const repo = new MariaDbUserRepository();
/**
 * Ou InMemoryUserRepository, à utiliser dans les tests, les 2 implémentations ont la même interface, 
 * ils ont le même contrat qui est accepté en argument par new UserService()
 */
// const repo = new InMemoryUserRepository();

const service = new UserService(repo);

await service.createUser({ id: "1", name: "Alice" });

2. Vraie base de données (recommandé)

Avantages:

  • Plus réaliste
  • Meilleure confiance

👉 Exemple: l’utilisation de transactions globales avec un système de tests adapté, couplé à un conteneur Docker pour la base de données. Le framework AdonisJS permet de mettre cela en place facilement.


Conclusion

Les tests unitaires ne doivent pas être une contrainte, mais un levier de confiance et de qualité.

Les tests unitaires ne doivent pas être une contrainte, mais un levierde confiance et de qualité.

Pour cela, il vous faut réfléchir à la manière de tester vos différents composants: identifiez ce qui apporte le plus de valeur et ce qui sécurise le mieux votre application. Chaque fois que vous faites un changement, pensez à la façon dont vous allez le tester. Organisez-vous de manière à privilégier la testabilité dès le départ.

Utilisez l’inversion de dépendance lorsque cela est pertinent, notamment pour tout ce qui concerne les entrées/sorties (I/O), les bases de données et les appels à des services tiers.

Enfin, encapsulez certaines de vos dépendances métiers lorsque c’est nécessaire, que ce soit pour les simuler (stubs) ou simplement pour disposer d’un comportement généralisé et centralisé.

Ces 2 règles de clean code vous apporteront lisibilité et stabilité.

À retenir :

  • ❌ Évitez le "tout mock"
  • ✅ Testez le comportement réel
  • ✅ Utilisez MSW pour HTTP
  • ✅ Favorisez des tests utiles plutôt que nombreux

Ressources utiles

juliendu11

Auteur

Article publié le 6 avril 2026 • Dernière mise à jour le 6 avril 2026