Chapitre 830 minIntermédiaire
Tests d'intégration et tests d'API avec Supertest
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- séparer une application Express de son démarrage pour la tester ;
- vérifier le code de statut, le corps et l'effet d'une requête ;
- prouver un contrôle d'accès par des tests 401 et 403 ;
- préparer des jeux de données de test fictifs et isolés ;
- tester un dépôt contre une vraie base MySQL de test.
4 exercices corrigés1 schéma
Un test d'intégration vérifie que plusieurs composants fonctionnent correctement ensemble : pour une API, la route, ses middlewares d'authentification et de validation, la règle métier et l'accès aux données. Avec Supertest, un test envoie une vraie requête HTTP à l'application Express en mémoire, sans démarrer de serveur, puis vérifie le code de statut, les en-têtes, le corps de la réponse et l'effet sur les données. Ce chapitre teste l'API de Prêt'Matos, prépare des jeux de données de test, puis branche une vraie base MySQL de test.
Ce que les tests unitaires ne voient pas
Les règles de regles.js ont leurs tests unitaires, tous verts. Pourtant, ces défauts peuvent encore exister :
| Défaut | Pourquoi les tests unitaires ne le voient pas |
|---|---|
La route POST /api/reservations oublie le middleware connecte : n'importe qui réserve sans être connecté | La règle métier ne connaît pas l'authentification |
| La route renvoie 400 au lieu de 409 pour un chevauchement | Le code de statut est choisi dans la route, pas dans la règle |
express.json() n'est pas déclaré : req.body vaut undefined | C'est la configuration de l'application |
La route passe aujourdhui au lieu de aujourdhui() à la règle | Chaque morceau est correct ; c'est leur branchement qui est faux |
Le dépôt MySQL renvoie les dates en objets Date, la règle attend des chaînes | Les deux ont été testés séparément, avec des données différentes |
Les tests d'intégration vérifient les branchements. Ils sont un peu plus lents que les tests unitaires, mais restent assez rapides pour tourner à chaque modification.
Préparer l'application : la séparer de son démarrage
Pour tester une application Express, il faut pouvoir la créer sans la démarrer. On sépare donc la fabrication de l'application (app.js) de son lancement en production (server.js) :
// src/app.js : l'application Express, sans app.listen
import express from 'express';
import { verifierReservation } from './regles.js';
const DATE_ISO = /^\d{4}-\d{2}-\d{2}$/;
export function creerApp({ depot, authentifier, aujourdhui }) {
const app = express();
app.use(express.json());
// Qui appelle ? Le jeton est vérifié par la fonction authentifier, fournie au démarrage.
async function connecte(req, res, next) {
const [type, jeton] = (req.get('Authorization') ?? '').split(' ');
const utilisateur = type === 'Bearer' ? await authentifier(jeton) : null;
if (!utilisateur) return res.status(401).json({ erreur: 'Authentification requise.' });
req.utilisateur = utilisateur;
next();
}
function role(attendu) {
return (req, res, next) =>
req.utilisateur.role === attendu ? next() : res.status(403).json({ erreur: 'Accès refusé.' });
}
app.get('/api/materiels', connecte, async (req, res) => {
res.json(await depot.listerMateriels());
});
app.post('/api/reservations', connecte, async (req, res) => {
const { materiel, debut, fin } = req.body ?? {};
if (typeof materiel !== 'string' || !DATE_ISO.test(debut ?? '') || !DATE_ISO.test(fin ?? '')) {
return res.status(400).json({ erreur: 'Champs attendus : materiel, debut et fin (AAAA-MM-JJ).' });
}
if (!(await depot.materielExiste(materiel))) {
return res.status(404).json({ erreur: `Matériel ${materiel} inconnu.` });
}
const demande = { materiel, debut, fin };
const verification = verifierReservation(demande, await depot.reservationsDu(materiel), aujourdhui());
if (!verification.acceptee) {
const statut = verification.motif.includes('déjà réservé') ? 409 : 400;
return res.status(statut).json({ erreur: verification.motif });
}
const reservation = await depot.ajouterReservation({ ...demande, salarie: req.utilisateur.id });
res.status(201).location(`/api/reservations/${reservation.id}`).json(reservation);
});
app.get('/api/reservations/mes', connecte, async (req, res) => {
res.json(await depot.reservationsDe(req.utilisateur.id));
});
app.post('/api/reservations/:id/sortie', connecte, role('logistique'), async (req, res) => {
const reservation = await depot.enregistrerSortie(Number(req.params.id));
if (!reservation) return res.status(404).json({ erreur: 'Réservation inconnue.' });
res.json(reservation);
});
return app;
}
creerApp est une fabrique : elle reçoit ses trois dépendances et renvoie une application prête. Le dépôt (MySQL ou en mémoire), la vérification du jeton et la date du jour viennent de l'extérieur. Avec Express 5, une fonction de route async qui lève une erreur la transmet automatiquement au gestionnaire d'erreurs : plus besoin d'envelopper chaque route dans un try … catch. Les codes de statut suivent les conventions du cours API REST : 201 pour une création, 400 pour une requête invalide, 401 sans authentification, 403 sans le droit, 404 pour une ressource absente, 409 pour un conflit avec l'état actuel.
En production, server.js branche les vraies dépendances et démarre le serveur :
// src/server.js : le démarrage en production (non testé : il ne contient aucune logique)
import mysql from 'mysql2/promise';
import { creerApp } from './app.js';
import { creerDepotMysql } from './depot-mysql.js';
import { verifierJeton } from './jetons.js';
import { aujourdhuiAParis } from './dates.js';
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
});
const app = creerApp({ depot: creerDepotMysql(pool), authentifier: verifierJeton, aujourdhui: aujourdhuiAParis });
app.listen(Number(process.env.PORT ?? 3000));
verifierJeton vérifie la signature d'un jeton JWT et renvoie l'utilisateur qu'il contient, ou null ; le cours Authentification explique son fonctionnement. Dans les tests, on la remplace par une fonction qui reconnaît deux jetons fictifs : on teste ainsi les droits de l'API sans fabriquer de vrais jetons.
Supertest : interroger l'API en mémoire
npm install --save-dev supertest
request(app) démarre l'application sur un port temporaire, le temps de la requête, puis l'arrête. Les méthodes s'enchaînent comme une phrase :
const reponse = await request(app)
.post('/api/reservations') // la méthode et l'URL
.set('Authorization', 'Bearer jeton-julie') // un en-tête
.send({ materiel: 'VP-02', debut: '2027-03-15', fin: '2027-03-18' }); // le corps, en JSON
reponse.status; // 201
reponse.headers.location; // '/api/reservations/2'
reponse.body; // l'objet JSON renvoyé
La suite de tests de l'API
// src/api.test.js : tests d'intégration de l'API avec Supertest
import { describe, test, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { creerApp } from './app.js';
import { creerDepotMemoire } from './depot-memoire.js';
import { AUJOURDHUI, UTILISATEURS, jeuDeTest, uneDemande } from './jeu-de-test.js';
let app;
let depot;
beforeEach(() => {
depot = creerDepotMemoire(jeuDeTest());
app = creerApp({
depot,
authentifier: async (jeton) => UTILISATEURS[jeton] ?? null,
aujourdhui: () => AUJOURDHUI,
});
});
describe('POST /api/reservations', () => {
test('crée la réservation et renvoie 201', async () => {
const reponse = await request(app)
.post('/api/reservations')
.set('Authorization', 'Bearer jeton-julie')
.send(uneDemande());
expect(reponse.status).toBe(201);
expect(reponse.headers.location).toBe('/api/reservations/2');
expect(reponse.body).toMatchObject({ id: 2, materiel: 'VP-02', salarie: 3 });
});
test('enregistre la réservation dans « mes réservations »', async () => {
await request(app).post('/api/reservations').set('Authorization', 'Bearer jeton-julie').send(uneDemande());
const reponse = await request(app).get('/api/reservations/mes').set('Authorization', 'Bearer jeton-julie');
expect(reponse.body).toHaveLength(1);
expect(reponse.body[0].materiel).toBe('VP-02');
});
test('renvoie 409 quand le matériel est déjà réservé', async () => {
const reponse = await request(app)
.post('/api/reservations')
.set('Authorization', 'Bearer jeton-julie')
.send(uneDemande({ materiel: 'PC-07', debut: '2027-03-12' }));
expect(reponse.status).toBe(409);
expect(reponse.body.erreur).toBe('PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.');
});
test('renvoie 400 quand une date est mal formée', async () => {
const reponse = await request(app)
.post('/api/reservations')
.set('Authorization', 'Bearer jeton-julie')
.send(uneDemande({ fin: '18/03/2027' }));
expect(reponse.status).toBe(400);
});
test('renvoie 404 pour un matériel inconnu', async () => {
const reponse = await request(app)
.post('/api/reservations')
.set('Authorization', 'Bearer jeton-julie')
.send(uneDemande({ materiel: 'XX-99' }));
expect(reponse.status).toBe(404);
});
test('renvoie 401 sans jeton', async () => {
const reponse = await request(app).post('/api/reservations').send(uneDemande());
expect(reponse.status).toBe(401);
});
});
describe('POST /api/reservations/:id/sortie', () => {
test('est réservé à la logistique : 403 pour un salarié', async () => {
const reponse = await request(app).post('/api/reservations/1/sortie').set('Authorization', 'Bearer jeton-julie');
expect(reponse.status).toBe(403);
expect((await depot.reservationsDu('PC-07'))[0].sortie).toBe(false);
});
test('enregistre la sortie pour la logistique', async () => {
const reponse = await request(app).post('/api/reservations/1/sortie').set('Authorization', 'Bearer jeton-nadia');
expect(reponse.status).toBe(200);
expect(reponse.body.sortie).toBe(true);
});
});
$ npx vitest run src/api.test.js
✓ src/api.test.js (8 tests) 32ms
Test Files 1 passed (1)
Tests 8 passed (8)
Quatre habitudes sont à retenir de ce fichier :
- Une application neuve par test. Le
beforeEachrecrée le dépôt et l'application : la réservation créée par le premier test n'existe plus pour le deuxième. C'est pour cela que chaque test peut attendre l'identifiant 2. - Le statut, le corps, puis l'effet. Le test 403 ne s'arrête pas au code de statut : il relit le dépôt pour vérifier que la sortie n'a pas été enregistrée. Une route qui renverrait 403 après avoir modifié la base passerait sinon le test.
- Les cas d'erreur sont la majorité. Sur huit tests, trois vérifient un cas nominal ; les cinq autres vérifient un refus. Une API se juge à ce qu'elle refuse.
- Les tests de droits sont des tests de sécurité. 401 sans jeton, 403 avec le mauvais rôle : ce sont les tests qui prouvent le contrôle d'accès, la première catégorie de risques du classement de l'OWASP (contrôle d'accès).
Les jeux de données de test
Un jeu de données de test est l'ensemble des données fictives que les tests utilisent : salariés, matériels, réservations, comptes. Il doit être petit (on le comprend d'un coup d'œil), représentatif (il contient les cas utiles : une réservation existante, deux rôles), connu (chaque test sait ce qu'il y a dedans) et recréé avant chaque test.
// src/jeu-de-test.js : les données fictives des tests d'intégration
export const AUJOURDHUI = '2027-03-08';
export const UTILISATEURS = {
'jeton-julie': { id: 3, prenom: 'Julie', role: 'salarie' },
'jeton-nadia': { id: 1, prenom: 'Nadia', role: 'logistique' },
};
export function jeuDeTest() {
return {
materiels: [
{ code: 'PC-07', libelle: 'Portable 14 pouces' },
{ code: 'VP-02', libelle: 'Vidéoprojecteur' },
],
reservations: [
{ id: 1, materiel: 'PC-07', debut: '2027-03-10', fin: '2027-03-14', salarie: 5, sortie: false },
],
};
}
// Une fabrique : une demande valide par défaut, que chaque test modifie au besoin.
export function uneDemande(modifications = {}) {
return { materiel: 'VP-02', debut: '2027-03-15', fin: '2027-03-18', ...modifications };
}
Trois techniques s'y combinent :
| Technique | Principe | Dans Prêt'Matos |
|---|---|---|
| Fixture (jeu fixe) | Un état de départ connu, chargé avant chaque test | jeuDeTest() : deux matériels, une réservation de PC-07 |
| Fabrique (factory) | Une fonction qui crée un objet valide par défaut ; le test ne précise que ce qui compte pour lui | uneDemande({ materiel: 'PC-07', debut: '2027-03-12' }) : on lit immédiatement ce que le test change |
| Nettoyage | Remettre l'état de départ avant chaque test | Le beforeEach recrée le dépôt ; avec une base, on vide les tables ou on annule une transaction |
jeuDeTest() est une fonction qui renvoie un nouvel objet à chaque appel, et le dépôt copie les réservations qu'il reçoit : aucun test ne peut modifier les données d'un autre par mégarde.
Pour la recette et l'épreuve E6, prépare aussi un compte de test par rôle (salarié, logistique), avec des mots de passe faits pour être lus, notés dans le README, et des données dans tous les états utiles : une réservation à venir, une en cours, une en retard, une rendue. La commission doit pouvoir essayer chaque cas sans rien créer à la main (la recette de l'E6).
Tester avec une vraie base MySQL
Le dépôt en mémoire est un fake : il ne prouve rien sur le SQL. Le dépôt MySQL a donc ses propres tests d'intégration, contre une base de test dédiée, jamais la base de production :
// src/depot-mysql.js : le dépôt de production, sur MySQL (extrait)
export function creerDepotMysql(pool) {
return {
async reservationsDu(code) {
const [lignes] = await pool.execute(
`SELECT id, materiel, DATE_FORMAT(debut, '%Y-%m-%d') AS debut,
DATE_FORMAT(fin, '%Y-%m-%d') AS fin, salarie, sortie = 1 AS sortie
FROM reservation WHERE materiel = ? ORDER BY debut`,
[code],
);
return lignes.map((l) => ({ ...l, sortie: Boolean(l.sortie) }));
},
async ajouterReservation({ materiel, debut, fin, salarie }) {
const [resultat] = await pool.execute(
'INSERT INTO reservation (materiel, debut, fin, salarie) VALUES (?, ?, ?, ?)',
[materiel, debut, fin, salarie],
);
return { id: resultat.insertId, materiel, debut, fin, salarie, sortie: false };
},
// listerMateriels, materielExiste, reservationsDe et enregistrerSortie suivent le même modèle.
};
}
Le DATE_FORMAT n'est pas un détail : sans lui, la bibliothèque mysql2 renvoie les colonnes DATE sous forme d'objets Date JavaScript, et la règle verifierReservation, qui attend des chaînes AAAA-MM-JJ, planterait. C'est exactement le genre de défaut d'intégration que seul un test contre la vraie base attrape.
// src/depot-mysql.test.js : test d'intégration avec une vraie base MySQL de test
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import mysql from 'mysql2/promise';
import { creerDepotMysql } from './depot-mysql.js';
// Sans base de test configurée (DB_HOST), ces tests sont sautés au lieu d'échouer.
describe.skipIf(!process.env.DB_HOST)('dépôt MySQL', () => {
let pool;
let depot;
beforeAll(() => {
pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, // pret_matos_test, jamais la base de production
});
depot = creerDepotMysql(pool);
});
beforeEach(async () => {
await pool.query('DELETE FROM reservation');
await pool.query(
"INSERT INTO reservation (id, materiel, debut, fin, salarie) VALUES (1, 'PC-07', '2027-03-10', '2027-03-14', 5)",
);
});
afterAll(async () => {
await pool.end();
});
test('relit une réservation avec des dates au format AAAA-MM-JJ', async () => {
const reservations = await depot.reservationsDu('PC-07');
expect(reservations).toEqual([
{ id: 1, materiel: 'PC-07', debut: '2027-03-10', fin: '2027-03-14', salarie: 5, sortie: false },
]);
});
test('ajoute une réservation et renvoie son identifiant', async () => {
const ajoutee = await depot.ajouterReservation({ materiel: 'PC-07', debut: '2027-03-20', fin: '2027-03-22', salarie: 3 });
expect(ajoutee.id).toBeGreaterThan(1);
expect(await depot.reservationsDu('PC-07')).toHaveLength(2);
});
});
beforeAllouvre une connexion (un pool) pour tout le bloc ;afterAllla ferme, sans quoi Vitest attendrait indéfiniment la fin du processus.beforeEachremet la table dans un état connu : on vide, puis on insère la fixture.describe.skipIfsaute le bloc quand aucune base n'est configurée : sur le poste d'un collègue sans MySQL, la suite reste verte et signale « 2 skipped ».- Le test vérifie
toBeGreaterThan(1)et nontoBe(2): après unDELETE, MySQL ne remet pas le compteurAUTO_INCREMENTà zéro, et l'identifiant suivant dépend des tests précédents. Un test ne doit pas dépendre d'un détail que le SGBD ne garantit pas.
Pour obtenir une base de test sur ton poste, un conteneur jetable suffit :
# Une base MySQL 8.4 de test, sur le port 3307 pour ne pas gêner une autre installation
docker run --name pret-matos-test -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pret_matos_test \
-p 3307:3306 -d mysql:8.4
# Créer les tables à partir du script versionné
docker exec -i pret-matos-test sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" pret_matos_test' < sql/schema.sql
# Lancer les tests avec cette base
DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=root DB_PASSWORD=test DB_NAME=pret_matos_test npm test
Le script sql/schema.sql est le même qu'en production : la base de test a la même structure, avec les mêmes contraintes. Dans la CI, la même base est démarrée comme service à côté du job ; le cours Intégration continue montre la configuration complète (tester avec une vraie base de données). La bibliothèque Testcontainers va plus loin : le test démarre lui-même son conteneur MySQL, et l'arrête à la fin.
Tester l'API à la main ou en collection
Les tests Supertest sont écrits par les développeurs, dans le code. Pour explorer une API, la montrer ou la faire vérifier par quelqu'un qui ne programme pas, on utilise un client HTTP : curl en ligne de commande, ou un client graphique comme Bruno ou Postman. Le cours API REST les présente en détail (tester une API).
Ces clients savent aussi exécuter des assertions sur les réponses et rejouer une collection entière de requêtes. Bruno range chaque requête dans un fichier texte, que l'on versionne avec Git à côté du code, et sa ligne de commande (npm install --global @usebruno/cli, puis bru run --env recette dans le dossier de la collection) rejoue la collection dans un terminal ou dans la CI. Postman propose l'équivalent avec son outil en ligne de commande. C'est une bonne solution pour des tests de recette de l'API déroulés par l'équipe, en complément des tests automatisés du code, pas à leur place.
En C# et en PHP
Le principe est le même dans les autres langages : démarrer l'application en mémoire, lui envoyer une requête, vérifier la réponse et l'effet.
| Langage | Outil | Principe |
|---|---|---|
| C# (ASP.NET Core) | WebApplicationFactory<Program> du paquet Microsoft.AspNetCore.Mvc.Testing | Démarre l'API en mémoire et fournit un HttpClient ; on remplace les services (dépôt, horloge) dans le conteneur d'injection de dépendances |
| PHP sans framework | Le serveur intégré php -S localhost:8000 -t public, lancé avant les tests, et un client HTTP (curl en PHP, ou la bibliothèque Guzzle) dans les tests PHPUnit | L'API tourne réellement ; les tests l'appellent par HTTP et vérifient les réponses |
| Node.js | Supertest, ou fetch vers un serveur démarré sur un port de test | Celui de ce chapitre |
Exercices
Exercice 1 sur 4 Écrire un test d'intégration Réussi À revoir
Écris avec Vitest et Supertest deux tests pour la route GET /api/reservations/mes :
- sans jeton, elle renvoie 401 ;
- Julie ne voit pas la réservation de PC-07 du jeu de données, qui appartient au salarié n° 5.
Voir la réponseMasquer la réponse de l'exercice 1
describe('GET /api/reservations/mes', () => {
test('renvoie 401 sans jeton', async () => {
const reponse = await request(app).get('/api/reservations/mes');
expect(reponse.status).toBe(401);
});
test("ne montre pas les réservations d'un autre salarié", async () => {
const reponse = await request(app).get('/api/reservations/mes').set('Authorization', 'Bearer jeton-julie');
expect(reponse.status).toBe(200);
expect(reponse.body).toEqual([]);
});
});
Le second test est un test de sécurité : il prouve que la route filtre sur l'utilisateur connecté. Il s'appuie sur le jeu de données (la seule réservation existante appartient au salarié n° 5) ; c'est pour cela qu'un jeu de données connu est indispensable.
Tu avais trouvé ?
Exercice 2 sur 4 Trouver le test manquant Réussi À revoir
Voici la liste des tests d'une route DELETE /api/reservations/:id (annuler une réservation) :
- un salarié annule sa propre réservation : 204 ;
- sans jeton : 401 ;
- réservation inexistante : 404.
Quel test de sécurité manque ? Écris-le en une phrase, avec le résultat attendu, et dis quel défaut il détecterait.
Voir la réponseMasquer la réponse de l'exercice 2
Il manque le test « un salarié tente d'annuler la réservation d'un autre salarié », avec le résultat attendu 403 (ou 404 si l'API préfère ne pas révéler que la réservation existe), et la réservation toujours présente après l'appel. Il détecte un contrôle d'accès oublié (IDOR) : sans lui, n'importe quel salarié connecté pourrait annuler les réservations de tous ses collègues en essayant des numéros. On peut aussi ajouter : « la logistique annule la réservation d'un salarié : 204 », si la règle l'autorise.
Tu avais trouvé ?
Exercice 3 sur 4 Le test qui échoue seulement avec les autres Réussi À revoir
Lancé seul, ce test passe. Lancé avec toute la suite, il échoue en recevant 409. Explique pourquoi et corrige.
const depot = creerDepotMemoire(jeuDeTest());
const app = creerApp({ depot, authentifier: async (j) => UTILISATEURS[j] ?? null, aujourdhui: () => AUJOURDHUI });
test('crée une réservation de VP-02', async () => {
const reponse = await request(app).post('/api/reservations').set('Authorization', 'Bearer jeton-julie').send(uneDemande());
expect(reponse.status).toBe(201);
});
test('crée une autre réservation de VP-02', async () => {
const reponse = await request(app).post('/api/reservations').set('Authorization', 'Bearer jeton-julie')
.send(uneDemande({ debut: '2027-03-16', fin: '2027-03-17' }));
expect(reponse.status).toBe(201);
});
Voir la réponseMasquer la réponse de l'exercice 3
Le dépôt et l'application sont créés une seule fois, au chargement du fichier, et partagés par tous les tests. Le premier test réserve VP-02 du 15 au 18 mars ; le second demande VP-02 du 16 au 17, une période incluse dans la première : l'API répond 409, à juste titre. Lancé seul, le second test trouve un dépôt vide et passe. Les tests ne sont pas indépendants.
Correction : recréer le dépôt et l'application avant chaque test.
let app;
beforeEach(() => {
const depot = creerDepotMemoire(jeuDeTest());
app = creerApp({ depot, authentifier: async (j) => UTILISATEURS[j] ?? null, aujourdhui: () => AUJOURDHUI });
});
Tu avais trouvé ?
Exercice 4 sur 4 Préparer les données de la recette Réussi À revoir
Pour la recette de Prêt'Matos, un développeur propose de copier la base de production (180 réservations réelles, 14 salariés) dans l'environnement de recette, « pour avoir des données réalistes ». Donne deux raisons de refuser et propose une alternative.
Voir la réponseMasquer la réponse de l'exercice 4
- Le RGPD : la copie expose des données personnelles (noms, adresses, historique des prêts) à des personnes et des environnements qui n'en ont pas besoin, souvent moins protégés que la production. Le principe de minimisation l'interdit sans anonymisation.
- La maîtrise des tests : avec des données réelles, on ne sait pas quels cas sont présents (y a-t-il un prêt en retard ? une réservation qui commence aujourd'hui ?), et les résultats attendus changent à chaque nouvelle copie.
Alternative : un jeu de données fictif, versionné avec le code (un script SQL ou une commande qui remplit la base), qui contient volontairement tous les cas utiles : deux rôles, un matériel libre, un réservé, un prêt en cours, un en retard, un rendu, une réservation qui commence le jour de la recette. Si de vraies données sont indispensables (pour mesurer des performances, par exemple), on les anonymise avant de les copier.
Tu avais trouvé ?