Maîtriser TypeScript : Développez des Applications Web Robustes et Scalables
Maîtriser TypeScript : Développez des Applications Web Robustes et Scalables

Tests Unitaires et Intégration avec TypeScript

Bienvenue dans cette leçon du cours "Maîtriser TypeScript : Développez des Applications Web Robustes et Scalables". Aujourd'hui, nous allons plonger dans un aspect crucial du développement logiciel de qualité : les tests. Plus spécifiquement, nous explorerons les tests unitaires et les tests d'intégration, en tirant parti de la puissance de TypeScript pour construire des applications encore plus fiables.

Introduction : L'Importance des Tests dans le Développement Logiciel

Dans le monde du développement logiciel, créer du code qui fonctionne est une chose, mais créer du code qui continue de fonctionner et qui est facile à modifier en est une autre. C'est là que les tests entrent en jeu. Les tests sont un filet de sécurité indispensable, qui vous permet de :

  • Valider la correction : S'assurer que votre code fait ce qu'il est censé faire.
  • Faciliter la refactorisation : Modifier votre code en toute confiance, sachant que les tests vous alerteront en cas de régression.
  • Documenter le comportement : Les tests peuvent servir de spécification exécutable pour votre code.
  • Réduire les bugs : Détecter les erreurs tôt dans le cycle de développement, où elles sont moins coûteuses à corriger.
  • Augmenter la confiance : Avoir l'assurance que votre application est stable et robuste.

Avec TypeScript, cette confiance est renforcée par la vérification statique des types, mais même le code le plus fortement typé peut contenir des erreurs logiques ou des interactions imprévues. C'est pourquoi les tests dynamiques (ceux que nous allons écrire) sont essentiels. Ils complètent la vérification des types en validant le comportement réel de votre application à l'exécution.

Nous allons nous concentrer sur deux types de tests fondamentaux : les tests unitaires et les tests d'intégration.

Les Tests Unitaires : Isoler pour Mieux Tester

Qu'est-ce qu'un Test Unitaire ?

Un test unitaire vise à vérifier le comportement d'une unité de code individuelle et isolée. Une unité peut être une fonction, une classe, un module ou un composant. L'objectif est de s'assurer que chaque petite pièce de votre application fonctionne correctement de manière autonome, sans dépendre d'autres parties du système ou d'éléments externes (base de données, API, système de fichiers, etc.).

Caractéristiques des Tests Unitaires

  • Isolation : Ils testent une unité à la fois. Toutes les dépendances externes sont généralement remplacées par des doubles de test (mocks, stubs, spies).
  • Rapidité : Étant isolés et ne nécessitant pas de ressources externes, ils s'exécutent très rapidement.
  • Déterminisme : Ils devraient toujours produire le même résultat, quelle que soit l'heure ou l'environnement d'exécution.
  • Granularité : Très détaillés, ils se concentrent sur des cas d'utilisation spécifiques et des chemins de code individuels.

Outils pour les Tests Unitaires avec TypeScript

Le framework de test le plus populaire et le plus adapté à TypeScript est Jest. Il est rapide, offre une excellente prise en charge de TypeScript (via ts-jest ou directement avec les dernières versions) et inclut des fonctionnalités comme la couverture de code, le mocking, et la parallélisation des tests.

Exemple Pratique : Test Unitaire avec Jest et TypeScript

Commençons par installer Jest et la bibliothèque ts-node (si vous n'utilisez pas ts-jest pour la compilation à la volée, ou pour des scripts simples).

npm install --save-dev jest @types/jest ts-node typescript
# Ou si vous préférez ts-jest pour une configuration plus intégrée
# npm install --save-dev jest @types/jest ts-jest typescript

Créez un fichier jest.config.js (si vous utilisez ts-jest) ou configurez Jest pour qu'il reconnaisse les fichiers .ts (avec les versions récentes de Jest, cela peut fonctionner directement en installant ts-node ou en configurant moduleFileExtensions).

Exemple de jest.config.js avec ts-jest:

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  // Options supplémentaires si nécessaire
};

Maintenant, écrivons une fonction simple et son test unitaire.

Fichier : src/math.ts

/**
 * Additionne deux nombres.
 * @param a Le premier nombre.
 * @param b Le deuxième nombre.
 * @returns La somme des deux nombres.
 */
export function add(a: number, b: number): number {
    return a + b;
}

/**
 * Soustrait deux nombres.
 * @param a Le nombre duquel soustraire.
 * @param b Le nombre à soustraire.
 * @returns La différence des deux nombres.
 */
export function subtract(a: number, b: number): number {
    return a - b;
}

/**
 * Multiplie deux nombres.
 * @param a Le premier nombre.
 * @param b Le deuxième nombre.
 * @returns Le produit des deux nombres.
 */
export function multiply(a: number, b: number): number {
    return a * b;
}

/**
 * Divise deux nombres.
 * @param a Le dividende.
 * @param b Le diviseur.
 * @throws Error si le diviseur est zéro.
 * @returns Le quotient des deux nombres.
 */
export function divide(a: number, b: number): number {
    if (b === 0) {
        throw new Error("Cannot divide by zero");
    }
    return a / b;
}

Ce fichier contient des fonctions mathématiques de base. Chaque fonction est une "unité" que nous pouvons tester.

Fichier : src/math.test.ts

import { add, subtract, multiply, divide } from './math';

// La fonction 'describe' regroupe un ensemble de tests logiquement liés.
describe('Math Operations', () => {

    // La fonction 'test' (ou 'it') définit un cas de test individuel.
    test('should correctly add two numbers', () => {
        // Arrange (Préparation): Définir les entrées.
        const num1 = 5;
        const num2 = 3;

        // Act (Action): Exécuter la fonction à tester.
        const result = add(num1, num2);

        // Assert (Assertion): Vérifier le résultat attendu.
        // 'expect' crée une assertion et '.toBe' est un "matcher" de Jest.
        expect(result).toBe(8);
    });

    test('should correctly subtract two numbers', () => {
        expect(subtract(10, 4)).toBe(6);
    });

    test('should correctly multiply two numbers', () => {
        expect(multiply(7, 2)).toBe(14);
        expect(multiply(0, 5)).toBe(0); // Tester un cas limite
    });

    test('should correctly divide two numbers', () => {
        expect(divide(10, 2)).toBe(5);
        expect(divide(7, 2)).toBe(3.5);
    });

    test('should throw an error when dividing by zero', () => {
        // Pour tester qu'une fonction lève une erreur, on doit l'envelopper dans une fonction.
        // 'toThrow' est le matcher pour les exceptions.
        expect(() => divide(10, 0)).toThrow("Cannot divide by zero");
    });
});

Pour exécuter ces tests, ajoutez un script dans votre package.json:

{
  "name": "mon-projet-ts",
  "version": "1.0.0",
  "scripts": {
    "test": "jest"
  },
  "devDependencies": {
    "jest": "^29.x.x",
    "@types/jest": "^29.x.x",
    "ts-jest": "^29.x.x",
    "typescript": "^5.x.x"
  }
}

Puis exécutez npm test dans votre terminal. Jest trouvera et exécutera les tests, affichant un rapport clair sur leur succès ou leur échec.

Ce test respecte la structure Arrange-Act-Assert (AAA) :

  1. Arrange (Préparation) : Mettre en place les données et l'environnement nécessaires.
  2. Act (Action) : Exécuter l'unité de code à tester.
  3. Assert (Assertion) : Vérifier que le résultat obtenu correspond à l'attendu.

Les Tests d'Intégration : La Collaboration des Composants

Qu'est-ce qu'un Test d'Intégration ?

Alors que les tests unitaires se concentrent sur des unités isolées, les tests d'intégration vérifient que différentes unités ou composants de votre système fonctionnent correctement ensemble. Ils testent les interactions, les flux de données et la communication entre les modules, les services ou les couches de votre application.

Caractéristiques des Tests d'Intégration

  • Interactions : Ils valident les chemins de code qui impliquent plusieurs composants.
  • Dépendances réelles : Ils peuvent interagir avec des bases de données réelles, des systèmes de fichiers, des API externes (via des doubles de test ou des environnements de test dédiés).
  • Lenteur relative : Plus lents que les tests unitaires car ils peuvent impliquer des I/O (Input/Output) ou des configurations plus complexes.
  • Moins nombreux : Moins de tests d'intégration que de tests unitaires, car ils couvrent des scénarios plus larges plutôt que des cas granulaires.
  • Confiance dans le système : Ils donnent une plus grande confiance dans le fonctionnement global de l'application.

Outils pour les Tests d'Intégration avec TypeScript

Jest peut également être utilisé pour les tests d'intégration. Pour tester des API REST, des bibliothèques comme supertest sont très utiles en combinaison avec Jest.

Exemple Pratique : Test d'Intégration d'une API avec Jest et Supertest

Imaginons que nous ayons une petite API Express en TypeScript.

Pré-requis : Installer express, @types/express, supertest, @types/supertest.

npm install --save-dev express @types/express supertest @types/supertest

Fichier : src/app.ts

import express from 'express';
import { add, subtract, multiply, divide } from './math'; // Réutiliser nos fonctions mathématiques

const app = express();
app.use(express.json()); // Pour parser les requêtes JSON

// Endpoint pour la somme
app.post('/api/add', (req, res) => {
    const { num1, num2 } = req.body;
    if (typeof num1 !== 'number' || typeof num2 !== 'number') {
        return res.status(400).json({ error: 'Invalid input: num1 and num2 must be numbers.' });
    }
    const result = add(num1, num2); // Utilise la fonction add de notre module math
    res.json({ result });
});

// Endpoint pour la division
app.post('/api/divide', (req, res) => {
    const { num1, num2 } = req.body;
    if (typeof num1 !== 'number' || typeof num2 !== 'number') {
        return res.status(400).json({ error: 'Invalid input: num1 and num2 must be numbers.' });
    }
    try {
        const result = divide(num1, num2); // Utilise la fonction divide
        res.json({ result });
    } catch (error: any) {
        res.status(400).json({ error: error.message });
    }
});

export default app; // Exporter l'application Express pour les tests

Cette application Express expose deux endpoints qui utilisent nos fonctions mathématiques.

Fichier : src/app.test.ts

import request from 'supertest';
import app from './app'; // Importer notre application Express

// Décrire les tests pour l'API
describe('API Integration Tests', () => {

    // Tester l'endpoint d'addition
    test('POST /api/add should return the sum of two numbers', async () => {
        const response = await request(app) // 'request' de supertest prend l'instance de l'app Express
            .post('/api/add')
            .send({ num1: 10, num2: 5 }); // Envoyer un corps de requête JSON

        expect(response.statusCode).toBe(200); // Vérifier le code de statut HTTP
        expect(response.body).toEqual({ result: 15 }); // Vérifier le corps de la réponse JSON
    });

    // Tester un cas d'erreur pour l'endpoint d'addition
    test('POST /api/add should return 400 for invalid input', async () => {
        const response = await request(app)
            .post('/api/add')
            .send({ num1: 'ten', num2: 5 }); // Entrée invalide

        expect(response.statusCode).toBe(400);
        expect(response.body).toEqual({ error: 'Invalid input: num1 and num2 must be numbers.' });
    });

    // Tester l'endpoint de division
    test('POST /api/divide should return the quotient of two numbers', async () => {
        const response = await request(app)
            .post('/api/divide')
            .send({ num1: 10, num2: 2 });

        expect(response.statusCode).toBe(200);
        expect(response.body).toEqual({ result: 5 });
    });

    // Tester le cas de division par zéro
    test('POST /api/divide should return 400 for division by zero', async () => {
        const response = await request(app)
            .post('/api/divide')
            .send({ num1: 10, num2: 0 });

        expect(response.statusCode).toBe(400);
        expect(response.body).toEqual({ error: 'Cannot divide by zero' });
    });
});

Ces tests d'intégration lancent votre application Express (en mémoire, grâce à Supertest) et lui envoient de vraies requêtes HTTP. Ils vérifient que les routes fonctionnent, que les données sont traitées correctement à travers les couches (API -> fonction métier) et que les réponses sont conformes aux attentes.

Distinction et Complémentarité : La Pyramide des Tests

Il est crucial de comprendre que les tests unitaires et d'intégration ne sont pas mutuellement exclusifs ; ils sont complémentaires. Ensemble, ils offrent une couverture de test robuste pour votre application. Le concept de la Pyramide des Tests est une excellente métaphore pour visualiser la relation entre les différents types de tests :

  • Base de la pyramide (la plus large) : Tests Unitaires
    • Nombreux, rapides, peu coûteux.
    • Testent des unités isolées de manière granulaire.
  • Milieu de la pyramide : Tests d'Intégration
    • Moins nombreux que les tests unitaires, plus lents, plus coûteux.
    • Testent les interactions entre plusieurs composants.
  • Sommet de la pyramide (la plus petite) : Tests End-to-End (E2E) ou IHM
    • Très peu nombreux, très lents, très coûteux.
    • Testent le système entier du point de vue de l'utilisateur final (ex: via un navigateur).
    • (Non abordé en détail dans cette leçon, mais important pour une couverture complète).

L'idée est d'avoir une grande quantité de tests unitaires pour une rétroaction rapide sur la correction de la logique métier, une quantité modérée de tests d'intégration pour s'assurer que les composants collaborent bien, et une petite quantité de tests E2E pour valider les parcours utilisateur critiques.

Bonnes Pratiques de Test

Pour rendre vos tests efficaces et maintenables :

  • Les principes F.I.R.S.T. :
    • Fast (Rapide) : Les tests devraient s'exécuter rapidement.
    • Isolated (Isolé) : Les tests ne devraient pas dépendre de l'ordre d'exécution ou de l'état d'autres tests.
    • Repeatable (Répétable) : Chaque exécution d'un test devrait produire le même résultat.
    • Self-validating (Auto-validant) : Le test doit passer ou échouer sans intervention manuelle.
    • Timely (Opportun) : Écrivez les tests tôt, idéalement avant ou pendant le développement du code (TDD).
  • Utiliser des Doubles de Test (Mocks, Stubs, Spies) :
    • Mocks : Objets simulés qui enregistrent les interactions et vous permettent de vérifier qu'une méthode a été appelée avec certains arguments. Essentiels pour l'isolation des tests unitaires.
    • Stubs : Objets qui fournissent des réponses prédéfinies à des appels de méthodes spécifiques.
    • Spies : Enveloppent des fonctions existantes pour espionner leur comportement (nombre d'appels, arguments passés) sans altérer leur logique.
  • Nommage clair des tests : Utilisez des noms descriptifs qui expliquent ce que le test fait et quel comportement il vérifie (ex: "should correctly add two numbers").
  • Couverture de code : Suivez le pourcentage de votre code qui est couvert par les tests. Cela peut aider à identifier les lacunes, mais ne doit pas être la seule métrique de qualité. Jest fournit des outils pour la couverture de code.
  • Tests d'erreurs et de cas limites : Ne testez pas seulement le "chemin heureux". Assurez-vous que votre code gère correctement les entrées invalides, les conditions d'erreur, et les cas extrêmes (par exemple, division par zéro, chaînes vides, grands nombres).
  • Tests atomiques : Chaque test ne devrait tester qu'une seule chose. Si un test échoue, il doit être évident de savoir pourquoi et où.

Conclusion

Les tests unitaires et les tests d'intégration sont deux piliers fondamentaux pour construire des applications TypeScript robustes et maintenables.

  • Les tests unitaires vous offrent une validation rapide et granulaire de la logique de vos plus petites unités de code, garantissant leur fonctionnement isolé.
  • Les tests d'intégration vous donnent confiance dans la manière dont ces unités collaborent, s'assurant que les interactions complexes et les flux de données fonctionnent comme prévu.

En adoptant une stratégie de test équilibrée (comme la pyramide des tests) et en suivant les bonnes pratiques, vous réduirez considérablement le nombre de bugs, améliorerez la qualité de votre code et développerez avec une plus grande sérénité. L'investissement dans les tests est un investissement dans la stabilité et la longévité de votre application. Continuez à pratiquer l'écriture de tests : c'est une compétence qui vous servira tout au long de votre carrière de développeur.