Accueil / Articles / Test de Enterprise Node.js : nommage AAA, couverture et usines de tests

Test de Enterprise Node.js : nommage AAA, couverture et usines de tests

Apprenez à structurer les tests Node.js avec le modèle AAA, à équilibrer la couverture unitaire et d’intégration, à vérifier cinq résultats clés du domaine, ainsi qu’à isoler les tests en utilisant des factories.

1401 mots

Même la base de code la plus soigneusement structurée s’usera avec le temps si rien ne la vérifie automatiquement. À mesure que les équipes s’agrandissent et que l’ensemble des fonctionnalités continue de croître, un ensemble de tests solide devient la principale ligne de défense contre les dérives qui pourraient atteindre la production.

Cet article se concentre sur les pratiques de test de niveau entreprise pour Node.js : comment écrire des tests clairs et lisible, comment maintenir l’indépendance entre les tests, comment trouver le bon équilibre entre la couverture unitaire et celle intégrée, ainsi que comment confirmer les cinq résultats clés que toute logique métier peut produire.

1. Structure et nommage des tests : le modèle AAA

Considérez vos tests comme une documentation vivante des règles métier qu’ils couvrent. Lorsqu’un test échoue au sein d’une pipeline CI/CD en pleine nuit, la personne de garde doit immédiatement comprendre ce qui a échoué, dans quelles circonstances et ce qui aurait dû se produire à la place.

Le modèle AAA (Arrange-Act-Assert)

Structurez chaque test de manière à ce qu’il se divise en trois étapes clairement distinctes :

┌─────────────────────────────────────────────────────────┐
│ 1. ARRANGE                                              │
│    Set up preconditions, create inputs, mock dependencies│
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 2. ACT                                                  │
│    Execute the single domain operation being tested      │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 3. ASSERT                                               │
│    Verify returned results, DB state, and side-effects  │
└─────────────────────────────────────────────────────────┘

Norme de nommage des tests descriptive

Évitez les étiquettes vagues telles que it('works') ou it('should test user service'). Préférez plutôt une convention de nommage qui indique clairement l’objectif du test dès le départ :

given [précondition/contexte], when [action], then [résultat attendu]

La mauvaise méthode : définitions de tests obscures

// orders.service.test.js
describe('orders service', () => {
  it('creates order', async () => {
    // Everything mashed together, no context
    const res = await orderService.createOrder({ userId: '123', items: [] });
    expect(res).toBeDefined();
  });
});

La bonne méthode : des tests AAA auto-déclaratifs

// components/orders/orders.service.test.js
const { orderService } = require('./orders.service');
const { ValidationError } = require('../../shared/errors/AppError');
const userFactory = require('../../../test/factories/user.factory');

describe('OrderService.createOrder', () => {
  it('given an empty cart, when creating an order, then it throws a ValidationError', async () => {
    // ARRANGE
    const user = await userFactory.build();
    const payload = { userId: user.id, items: [] };

    // ACT & ASSERT
    await expect(orderService.createOrder(payload))
      .rejects
      .toThrow(ValidationError);
  });
});

2. Tests unitaires vs. tests d’intégration : trouver l’équilibre idéal

Une question récurrente dans les tests Node.js concerne la manière de répartir les efforts entre les tests unitaires, qui s’exécutent rapidement et reposent fortement sur des mocks, et les tests d’intégration, qui s’exécutent plus lentement mais utilisent des bases de données réelles et des appels réseau.

                      ┌──────────────────────────┐
                      │    End-to-End (E2E)      │  ~10% of tests
                      │  (Full stack / Cypress)  │  (Slowest, high confidence)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │    Integration Tests     │  ~40% of tests
                      │ (Real DB / HTTP Endpoints│  (Medium speed, real wiring)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │       Unit Tests         │  ~50% of tests
                      │ (Isolated Domain Logic)  │  (Fastest, instant feedback)
                      └──────────────────────────┘

Tests unitaires : logique de domaine pure

Les tests unitaires ont pour but d’évaluer la logique métier contenue au sein des services de domaine en totale isolation. Tout élément extérieur, tel que des repositories de base de données ou des clients API externes, doit être remplacé par des stubs ou des mocks.

  • Ce qu’ils couvrent : les services de domaine, la logique de calcul et les outils utilitaires partagés.
  • Vitesse d’exécution : généralement moins d’un milliseconde par test.
  • Ce qui est interdit : pas de contact avec le réseau, pas d’accès à une base de données en temps réel, pas d’accès au système de fichiers.
  • Tests d’intégration : Connexions réelles et infrastructure

    Les tests d’intégration vérifient que votre code fonctionne correctement avec des systèmes externes réels (PostgreSQL, Redis, RabbitMQ) ainsi qu’avec le framework web que vous utilisez (Express ou Fastify).

    • Ce qu’ils couvrent : les requêtes vers le répertoire de données, les points d’entrée API (via supertest), et les consommateurs de files d’attente.
    • Vitesse d’exécution : environ des dizaines à des centaines de millisecondes par test.
    • Sur quoi ils s’appuient : des bases de données conteneurisées, mises en place à l’aide d’outils tels que Testcontainers ou Docker Compose, afin de vérifier l’exécution réelle de commandes SQL ou NoSQL.

    3. Les 5 résultats clés des services de domaine

    Lorsque vous écrivez des tests unitaires ou d’intégration pour une méthode de service métier, cette fonctionnalité du domaine peut générer jusqu’à cinq types différents de résultats. Une suite de tests complète doit couvrir chaque résultat applicable à l’opération testée :

    ┌────────────────────────────────────────────────────────────────────────┐
    │                        Domain Service Operation                        │
    └──────┬──────────────┬──────────────┬──────────────────┬────────────────┘
           │              │              │                  │
           ▼              ▼              ▼                  ▼
    ┌─────────────┐┌─────────────┐┌─────────────┐┌─────────────────────┐┌──────────────┐
    │  1. Return  ││  2. State   ││ 3. Outgoing ││ 4. Events Published ││ 5. Telemetry │
    │    Value    ││   Changes   ││  API Calls  ││  (Broker / Queue)   ││   / Logs     │
    └─────────────┘└─────────────┘└─────────────┘└─────────────────────┘└──────────────┘
    

    Exemple : Tester les 5 résultats

    Considérez comment vous testeriez une opération métier complète telle que orderService.checkoutOrder.

    // components/orders/orders.service.test.js
    
    describe('OrderService.checkoutOrder', () => {
      it('given a valid order, when checkout occurs, then fulfills all 5 outcomes', async () => {
        // -------------------------------------------------------------
        // ARRANGE: Setup Mocks & Dependencies
        // -------------------------------------------------------------
        const mockOrderRepo = {
          findById: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PENDING', total: 100 }),
          updateStatus: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PAID', total: 100 })
        };
    
        const mockPaymentGateway = {
          charge: jest.fn().mockResolvedValue({ transactionId: 'txn_999', success: true })
        };
    
        const mockEventBus = {
          publish: jest.fn().mockResolvedValue(true)
        };
    
        const mockLogger = {
          info: jest.fn()
        };
    
        const orderService = createOrderService({
          orderRepo: mockOrderRepo,
          paymentGateway: mockPaymentGateway,
          eventBus: mockEventBus,
          logger: mockLogger
        });
    
        // -------------------------------------------------------------
        // ACT: Execute Domain Action
        // -------------------------------------------------------------
        const result = await orderService.checkoutOrder({ orderId: 'ord_123', paymentToken: 'tok_visa' });
    
        // -------------------------------------------------------------
        // ASSERT: Verify All 5 Outcomes
        // -------------------------------------------------------------
    
        // Outcome 1: Verify Return Value
        expect(result).toEqual(expect.objectContaining({
          id: 'ord_123',
          status: 'PAID'
        }));
    
        // Outcome 2: Verify Database State Change
        expect(mockOrderRepo.updateStatus).toHaveBeenCalledWith('ord_123', 'PAID');
    
        // Outcome 3: Verify Outgoing Third-Party Call
        expect(mockPaymentGateway.charge).toHaveBeenCalledWith({
          amount: 100,
          token: 'tok_visa'
        });
    
        // Outcome 4: Verify Message Queue / Event Emission
        expect(mockEventBus.publish).toHaveBeenCalledWith(
          'order.completed',
          expect.objectContaining({ orderId: 'ord_123' })
        );
    
        // Outcome 5: Verify Telemetry / Observability
        expect(mockLogger.info).toHaveBeenCalledWith(
          expect.stringContaining('Order ord_123 successfully checked out')
        );
      });
    });
    

    4. Prévenir l’interdépendance des tests avec les usines de tests

    L’une des principales causes de suites de tests peu fiables est l’état mutable partagé — des tests qui dépendent de fichiers de configuration globaux, de lignes partagées dans une base de données ou de résidus d’une exécution de test précédente.

    Le problème des fichiers de configuration partagés

    // ❌ BAD: Hardcoded shared static data across test files
    const testUser = { id: '123', email: 'john@example.com' };
    
    // If Test A mutates testUser.email, Test B fails unpredictably!
    

    La solution : des usines de tests dynamiques

    La solution consiste à s’appuyer sur des usines de tests qui génèrent des données fraîches et uniques à chaque exécution, plutôt que de réutiliser des objets statiques entre différents fichiers.

    // test/factories/user.factory.js
    const { crypto } = require('crypto');
    
    class UserFactory {
      static build(overrides = {}) {
        const randomId = Math.random().toString(36).substring(7);
    
        return {
          id: `usr_${randomId}`,
          email: `user_${randomId}@example.com`,
          role: 'CUSTOMER',
          createdAt: new Date(),
          ...overrides // Allow callers to override specific properties
        };
      }
    
      static async create(dbClient, overrides = {}) {
        const user = this.build(overrides);
        await dbClient.query(
          'INSERT INTO users (id, email, role, created_at) VALUES ($1, $2, $3, $4)',
          [user.id, user.email, user.role, user.createdAt]
        );
        return user;
      }
    }
    
    module.exports = UserFactory;
    

    Utilisation dans les tests d’intégration

    Avec cette approche, chaque test dispose de son propre ensemble de données isolé, ce qui permet aux exécuteurs de tests tels que Jest, Vitest ou l’exécuteur de tests intégré de Node d’exécuter les spécifications en parallèle sans rencontrer de conditions concurrentes :

    it('given an admin user, when fetching reports, then returns data', async () => {
      // Generates fresh, isolated database record with admin role override
      const adminUser = await UserFactory.create(dbClient, { role: 'ADMIN' });
    
      const response = await request(app)
        .get('/api/v1/reports')
        .set('x-user-id', adminUser.id);
    
      expect(response.status).toBe(200);
    });
    

    Checklist d’architecture pour la partie 3

    • Structure AAA : Chaque test est-il clairement divisé en sections Arrange, Act et Assert ?
    • Nommage descriptif : Les titres des tests décrivent-ils le contexte, l’action et le résultat attendu, selon un schéma given... when... then... ?
  • Pyramide équilibrée : Comptez-vous sur des tests unitaires rapides pour la logique métier tout en réservant les tests d’intégration aux frontières avec la base de données et HTTP ?
  • Tous les cinq résultats vérifiés : Lorsque cela est pertinent, vérifiez-vous les valeurs de retour, les modifications dans la base de données, les appels vers des tiers, les événements émis et le comportement de journalisation ?
  • Aucun état partagé : Les générateurs de données créent-ils des enregistrements isolés pour chaque test au lieu que les tests partagent des fichiers de configuration globaux ?
  • Lectures complémentaires