Qu’est-ce que Supertest ?
Supertest est une bibliothèque d’assertions HTTP pour Node.js. Elle prend un objet d’application — une application Express, une instance Fastify, ou un simple écouteur de requêtes http — et vous permet d’effectuer des requêtes via une API chaînable, puis de vérifier la réponse.
Elle est basée sur superagent, donc la partie requête vous semblera familière si vous avez déjà utilisé cette bibliothèque. Ce que Supertest apporte, c’est l’ergonomie du test : une application peut être passée directement à la place d’une URL, le cycle de vie du serveur est géré pour vous, et des assertions .expect() peuvent être attachées à la chaîne.
L’élément important à comprendre est que Supertest ne lance pas de navigateur et ne démarre pas votre serveur de production. Il crée un serveur HTTP éphémère dans le même processus, lié à un port temporaire sur localhost, envoie la requête, puis l’arrête une fois que la réponse est résolue. Votre test interagit avec la véritable pile HTTP — codes de statut, headers, corps de réponse, sérialisation — sans le coût ni l’instabilité d’un processus séparé.
Pourquoi tester in-process
La majeure partie des difficultés liées aux tests HTTP provient du serveur, et non de la requête. Un processus séparé nécessite un port, un temps d’attente au démarrage, un health check et une phase de nettoyage. Les ports fixes entrent en collision en CI, et une course entre listen et la première requête produit des échecs qui ressemblent à des bugs applicatifs.
Le test in-process élimine tout cela. Il n’y a pas de processus à démarrer, donc pas de vérification de disponibilité. Il n’y a pas de port fixe, donc les fichiers de tests parallèles ne se parasitent pas. Il n’y a pas de frontière réseau, donc un échec pointe vers votre handler plutôt que vers l’environnement.
Vous bénéficiez toujours de l’intégralité du pipeline de requête : le middleware, le routing, le body parsing, l’authentification et la gestion des erreurs s’exécutent exactement comme en production, car il s’agit du même code. Ce à quoi vous renoncez, ce sont les éléments que seuls un vrai navigateur ou un vrai réseau peuvent fournir : l’exécution JavaScript, un moteur de rendu et le comportement exact d’un proxy placé devant votre application.
Exportez l’application, pas le listener
Pour que Supertest puisse importer votre application, celle-ci doit être exportable. L’erreur classique consiste à définir les routes et à appeler listen dans le même module, ce qui démarre un serveur dès l’importation du fichier et vous expose à des conflits de ports lors de vos tests.
Séparez ces deux responsabilités :
// src/app.ts
import express from "express";
import { postsRouter } from "./routes/posts.js";
export const app = express();
app.use(express.json());
app.use("/posts", postsRouter);
app.use((err, req, res, next) => {
res.status(err.status ?? 500).json({ error: err.code ?? "internal_error" });
});
// src/server.ts
import { app } from "./app.js";
app.listen(3000, () => console.log("listening on http://localhost:3000"));
Désormais, src/app.ts exporte le request listener et rien d’autre. Les tests l’importent, et l’environnement de production l’importe depuis server.ts. Cette simple séparation fait toute la différence entre une API que vous pouvez tester en quelques millisecondes et une autre que vous devez démarrer complètement.
Le app d’Express est lui-même une fonction avec la signature (req, res), ce qui correspond exactement à ce qu’attend le http.createServer de Node.js. C’est pourquoi request(app) fonctionne sans aucun adaptateur. Fastify nécessite app.server ou un app.ready() attendu (awaited), tandis qu’un handler Node brut fonctionne directement.
Effectuer une requête
Une requête commence par request(app) et la méthode HTTP. Toutes les méthodes supportées par superagent sont disponibles, et la chaîne renvoie le même objet de requête, ce qui permet d’empiler les appels.
import request from "supertest";
import app from "../src/app.js";
await request(app).get("/posts");
await request(app).post("/posts").send({ title: "Hello" });
await request(app).patch("/posts/1").send({ title: "Updated" });
await request(app).delete("/posts/1");
send sérialise un objet en JSON et définit automatiquement le header Content-Type. Une chaîne de caractères est envoyée telle quelle, ce qui est utile lorsque vous testez volontairement un corps de requête malformé.
Les headers sont définis avec set, soit un par un, soit via un objet. Les paramètres de requête sont plus lisibles avec query, qui les encode et les ajoute pour vous.
await request(app)
.get("/posts")
.query({ page: 2, perPage: 10 })
.set("Accept", "application/json")
.set({ "X-Request-Id": "test-1" });
L’URL résultante est /posts?page=2&perPage=10. Si vous avez besoin d’un corps brut — XML, une chaîne simple, une charge utile délibérément corrompue — passez set("Content-Type", ...) et transmettez la chaîne à send.
Effectuer des assertions sur la réponse
La réponse est un objet classique contenant status, headers, body et text. Vous pouvez effectuer des assertions dessus via le expect de votre test runner, ou utiliser directement le .expect() de Supertest dans la chaîne.
const res = await request(app).get("/posts").expect(200);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(res.body).toHaveLength(3);
Le .expect() de Supertest est pratique car il échoue en incluant la réponse complète dans le message d’erreur, ce qui suffit généralement pour comprendre l’origine du problème sans avoir à ajouter de logs.
await request(app)
.get("/posts")
.expect("Content-Type", /json/)
.expect(200);
.expect() accepte un code de statut, un nom et une valeur d’en-tête, un corps pour une égalité profonde, ou une fonction qui reçoit la réponse et peut lever une erreur. La forme fonctionnelle est la solution de secours lorsqu’une assertion nécessite une logique particulière.
await request(app)
.get("/posts")
.expect((res) => {
if (!res.body.every((p: { id: number }) => p.id > 0)) {
throw new Error("every post must have a positive id");
}
});
Privilégiez le expect du runner pour tout ce qui dépasse la ligne de statut et le type de contenu. Cela permet d’obtenir de meilleurs diffs, supporte des matchers comme toMatchObject et arrayContaining, et maintient un style d’assertion cohérent avec le reste de votre suite de tests.
Combiner Supertest avec votre test runner
Supertest n’est pas un test runner. Il permet de construire des requêtes et d’effectuer des assertions sur les réponses, mais il ne détecte pas les tests, ne fournit pas de describe ni de it, ne mocke pas de modules et ne génère pas de rapports de résultats. Ce rôle revient à Vitest, Jest ou node:test.
Les deux se complètent parfaitement car une requête Supertest est “thenable”. Le fait de l’attendre avec await résout la réponse, ainsi un test n’est rien d’autre qu’une fonction asynchrone.
import request from "supertest";
import { beforeEach, describe, expect, it } from "vitest";
import app from "../src/app.js";
describe("POST /posts", () => {
beforeEach(async () => {
await resetDatabase();
});
it("creates a post", async () => {
const res = await request(app)
.post("/posts")
.send({ title: "Hello" })
.expect(201);
expect(res.body.title).toBe("Hello");
});
});
Si vous oubliez le await, le test réussit avant même que la requête ne soit envoyée, et l’échec apparaît plus tard sous la forme d’une “unhandled rejection”. await chaque requête, même celles dont la seule assertion est .expect().
Tester l’authentification
L’authentification n’est qu’un en-tête ou un cookie, ces deux cas sont donc faciles à tester.
Pour les jetons bearer, définissez l’en-tête Authorization. Signez un jeton avec le même secret de test que celui utilisé par l’application plutôt que d’appeler le fournisseur d’identité réel.
const token = await signTestToken({ sub: "user_1", scope: "read:posts" });
await request(app).get("/posts").expect(401);
await request(app)
.get("/posts")
.set("Authorization", `Bearer ${token}`)
.expect(200);
Pour les cookies de session, request.agent(app) conserve un “cookie jar” entre les requêtes, ce qui imite le comportement d’un navigateur après une connexion.
const agent = request.agent(app);
await agent
.post("/login")
.send({ email: "[email protected]", password: "secret" })
.expect(204);
await agent.get("/me").expect(200);
Lorsque vous disposez déjà d’une valeur de cookie, définissez-la directement. Les cookies sont transmis sous forme de tableau ou de chaîne de caractères séparée par des points-virgules.
await request(app).get("/me").set("Cookie", ["session=abc123"]).expect(200);
Testez les cas d’échec aussi rigoureusement que le cas nominal : absence de jeton, jeton expiré, jeton destiné à une autre audience et jeton valide sans le scope requis. Ces quatre tests vous protègent bien plus qu’un seul cas de succès.
Préparation et nettoyage des données
Supertest n’impose aucune contrainte concernant la base de données, ce qui signifie qu’une base de données partagée entraînera des fuites d’état entre les tests si vous ne la réinitialisez pas. Il existe trois stratégies courantes.
Réinitialiser avant chaque test. Videz les tables utilisées par la suite de tests dans beforeEach. C’est une méthode simple et prévisible, et le coût est acceptable pour les petites suites de tests.
beforeEach(async () => {
await db.query("TRUNCATE posts RESTART IDENTITY CASCADE");
await db.query("INSERT INTO posts (id, title) VALUES (1, 'Seeded')");
});
Une transaction par test. Si votre application et vos tests partagent la même connexion, enveloppez chaque test dans une transaction et annulez-la (rollback) dans afterEach. C’est rapide et cela laisse la base de données intacte, mais cela ne fonctionne que si l’application utilise le même client, ce qui n’est pas toujours le cas via HTTP.
Une base de données fraîche par fichier de test. Démarrez une base de données en mémoire ou conteneurisée pour le fichier, exécutez les migrations, puis supprimez-la à la fin. Cela offre l’isolation la plus forte et c’est l’approche privilégiée par la plupart des suites d’intégration, au prix d’un premier test plus lent.
Quel que soit votre choix, fermez le pool de connexions dans afterAll. Un pool ouvert maintient le processus Node.js actif et transforme une suite de tests réussie en un job CI qui ne s’arrête jamais.
Tester les cas d’erreur (unhappy paths)
Une route n’est pas réellement testée tant que ses échecs n’ont pas été testés. Le code de statut fait partie du contrat, vous devez donc l’affirmer explicitement.
await request(app).get("/posts/999").expect(404);
await request(app).post("/posts").send({}).expect(422);
await request(app).get("/admin").expect(403);
Une erreur de validation doit vous indiquer quel champ est en cause, et pas seulement qu’une erreur est survenue.
const res = await request(app)
.post("/posts")
.send({ title: "" })
.expect(422);
expect(res.body).toEqual({
error: "validation_error",
fields: { title: "required" },
});
Ces distinctions sont importantes. 400 correspond à une requête malformée, 401 signifie que l’utilisateur n’est pas authentifié, 403 signifie qu’il est authentifié mais n’a pas les permissions nécessaires, 404 signifie que la ressource n’existe pas, et 422 signifie que le corps a été analysé mais a échoué à la validation. Affirmer le mauvais code est un bug dans le test qui masque un bug dans l’application.
Téléchargements de fichiers et multipart
Supertest construit des requêtes multipart en utilisant attach pour les fichiers et field pour les champs de formulaire associés. Vous pouvez passer un buffer ou un chemin ; si vous passez un buffer, spécifiez un nom de fichier pour que le serveur en reçoive un qui soit cohérent.
await request(app)
.post("/users/1/avatar")
.field("caption", "Profile picture")
.attach("avatar", Buffer.from("fake-image"), "avatar.png")
.expect(201);
Testez également les rejets : un fichier manquant, un fichier dépassant la limite de taille et un type MIME non autorisé. Les uploads sont l’un des endroits les plus courants où peuvent se cacher des failles de validation.
Tester la pagination, le filtrage et le tri
Les query strings font partie du contrat de l’API, elles méritent donc d’être testées. query les rend plus lisibles, et le fait de vérifier la longueur et l’ordre du corps de la réponse permet de détecter les erreurs de type “off-by-one” ou les bugs liés aux valeurs par défaut.
test("GET /posts paginates", async () => {
await seedPosts(25);
const page1 = await request(app)
.get("/posts")
.query({ page: 1, perPage: 10 })
.expect(200);
expect(page1.body).toHaveLength(10);
expect(page1.body[0].id).toBe(1);
const page3 = await request(app)
.get("/posts")
.query({ page: 3, perPage: 10 })
.expect(200);
expect(page3.body).toHaveLength(5);
});
Testez les limites, pas seulement le milieu : la première page, la dernière page, une page au-delà de la fin, et un perPage invalide qui devrait être limité ou rejeté.
await request(app)
.get("/posts")
.query({ page: 999 })
.expect(200)
.expect((res) => {
if (res.body.length !== 0) throw new Error("expected an empty page");
});
await request(app).get("/posts").query({ perPage: 10_000 }).expect(400);
Le filtrage et le tri sont tout aussi testables, et c’est précisément là qu’un index manquant ou un ORDER BY erroné se manifeste sous forme de bug subtil.
const res = await request(app)
.get("/posts")
.query({ status: "published", sort: "-createdAt" })
.expect(200);
expect(
res.body.every((p: { status: string }) => p.status === "published"),
).toBe(true);
Redirections, cookies et autres détails HTTP
Toutes les réponses ne consistent pas en un corps JSON. Les codes de statut tels que 301, 302 et 304, ainsi que les headers tels que Location, Set-Cookie et Cache-Control, constituent souvent l’intégralité du comportement à tester.
Par défaut, supertest ne suit pas les redirections, ce qui est précisément ce que l’on souhaite lorsqu’on veut vérifier la redirection elle-même.
const res = await request(app).get("/old-posts").expect(301);
expect(res.headers.location).toBe("/posts");
Si c’est la chaîne de redirection qui importe, redirects(1) suit un saut et résout la promesse avec la réponse finale.
await request(app).get("/old-posts").redirects(1).expect(200);
Les cookies sont visibles dans set-cookie, et le “jar” de l’agent vous permet de vérifier qu’une connexion a défini les bons attributs sans avoir à décoder la valeur.
const res = await request.agent(app)
.post("/login")
.send({ email: "[email protected]", password: "secret" })
.expect(204);
const cookie = res.headers["set-cookie"][0];
expect(cookie).toContain("HttpOnly");
expect(cookie).toContain("SameSite=Lax");
Les requêtes conditionnelles, la compression et les headers de mise en cache méritent tous un test lorsque vous vous appuyez sur eux, car un proxy ou un CDN modifiera volontiers le comportement si les headers sont incorrects.
Accélérer la suite de tests
Une suite Supertest est généralement rapide, mais quelques bonnes habitudes permettent de le maintenir à mesure qu’elle s’agrandit.
Réutilisez les configurations coûteuses dans beforeAll et ne réinitialisez que les parties mutables par test. Démarrer un conteneur ou migrer un schéma une seule fois par fichier plutôt qu’une fois par test peut faire gagner plusieurs minutes sur une suite volumineuse.
Exécutez vos fichiers de tests en parallèle. Vitest et Jest le font tous deux par défaut, et comme chaque requête Supertest utilise un port éphémère, il n’y a pas de collisions à gérer. La seule chose que vous devez garantir est que les fichiers ne partagent pas de lignes dans la base de données.
Évitez les tâches inutiles dans les tests qui ne font que de la lecture. Si une route a seulement besoin d’un utilisateur et d’un post, ne seedez pas l’ensemble des fixtures. Des fixtures plus restreintes sont plus rapides à créer et plus faciles à analyser.
# run one file while iterating
pnpm exec vitest run test/posts.test.ts
# watch the file you are editing
pnpm exec vitest test/posts.test.ts
Enfin, conservez les tests unitaires pour la logique pure et laissez les tests HTTP couvrir l’intégration. Une suite qui fait passer chaque calcul par une requête complète est lente sans pour autant apporter une confiance supplémentaire.
Organiser une suite de tests
Reproduisez la structure du code source afin qu’un test en échec pointe vers un fichier facile à retrouver. Si l’application possède src/routes/posts.ts, placez test/posts.test.ts juste à côté dans l’arborescence des tests.
Regroupez la configuration partagée dans un petit nombre d’helpers :
- Un
test/app.tsqui construit l’application avec la configuration de test. - Un
test/db.tsqui migre, vide et ferme la base de données. - Un
test/factories.tscontenant des fonctions pour créer des utilisateurs, des articles et des tokens. - Un
test/tokens.tsqui signe un token avec le secret de test.
// test/factories.ts
export async function createUser(overrides: Partial<User> = {}) {
return db.user.create({
data: { email: "[email protected]", role: "member", ...overrides },
});
}
Les factories permettent de garder les tests lisibles car seule la valeur modifiée (l’override) est mise en avant. Un test qui indique createUser({ role: "admin" }) communique son intention bien mieux qu’un bloc compact de champs littéraux.
Garantir l’indépendance des tests
Chaque test doit réussir seul et quel que soit l’ordre d’exécution. C’est cette propriété qui permet à un runner de paralléliser les fichiers et qui empêche qu’un seul échec ne provoque une cascade d’une douzaine d’erreurs trompeuses.
Les ennemis de l’indépendance sont les états mutables partagés : un compteur au niveau du module, une ligne insérée qu’un autre test supprime, une horloge mockée qui n’est jamais réinitialisée, ou une base de données configurée une seule fois. Réinitialisez chaque élément dont dépend un test, et ne comptez jamais sur un test précédent pour avoir créé quelque chose.
Lorsqu’une fixture est réellement coûteuse — comme une base de données migrée ou un container en cours d’exécution — créez-la une seule fois dans beforeAll et réinitialisez les parties mutables dans beforeEach. La distinction se fait entre la configuration en lecture seule et celle qui subit des modifications.
Bonnes pratiques
- Exportez l’application depuis
app.tset gardezlisten()dansserver.ts. - Utilisez
awaitpour chaque requête ; unawaitmanquant est une erreur silencieuse. - Vérifiez le code de statut et le type de contenu avant le corps de la réponse.
- Testez les scénarios d’erreur — 400, 401, 403, 404, 422 — et pas seulement le cas nominal.
- Signez les jetons de test avec un secret de test au lieu d’appeler le fournisseur réel.
- Réinitialisez les données modifiées par chaque test et fermez le pool dans
afterAll. - Limitez chaque test à un seul comportement afin qu’un échec identifie précisément l’élément défectueux.
- Privilégiez le
expectdu runner pour les assertions sur le corps et.expect()pour la ligne de statut. - Exécutez la suite de tests avec la même pile de middleware que celle utilisée en production.
Erreurs courantes
- Appeler
listen()dans le module importé, ce qui fait qu’un port est ouvert pour chaque fichier de test. - Oublier
await, ce qui peut valider un test avant même que la requête ne soit envoyée. - Partager une ligne de base de données entre plusieurs tests et dépendre de l’ordre d’exécution.
- Tester le framework — par exemple, vérifier qu’Express analyse le JSON — au lieu de tester votre propre code.
- Vérifier un code 200 alors que la route devrait retourner un 404.
- Laisser le pool de connexions à la base de données ouvert, empêchant ainsi le processus de s’arrêter.
- Trop mocker la base de données, au point que le test ne prouve que le mock fonctionne.
- Vérifier le corps de la réponse avec une correspondance de chaîne de caractères alors qu’un matcher structurel serait plus clair.
- Ignorer les headers tels que
Location,Set-Cookieet les directives de cache.
Et après ?
Supertest couvre la frontière HTTP et s’intègre parfaitement avec tout l’écosystème environnant. Consultez la section sur Vitest pour découvrir le runner qui exécutera ces tests, ou Jest si votre projet utilise déjà Jest. Le guide Express explique l’objet app que vous passez à Supertest, et la section REST détaille les codes de statut et la sémantique que vos assertions encodent. Une fois l’API couverte, la section End-to-End Testing vous montre comment valider ces mêmes parcours via un navigateur réel.