Contactez-nous

Ecriture de tests unitaires : tester des fonctions individuelles

Apprenez à écrire des tests unitaires ciblés pour valider le comportement de fonctions individuelles en Node.js, une étape clé pour un code fiable et maintenable.

Fondements des tests unitaires en Node.js

L'écriture de tests unitaires constitue une pratique fondamentale dans le développement logiciel moderne, et Node.js ne fait pas exception. Un test unitaire vise à vérifier le bon fonctionnement de la plus petite unité de code testable, qui est généralement une fonction ou une méthode individuelle. L'objectif principal est d'isoler cette unité et de s'assurer qu'elle produit le résultat attendu pour un ensemble donné d'entrées, indépendamment du reste de l'application.

L'intérêt des tests unitaires est multiple. Ils permettent de détecter les régressions très tôt dans le cycle de développement, c'est-à-dire les bugs introduits dans une partie du code par une modification effectuée ailleurs. Ils servent également de documentation vivante, décrivant précisément ce que chaque fonction est censée faire. De plus, une bonne couverture de tests unitaires donne confiance aux développeurs pour refactoriser le code ou ajouter de nouvelles fonctionnalités sans craindre de casser l'existant.

Pour tester une fonction de manière unitaire, il est crucial de l'isoler de ses dépendances externes, comme les accès à la base de données, les appels réseau ou l'interaction avec le système de fichiers. Ces dépendances sont souvent simulées à l'aide de techniques comme le mocking ou le stubbing, qui seront abordées plus en détail ultérieurement. L'accent est mis ici sur la logique interne de la fonction elle-même.

L'écosystème Node.js offre plusieurs frameworks populaires pour faciliter l'écriture et l'exécution des tests unitaires. Parmi les plus utilisés, on trouve Jest, Mocha (souvent associé à Chai pour les assertions) et Ava. Ces outils fournissent une structure pour organiser les tests, des fonctions pour effectuer des assertions (vérifier les résultats) et des mécanismes pour exécuter les tests et rapporter les résultats.

Le pattern AAA : structurer vos tests unitaires

Pour garantir la clarté, la lisibilité et la maintenabilité des tests unitaires, il est fortement recommandé d'adopter une structure cohérente. Le pattern "Arrange, Act, Assert" (AAA), parfois traduit par "Arranger, Agir, Auditer" ou "Préparer, Exécuter, Vérifier", est une convention largement répandue et efficace pour organiser le corps de chaque cas de test.

La première phase, Arrange (Arranger), consiste à mettre en place toutes les préconditions nécessaires à l'exécution du test. Cela inclut l'initialisation des variables, la création d'objets, la configuration d'éventuels mocks ou stubs pour isoler la fonction testée, et la définition des données d'entrée spécifiques pour ce cas de test.

La deuxième phase, Act (Agir), correspond à l'exécution de l'unité de code que l'on souhaite tester, c'est-à-dire l'appel de la fonction ou de la méthode avec les paramètres préparés lors de la phase "Arrange". Le résultat de cette action est généralement stocké dans une variable pour être vérifié ensuite.

La troisième et dernière phase, Assert (Auditer ou Vérifier), consiste à comparer le résultat obtenu lors de la phase "Act" (ou l'état de l'application après l'action) avec le résultat attendu. C'est ici que l'on utilise les fonctions d'assertion fournies par le framework de test (comme `expect` dans Jest ou Chai) pour valider que la fonction s'est comportée comme prévu.

Prenons un exemple conceptuel simple : tester une fonction `calculerRemise(prix, pourcentage)`. La phase "Arrange" définirait `prix = 100` et `pourcentage = 10`. La phase "Act" appellerait `remise = calculerRemise(100, 10)`. La phase "Assert" vérifierait que `remise` est bien égale à `10`.

Exemple concret : tester une fonction simple avec Jest

Mettons en pratique le pattern AAA avec un exemple concret en utilisant Jest, un framework de test populaire dans l'écosystème JavaScript et Node.js. Supposons que nous ayons une fonction simple qui met la première lettre d'une chaîne de caractères en majuscule.

Voici le code de notre fonction, que nous placerons dans un fichier nommé `stringUtils.js` :

// stringUtils.js
function capitalize(str) {
  if (typeof str !== 'string' || str.length === 0) {
    return '';
  }
  return str.charAt(0).toUpperCase() + str.slice(1);
}

module.exports = { capitalize };

Maintenant, créons le fichier de test correspondant, traditionnellement nommé `stringUtils.test.js` (ou `stringUtils.spec.js`). Jest détectera et exécutera automatiquement les fichiers respectant cette convention de nommage.

Dans ce fichier de test, nous utilisons la structure fournie par Jest : `describe` pour regrouper les tests relatifs à une fonctionnalité ou un module, et `test` (ou son alias `it`) pour définir un cas de test individuel. La fonction `expect` de Jest, combinée à des "matchers" comme `toBe`, nous permet d'effectuer nos assertions.

Voici le test pour notre fonction `capitalize`, en suivant le pattern AAA :

// stringUtils.test.js
const { capitalize } = require('./stringUtils');

describe('Fonction capitalize', () => {
  test('devrait mettre la première lettre en majuscule', () => {
    // Arrange
    const inputString = 'bonjour';
    const expectedString = 'Bonjour';

    // Act
    const result = capitalize(inputString);

    // Assert
    expect(result).toBe(expectedString);
  });

  test('devrait retourner une chaîne vide si l\'entrée est vide', () => {
    // Arrange
    const inputString = '';
    const expectedString = '';

    // Act
    const result = capitalize(inputString);

    // Assert
    expect(result).toBe(expectedString);
  });

  test('devrait retourner une chaîne vide si l\'entrée n\'est pas une chaîne', () => {
    // Arrange
    const input = 123;
    const expectedString = '';

    // Act
    const result = capitalize(input);

    // Assert
    expect(result).toBe(expectedString);
  });
});

Pour exécuter ces tests, vous lanceriez la commande `jest` (ou `npx jest`) dans votre terminal, après avoir installé Jest (`npm install --save-dev jest` ou `yarn add --dev jest`).

Couvrir différents scénarios et utiliser les matchers Jest

Un bon test unitaire ne se contente pas de vérifier le "chemin heureux" (le cas nominal où tout se passe bien). Il est essentiel de tester également les cas limites (edge cases), les valeurs invalides et différents scénarios possibles pour s'assurer de la robustesse de la fonction.

Pour notre fonction `capitalize`, nous avons déjà inclus des tests pour une chaîne vide et une entrée non-chaîne. On pourrait ajouter des tests pour des chaînes contenant déjà une majuscule, des chaînes avec des espaces, des caractères spéciaux, etc., en fonction des spécifications attendues de la fonction.

Jest fournit une large gamme de "matchers" pour effectuer des assertions variées et expressives. Nous avons utilisé `toBe` qui vérifie l'égalité stricte (`===`). D'autres matchers courants incluent :

  • `toEqual(value)` : Vérifie l'égalité profonde (récursive) pour les objets et les tableaux.
  • `toBeNull()` : Vérifie si la valeur est `null`.
  • `toBeUndefined()` : Vérifie si la valeur est `undefined`.
  • `toBeDefined()` : L'inverse de `toBeUndefined()`.
  • `toBeTruthy()` : Vérifie si la valeur est évaluée comme vraie dans un contexte booléen.
  • `toBeFalsy()` : Vérifie si la valeur est évaluée comme fausse.
  • `toContain(item)` : Vérifie si un tableau ou une chaîne contient un élément spécifique.
  • `toMatch(regexp | string)` : Vérifie si une chaîne correspond à une expression régulière ou une sous-chaîne.
  • `toThrow(error?)` : Vérifie si une fonction lève une exception lorsqu'elle est appelée.

Imaginons une fonction qui filtre un tableau pour ne garder que les nombres pairs. Voici comment on pourrait la tester en utilisant `toEqual` pour comparer des tableaux :

// filterUtils.js
function filterEvenNumbers(numbers) {
  if (!Array.isArray(numbers)) {
    return [];
  }
  return numbers.filter(num => typeof num === 'number' && num % 2 === 0);
}
module.exports = { filterEvenNumbers };

// filterUtils.test.js
const { filterEvenNumbers } = require('./filterUtils');

describe('Fonction filterEvenNumbers', () => {
  test('devrait retourner uniquement les nombres pairs', () => {
    // Arrange
    const input = [1, 2, 3, 4, 5, 6];
    const expected = [2, 4, 6];

    // Act
    const result = filterEvenNumbers(input);

    // Assert
    expect(result).toEqual(expected); // Utilisation de toEqual pour les tableaux
  });

  test('devrait retourner un tableau vide si aucun nombre pair', () => {
    // Arrange
    const input = [1, 3, 5, 7];
    const expected = [];

    // Act
    const result = filterEvenNumbers(input);

    // Assert
    expect(result).toEqual(expected);
  });

  test('devrait gérer un tableau vide', () => {
    // Arrange
    const input = [];
    const expected = [];

    // Act
    const result = filterEvenNumbers(input);

    // Assert
    expect(result).toEqual(expected);
  });

  test('devrait ignorer les éléments non numériques', () => {
    // Arrange
    const input = [1, 'deux', 3, 4, null, 6];
    const expected = [4, 6];

    // Act
    const result = filterEvenNumbers(input);

    // Assert
    expect(result).toEqual(expected);
  });
});

Choisir le bon matcher rend vos tests plus clairs et exprime précisément l'intention de la vérification.

Tester les fonctions asynchrones

Node.js étant fondamentalement asynchrone, il est très fréquent de devoir tester des fonctions qui effectuent des opérations asynchrones, comme des appels réseau, des lectures de fichiers ou des interactions avec une base de données (même si ces dernières sont souvent mockées en tests unitaires). Tester du code asynchrone nécessite une approche légèrement différente car le test ne doit pas se terminer avant la fin de l'opération asynchrone.

Si votre fonction retourne une Promesse (Promise), ce qui est une pratique courante pour gérer l'asynchronisme en JavaScript moderne, Jest facilite grandement les tests. Il suffit de retourner la Promesse depuis votre fonction de test. Jest attendra alors que la Promesse soit résolue ou rejetée avant de terminer le test.

Une autre approche, souvent considérée comme plus lisible, consiste à utiliser `async/await` directement dans votre fonction de test. Déclarez la fonction de test avec le mot-clé `async`, puis utilisez `await` pour attendre la résolution de la Promesse retournée par la fonction testée. Jest gère cela nativement.

Considérons une fonction simple qui simule une récupération de données après un court délai :

// dataService.js
function fetchData(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (userId === 1) {
        resolve({ id: 1, name: 'Alice' });
      } else {
        reject(new Error('Utilisateur non trouvé'));
      }
    }, 50); // Simule un délai réseau
  });
}
module.exports = { fetchData };

// dataService.test.js
const { fetchData } = require('./dataService');

describe('Fonction fetchData', () => {
  // Test du cas de succès avec async/await
  test('devrait retourner les données de l\'utilisateur pour un ID valide', async () => {
    // Arrange
    const userId = 1;
    const expectedData = { id: 1, name: 'Alice' };

    // Act
    const data = await fetchData(userId);

    // Assert
    expect(data).toEqual(expectedData);
  });

  // Test du cas d'erreur avec async/await et expect().rejects
  test('devrait rejeter avec une erreur pour un ID invalide', async () => {
    // Arrange
    const userId = 2;

    // Act & Assert
    // expect().rejects permet d'attendre qu'une promesse soit rejetée
    await expect(fetchData(userId)).rejects.toThrow('Utilisateur non trouvé');
  });

  // Alternative pour le succès en retournant la promesse
  test('devrait retourner les données (alternative avec return)', () => {
    // Arrange
    const userId = 1;
    const expectedData = { id: 1, name: 'Alice' };

    // Act & Assert
    // expect().resolves permet d'attendre qu'une promesse soit résolue
    return expect(fetchData(userId)).resolves.toEqual(expectedData);
  });
});

Jest fournit également les matchers `.resolves` et `.rejects` qui peuvent être combinés avec `expect` pour une syntaxe concise lors du test de promesses, comme illustré dans les exemples ci-dessus.

Adopter les bonnes pratiques pour des tests unitaires de qualité

Pour maximiser les bénéfices des tests unitaires, il convient de suivre certaines bonnes pratiques reconnues. Premièrement, chaque test doit être indépendant des autres. L'ordre d'exécution des tests ne devrait jamais influencer leur résultat. Cela signifie qu'un test ne doit pas dépendre de l'état laissé par un test précédent, et chaque test doit nettoyer après lui si nécessaire (bien que pour les tests unitaires purs de fonctions, le nettoyage soit moins souvent requis).

Visez des tests petits et focalisés. Idéalement, un cas de test (`test` ou `it`) ne devrait vérifier qu'une seule chose, un seul aspect du comportement de la fonction. Si vous avez plusieurs assertions distinctes à faire sur différents aspects, envisagez de créer plusieurs cas de test. Cela rend les échecs de test plus faciles à diagnostiquer : le nom du test qui échoue indique précisément quelle fonctionnalité est cassée.

Utilisez des noms descriptifs pour vos suites de tests (`describe`) et vos cas de test (`test`/`it`). Le nom doit clairement indiquer ce qui est testé et quel est le comportement attendu. Par exemple, au lieu de `test('test 1')`, préférez `test('devrait retourner la somme de deux nombres positifs')`. Ces noms servent de documentation et facilitent la compréhension rapide de l'intention du test.

Evitez d'introduire de la logique complexe (conditions `if`, boucles `for`) à l'intérieur de vos tests. Un test doit être simple et direct à lire. Si vous avez besoin de tester plusieurs variations d'entrées, préférez créer plusieurs tests distincts ou utilisez les fonctionnalités de tests paramétrés offertes par certains frameworks comme Jest (`test.each`).

Assurez-vous que vos tests unitaires sont rapides à exécuter. Une suite de tests rapide encourage les développeurs à la lancer fréquemment. C'est l'une des raisons pour lesquelles on isole la fonction de ses dépendances externes (I/O, réseau) qui sont généralement lentes. Enfin, écrivez des tests pour le code que vous écrivez. Intégrer les tests dans votre flux de travail (par exemple, en adoptant le Test-Driven Development - TDD) garantit une meilleure couverture et une conception plus testable.