Jest simplifient la création et la gestion des tests en fournissant des fonctionnalités comme l’exécution isolée pour chaque fichier. Cependant, l’intégration de MongoDB via une base de données en mémoire (par exemple, @shelf/jest-mongodb ou mongodb-memory-server) peut poser des problèmes d’isolation des tests.
29 Nov 2024
Les tests sont une étape cruciale du développement logiciel moderne, garantissant que le code se comporte comme prévu dans diverses situations. Des outils comme Jest simplifient la création et la gestion des tests en fournissant des fonctionnalités comme l’exécution isolée pour chaque fichier. Cependant, l’intégration de MongoDB via une base de données en mémoire (par exemple, @shelf/jest-mongodb ou mongodb-memory-server) peut poser des problèmes d’isolation des tests.
Les tests unitaires sont essentiels pour garantir la qualité et la fiabilité d'une application. Des outils comme Jest simplifient grandement ce processus en offrant des fonctionnalités comme l'exécution isolée de chaque test. Cependant, lorsqu'on intègre une base de données comme MongoDB dans nos tests, des challenges spécifiques émergent, notamment en ce qui concerne l'isolation des tests.
Par défaut, les bibliothèques comme @shelf/jest-mongodb et mongodb-memory-server initialisent une seule instance de MongoDB en mémoire pour tous les tests. Bien que cela puisse accélérer les exécutions, cela entraîne un partage de l'état de la base de données entre les différents tests.
Pourquoi ?
Base de données unique: Tous les tests utilisent la même base de données, ce qui signifie que les modifications effectuées par un test peuvent affecter les résultats des tests suivants.
Limites de Jest: Bien que Jest exécute chaque fichier de test dans un processus séparé, la base de données MongoDB reste un point d'ancrage commun à tous ces processus.
Conséquences
Résultats non reproductibles: L'ordre d'exécution des tests peut influencer les résultats, rendant les tests moins fiables.
Difficultés de débogage: Il devient plus compliqué d'isoler les causes d'échecs, car les problèmes peuvent être liés à des interactions inattendues entre les tests.
Violation des principes fondamentaux des tests unitaires: L'isolation des tests est un principe clé qui est compromis lorsque les tests partagent un état.
Les bases de données SQL offrent souvent des mécanismes de transaction qui permettent d'annuler les modifications apportées à la base de données à la fin d'un test. MongoDB, en revanche, ne supporte pas nativement les transactions dans toutes les configurations, ce qui rend plus difficile de garantir l'isolation des tests.
yarn add --dev @shelf/jest-mongodb jest ts-jest typescript
// jest.config.js
module.exports = {
preset: '@shelf/jest-mongodb',
testEnvironment: 'node',
testMatch: ['**/__tests__/?(*.)+(spec|test).ts'],
moduleFileExtensions: ['ts'],
watchPathIgnorePatterns: ['globalConfig'],
testPathIgnorePatterns: ['/node_modules/'],
transform: {
'^.+\\.(ts)$': 'ts-jest',
}
};
Pour exécuter lest tests, il faut ajouter un script test
dans package.json
.
"scripts": {
"test": "jest",
},
Plutôt que de configurer manuellement les serveurs et bases de données dans chaque fichier de test, on peut automatiser ce processus avec un utilitaire centralisé et une configuration globale dans Jest.
Jest permet de définir des hooks globaux via des fichiers de configuration, comme beforeAll
et afterAll
, qui seront automatiquement exécutés pour chaque fichier de test.
Avant chaque fichier de test (beforeAll
)
Initialiser une nouvelle instance de serveur Express.
Créer une base de données MongoDB unique pour le fichier.
Attacher cette base de données au serveur pour que chaque test dans le fichier y accède facilement.
Après chaque fichier de test (afterAll
)
Supprimer la base de données créée pour le fichier.
Arrêter le serveur Express.
Libérer les ressources associées (fermeture des connexions MongoDB, arrêt des instances en mémoire).
Créer un fichier jest.setup.ts
dans le dossier __tests__/
// __tests__/jest.setup.ts
import { MongoClient } from 'mongodb';
import crypto from 'crypto';
import { deleteTestDatabases, generateRandomPort } from './utils';
let connection: MongoClient;
beforeAll(() => {
// appelé avant chaque fichier de test
});
afterAll(() => {
// appelé après chaque fichier de test
});
Puis ajouter cette ligne dans jest.config.js
setupFilesAfterEnv: ['./__tests__/jest.setup.ts']
Une base de données unique pour chaque test
Pour résoudre ce problème, il est essentiel que chaque fichier de test utilise sa propre base de données. Cette solution peut être mise en œuvre en :
Pour assurer l'intégrité des tests, on réinitialise la base de données à un état connu avant chaque test. Cela garantit que chaque cas de test s'exécute dans un environnement propre, sans être influencé par les tests précédents.
Pour cela il est essentiel que chaque fichier de test utilise sa propre base de données
// __tests__/jest.setup.ts
import { MongoClient } from 'mongodb';
import crypto from 'crypto';
import { deleteTestDatabases, generateRandomPort } from './utils';
let connection: MongoClient;
beforeAll(async () => {
const port = generateRandomPort();
const randomText = crypto.randomUUID();
const mongoUrl = process.env.MONGO_URL + 'test_' + randomText;
connection = await MongoClient.connect(mongoUrl, {});
});
afterAll(async () => {
await deleteTestDatabases(connection);
});
Explication
beforeAll : Avant chaque série de tests, un nom de base de données unique est généré.
afterAll : À la fin de chaque série de tests, la base de données créée est supprimée.
Fonction de nettoyage : Une fonction est mise en place pour nettoyer l'environnement en supprimant toutes les bases de données créées pour les tests.
// __tests__/utils.ts
import { ListDatabasesResult, MongoClient } from 'mongodb';
export const deleteTestDatabases = async (connection: MongoClient) => {
try {
const databaseNames = await connection.db().admin().listDatabases();
const testDatabaseNames = databaseNames.databases
.filter((db: ListDatabasesResult['databases'][0]) => db.name.startsWith('test_'));
for (const database of testDatabaseNames) {
await connection.db(database.name).dropDatabase();
console.log(`Deleted database: ${database.name}`);
}
}
catch (error) {
console.error('Error deleting test databases:', error);
}
finally {
await connection.close();
}
};
Ici on supprime les base de données dont les noms commencent par test_
Chaque fichier de test doit initialiser son propre serveur et sa propre base de données
Installer Express et Parse Server
yarn add express parse-server
Modifier jest.setup
.js
// __tests__/jest.setup.ts
import http from 'http';
import { MongoClient } from 'mongodb';
import { ParseServer } from 'parse-server';
import express from 'express';
import crypto from 'crypto';
import { deleteTestDatabases, generateRandomPort } from './utils';
let parseServer;
let server: http.Server;
let connection: MongoClient;
beforeAll(async () => {
const port = generateRandomPort();
const randomText = crypto.randomUUID();
const appId = 'testAppId' + randomText;
const serverURL = `http://localhost:${port}/parse`;
const mongoUrl = process.env.MONGO_URL + 'test_' + randomText;
// Connect to MongoDB
connection = await MongoClient.connect(mongoUrl, {});
// Configure Parse Server with MongoDB URI
parseServer = new ParseServer({
databaseURI: mongoUrl,
appId,
serverURL,
});
// Start the Parse Server
await parseServer.start();
const app = express();
// Mount the Parse Server on the Express app
app.use('/parse', parseServer.app);
// Start the Express server
server = app.listen(port, undefined);
});
afterAll(async () => {
await parseServer.handleShutdown();
server.close();
await deleteTestDatabases(connection);
});
Créer 2 cas de tests indépendants.
// __tests__/article.spec.ts
import { createArticleByAuthor, createUser, getUser } from "./mock";
describe('Article test', () => {
let Article: Parse.ObjectConstructor;
let author: Parse.User;
beforeAll(async () => {
Article = Parse.Object.extend('Article');
author = await createUser()
});
it('should user in author test case not defined', async () => {
const user = await getUser('user');
expect(user).toBeUndefined();
});
it('should create article', async () => {
const article = await createArticleByAuthor(author);
expect(article).toBeDefined();
});
it('should author not defined', async () => {
try {
await createArticleByAuthor(undefined);
} catch (error) {
expect((error as Error).message).toBe('Author is required');
}
});
});
// __tests__/author.spec.ts
import { createUser, deleteUser, getUser } from "./mock";
describe('Author test', () => {
it('should user in article test case not defined', async () => {
const user = await getUser('john');
expect(user).toBeUndefined();
});
it('should create user', async () => {
const user = await createUser();
expect(user).toBeDefined();
});
it('should get user', async () => {
const user = await getUser('john');
expect(user).toBeDefined();
});
it('should get user not exist', async () => {
const user = await getUser('john2');
expect(user).toBeUndefined();
});
it('should delete author', async () => {
await deleteUser('john');
const user = await getUser('john');
expect(user).toBeUndefined();
});
});
La partie la plus importante de ces deux fichiers est :
it('should user in author test case not defined', async () => {
const user = await getUser('user');
expect(user).toBeUndefined();
});
it('should user in author test case not defined', async () => {
const user = await getUser('user');
expect(user).toBeUndefined();
})
Coût en termes de performance: Les réinitialisations fréquentes de la base de données peuvent engendrer un ralentissement significatif des tests, en particulier pour les bases de données volumineuses ou complexes.
Intégrité des données: Maintenir l'intégrité et la cohérence des données après chaque réinitialisation est un défi majeur, car toute anomalie peut compromettre la fiabilité des résultats.
Consommation de ressources: Cette stratégie peut être gourmande en ressources, notamment en termes de calcul, ce qui limite son utilisation dans certains environnements.
Complexité de mise en œuvre: La configuration et la maintenance d'un système de réinitialisation peuvent être complexes, surtout pour des applications avec des interdépendances importantes.
Environnement de test propre: La réinitialisation permet de commencer chaque test dans un environnement vierge, sans aucune donnée résiduelle des tests précédents.
Isolation des tests: Cette méthode est particulièrement adaptée aux scénarios de test complexes où les interactions entre les données peuvent être difficiles à isoler.
Flexibilité: La réinitialisation de la base de données offre une grande flexibilité et s'adapte à une variété de scénarios de test.
Fiabilité des résultats: En garantissant un point de départ identique pour chaque test, cette stratégie améliore considérablement la fiabilité et la reproductibilité des résultats.
Mettre en place des tests unitaires rigoureux pour votre application Node.js Express qui utilise MongoDB peut sembler complexe. Pourtant, en suivant les étapes détaillées de ce guide, vous pourrez rapidement créer des tests isolés et efficaces. Pour une démonstration concrète, n'hésitez pas à consulter le code source complet disponible sur mon dépôt GitHub:
https://github.com/tiavina-mika/blog/tree/main/jest-mongodb-express-parse-server
Tags:
Abonnez-vous à ma newsletter pour pouvoir suivre et récevoir des offres spéciales et les articles / tutos que je publie occasionnellement sur mon blog
* Vous pouvez se désabonner à tout moment en cliquant sur le lien de désabonnement contenu dans chacun de nos mails.