Contactez-nous

Tests unitaires et tests d'intégration

Assurez la fiabilité de votre API de tâches ! Apprenez à écrire des tests unitaires pour vos modèles Mongoose et des tests d'intégration pour vos endpoints Express avec Jest et SuperTest.

Garantir la qualité : pourquoi tester notre API ?

Maintenant que nous avons une première version fonctionnelle de notre API de gestion de tâches, comment pouvons-nous nous assurer qu'elle fonctionne correctement et qu'elle continuera de le faire à mesure que nous ajoutons des fonctionnalités ou que nous la modifions ? La réponse réside dans les tests automatisés. Ecrire des tests est une étape essentielle pour construire des applications robustes et maintenables.

Pour notre API, les tests nous permettront de :

  • Vérifier la logique métier : S'assurer que nos modèles de données se comportent comme prévu (validations, valeurs par défaut).
  • Valider les endpoints : Confirmer que chaque route de notre API répond correctement aux requêtes HTTP (bons codes de statut, formats de réponse attendus).
  • Prévenir les régressions : Détecter rapidement si un changement dans le code casse une fonctionnalité existante.
  • Faciliter le refactoring : Avoir une suite de tests solide nous donne la confiance nécessaire pour remanier ou améliorer notre code sans craindre de tout casser.
  • Documenter le comportement : Les tests servent aussi de documentation vivante, illustrant comment l'API est censée fonctionner.

Nous allons nous concentrer sur deux niveaux de tests principaux pour notre API : les tests unitaires pour isoler et vérifier de petites parties de notre code (comme le modèle Mongoose), et les tests d'intégration pour vérifier que les différents composants de notre API (routes Express, logique métier, interaction avec la base de données) fonctionnent correctement ensemble au niveau des requêtes HTTP.

Mise en place de l'environnement de test avec Jest et SuperTest

Nous utiliserons Jest comme framework de test principal. Il est populaire, facile à configurer et inclut un exécuteur de tests, une bibliothèque d'assertions et des fonctionnalités de mocking. Pour tester nos endpoints HTTP, nous utiliserons SuperTest, une bibliothèque qui facilite l'envoi de requêtes HTTP à notre serveur Express directement depuis nos tests.

Installons ces dépendances de développement :

npm install --save-dev jest supertest mongodb-memory-server

Nous ajoutons `mongodb-memory-server` pour pouvoir exécuter nos tests d'intégration contre une base de données MongoDB en mémoire, ce qui les rend plus rapides et isolés de notre base de développement ou de production.

Ajoutons un script de test dans notre `package.json` :

// package.json (section scripts)
"scripts": {
  "start": "node src/server.js",
  "dev": "nodemon src/server.js",
  "test": "jest --watchAll --runInBand"
}

L'option `--watchAll` relance les tests à chaque modification de fichier (utile pendant l'écriture des tests), et `--runInBand` exécute les tests séquentiellement, ce qui peut être utile pour éviter des conflits avec la base de données en mémoire.

Nous allons créer un fichier de configuration pour Jest (`jest.config.js`) ou simplement laisser Jest utiliser ses valeurs par défaut. Il est crucial de configurer nos tests pour utiliser une base de données séparée. Nous utiliserons `mongodb-memory-server` pour cela. Créons un fichier de setup pour Jest, par exemple `tests/setup.js` :

// tests/setup.js (exemple basique)
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');

let mongoServer;

beforeAll(async () => {
  mongoServer = await MongoMemoryServer.create();
  const mongoUri = mongoServer.getUri();
  await mongoose.connect(mongoUri, {
      useNewUrlParser: true,
      useUnifiedTopology: true,
  });
});

beforeEach(async () => {
  // Nettoyer toutes les collections avant chaque test
  const collections = mongoose.connection.collections;
  for (const key in collections) {
    const collection = collections[key];
    await collection.deleteMany({});
  }
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
  await mongoServer.stop();
});

Configurez Jest pour utiliser ce fichier de setup dans `package.json` ou `jest.config.js` :

// package.json (ajout dans la section jest ou à la racine)
"jest": {
  "setupFilesAfterEnv": ["./tests/setup.js"]
}

Ecriture des tests unitaires : Valider le modèle Task

Les tests unitaires se concentrent sur des unités isolées. Testons notre modèle Mongoose `Task` pour vérifier ses validations et ses valeurs par défaut. Créez un fichier `tests/models/task.test.js` :

// tests/models/task.test.js
const mongoose = require('mongoose');
const Task = require('../../src/models/task');

describe('Task Model', () => {

  it('devrait créer une nouvelle tâche avec succès avec les champs obligatoires', async () => {
    const taskData = { title: 'Test unitaire Modèle' };
    const task = new Task(taskData);
    await task.save();
    expect(task._id).toBeDefined();
    expect(task.title).toBe(taskData.title);
    expect(task.description).toBe(''); // Valeur par défaut
    expect(task.completed).toBe(false); // Valeur par défaut
    expect(task.createdAt).toBeDefined();
    expect(task.updatedAt).toBeDefined();
  });

  it('devrait échouer si le titre est manquant', async () => {
    const taskData = { description: 'Sans titre' }; // Titre manquant
    let err;
    try {
      const task = new Task(taskData);
      await task.save();
    } catch (error) {
      err = error;
    }
    expect(err).toBeInstanceOf(mongoose.Error.ValidationError);
    expect(err.errors.title).toBeDefined();
  });

  it('devrait assigner correctement la description et completed si fournis', async () => {
    const taskData = { 
        title: 'Tâche complète', 
        description: 'Description test', 
        completed: true 
    };
    const task = new Task(taskData);
    await task.save();
    expect(task.description).toBe(taskData.description);
    expect(task.completed).toBe(true);
  });

  it('devrait enlever les espaces superflus du titre et de la description', async () => {
    const taskData = { title: '  Titre avec espaces  ', description: '  Desc espaces  ' };
    const task = new Task(taskData);
    await task.save();
    expect(task.title).toBe('Titre avec espaces');
    expect(task.description).toBe('Desc espaces');
  });

});

Ces tests vérifient que notre modèle Mongoose se comporte comme attendu, indépendamment des routes Express.

Ecriture des tests d'intégration : Valider les endpoints API

Les tests d'intégration vérifient que les différentes parties de l'application fonctionnent ensemble. Nous allons utiliser SuperTest pour envoyer des requêtes HTTP à notre application Express et vérifier les réponses.

D'abord, exportons notre application Express depuis `server.js` pour pouvoir l'importer dans nos tests, mais sans la démarrer avec `app.listen()` directement dans ce fichier si elle est importée. Modifions légèrement `src/server.js` :

// src/server.js (modifications)
// ... (imports et configuration initiale)

const app = express();
const port = process.env.PORT || 3000;

connectDB();
app.use(express.json());
app.use(taskRouter);
// ... (gestion 404 et erreurs)

// Démarrer le serveur seulement si ce fichier est exécuté directement
if (require.main === module) {
    app.listen(port, () => {
        console.log(`Serveur démarré et écoute sur le port ${port}`);
    });
}

module.exports = app; // Exporter l'application pour les tests

Créons maintenant un fichier de test pour nos routes, par exemple `tests/routes/tasks.test.js` :

// tests/routes/tasks.test.js
const request = require('supertest');
const app = require('../../src/server'); // Importer notre app Express
const Task = require('../../src/models/task');

describe('Task API Endpoints', () => {

  // Variable pour stocker une tâche créée pour les tests
  let testTask;

  beforeEach(async () => {
    // Créer une tâche de base avant certains tests
    testTask = await new Task({ title: 'Tâche de test initiale' }).save();
  });

  // Test pour POST /tasks
  it('devrait créer une nouvelle tâche', async () => {
    const newTaskData = { title: 'Nouvelle tâche API', description: 'Description via API' };
    const response = await request(app)
      .post('/tasks')
      .send(newTaskData)
      .expect(201); // Vérifier le code de statut

    // Vérifier la réponse
    expect(response.body._id).toBeDefined();
    expect(response.body.title).toBe(newTaskData.title);
    expect(response.body.completed).toBe(false);

    // Vérifier que la tâche a bien été sauvegardée en base
    const taskInDb = await Task.findById(response.body._id);
    expect(taskInDb).not.toBeNull();
    expect(taskInDb.title).toBe(newTaskData.title);
  });

  it('ne devrait pas créer une tâche sans titre', async () => {
    await request(app)
      .post('/tasks')
      .send({ description: 'Tâche invalide' })
      .expect(400); // Vérifier le code Bad Request
  });

  // Test pour GET /tasks
  it('devrait récupérer toutes les tâches', async () => {
    const response = await request(app)
      .get('/tasks')
      .expect(200);
    
    expect(response.body).toBeInstanceOf(Array);
    // Au moins la tâche créée dans beforeEach doit exister
    expect(response.body.length).toBeGreaterThanOrEqual(1);
    expect(response.body.some(task => task.title === 'Tâche de test initiale')).toBe(true);
  });

  // Test pour GET /tasks/:id
  it('devrait récupérer une tâche spécifique par son ID', async () => {
    const response = await request(app)
      .get(`/tasks/${testTask._id}`)
      .expect(200);

    expect(response.body._id).toBe(testTask._id.toString());
    expect(response.body.title).toBe(testTask.title);
  });

  it('devrait retourner 404 pour un ID de tâche inexistant', async () => {
    const nonExistentId = new mongoose.Types.ObjectId(); // Génère un ID valide mais non existant
    await request(app)
      .get(`/tasks/${nonExistentId}`)
      .expect(404);
  });

  // --- Ajoutez ici les tests pour PATCH /tasks/:id et DELETE /tasks/:id --- 
  // Test pour PATCH /tasks/:id
  it('devrait mettre à jour une tâche existante', async () => {
    const updates = { completed: true, description: 'Mise à jour via test' };
    const response = await request(app)
        .patch(`/tasks/${testTask._id}`)
        .send(updates)
        .expect(200);

    expect(response.body.completed).toBe(true);
    expect(response.body.description).toBe(updates.description);

    // Vérifier en base
    const updatedTaskInDb = await Task.findById(testTask._id);
    expect(updatedTaskInDb.completed).toBe(true);
    expect(updatedTaskInDb.description).toBe(updates.description);
  });

  // Test pour DELETE /tasks/:id
  it('devrait supprimer une tâche existante', async () => {
    await request(app)
        .delete(`/tasks/${testTask._id}`)
        .expect(204);

    // Vérifier qu'elle n'est plus en base
    const taskInDb = await Task.findById(testTask._id);
    expect(taskInDb).toBeNull();
  });
});

// N'oubliez pas mongoose si vous utilisez ObjectId
const mongoose = require('mongoose');

Ces tests envoient de vraies requêtes HTTP à votre application Express (sans passer par le réseau réel grâce à SuperTest) et vérifient les codes de statut, les corps de réponse et parfois l'état de la base de données de test.

Exécuter les tests et aller plus loin

Vous pouvez maintenant lancer votre suite de tests complète avec la commande que nous avons configurée :

npm test

Jest exécutera tous les fichiers `*.test.js` trouvés, en utilisant la configuration de la base de données en mémoire définie dans `tests/setup.js`. Vous verrez les résultats s'afficher dans votre terminal.

Il est recommandé d'exécuter ces tests fréquemment pendant le développement, et surtout avant chaque commit, pour s'assurer que vous n'introduisez pas de régressions. Cette suite de tests deviendra également une étape essentielle de notre futur pipeline CI/CD, garantissant que seul du code testé et fonctionnel est déployé.

Pour aller plus loin, vous pourriez :

  • Ajouter des tests pour les cas d'erreur (ID invalides, données de mise à jour incorrectes).
  • Tester la pagination et le filtrage si vous implémentez ces fonctionnalités sur le `GET /tasks`.
  • Explorer les tests d'authentification si vous ajoutez une couche de sécurité à votre API.
  • Mesurer et viser une bonne couverture de code pour identifier les parties non testées de votre application.

Avec une suite de tests unitaires et d'intégration en place, notre API RESTful est non seulement fonctionnelle mais aussi beaucoup plus fiable et prête pour les prochaines étapes : la documentation et le déploiement.