Was ist Supertest?
Supertest ist eine HTTP-Assertion-Library für Node.js. Sie nimmt ein App-Objekt – eine Express-App, eine Fastify-Instanz oder einen einfachen http Request-Listener – und ermöglicht es Ihnen, über eine chainable API Anfragen an dieses Objekt zu senden und anschließend die Antwort zu validieren.
Es basiert auf superagent, sodass Ihnen die Seite der Anfragen vertraut vorkommen wird, falls Sie diese Library bereits genutzt haben. Was Supertest ergänzt, ist die Ergonomie beim Testen: Eine App kann direkt anstelle einer URL übergeben werden, der Server-Lifecycle wird automatisch verwaltet und .expect() Assertions können an die Chain angehängt werden.
Wichtig zu verstehen ist, dass Supertest keinen Browser ausführt und nicht Ihren Produktionsserver startet. Es erstellt einen kurzlebigen HTTP-Server im selben Prozess, der an einen temporären Port auf localhost gebunden wird, sendet die Anfrage ab und fährt den Server wieder herunter, sobald die Antwort aufgelöst wurde. Ihr Test sieht den echten HTTP-Stack – Statuscodes, Header, Bodys, Serialisierung – ohne den Overhead und die Instabilität eines separaten Prozesses.
Warum In-Process-Tests?
Der Großteil der Schwierigkeiten beim HTTP-Testing resultiert aus dem Server, nicht aus dem Request. Ein separater Prozess benötigt einen Port, eine Wartezeit für den Start, einen Health Check und einen Teardown. Fixe Ports kollidieren in der CI, und ein Race Condition zwischen listen und dem ersten Request führt zu Fehlern, die wie Application-Bugs aussehen.
In-Process-Tests eliminieren all das. Es gibt keinen Prozess, der gestartet werden muss, also ist kein Readiness Check erforderlich. Es gibt keinen fixen Port, sodass parallele Testdateien sich nicht gegenseitig blockieren. Es gibt keine Netzwerkbarriere, wodurch ein Fehler direkt auf deinen Handler und nicht auf die Umgebung hinweist.
Du erhältst dennoch die vollständige Request-Pipeline: middleware, Routing, Body Parsing, Authentifizierung und Error Handling laufen exakt so ab wie in der Produktion, da es sich um denselben Code handelt. Was du aufgibst, sind die Dinge, die nur ein echter Browser oder ein echtes Netzwerk bieten können – die JavaScript-Ausführung, eine Rendering-Engine und das exakte Verhalten eines Proxys vor deiner App.
Exportiere die App, nicht den Listener
Damit Supertest deine App importieren kann, muss diese exportierbar sein. Ein häufiger Fehler besteht darin, die Routen zu definieren und listen im selben Modul aufzurufen. Dadurch wird der Server bereits in dem Moment gestartet, in dem die Datei importiert wird, was in deinen Tests zu belegten Ports führt.
Trenne diese beiden Zuständigkeiten:
// 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"));
Nun exportiert src/app.ts den Request-Listener und sonst nichts. Die Tests importieren ihn, und die Produktionsumgebung importiert ihn aus server.ts. Diese eine Trennung macht den Unterschied zwischen einer API, die du in Millisekunden testen kannst, und einer, die du jedes Mal komplett booten musst.
Das app von Express ist selbst eine Funktion mit der (req, res)-Signatur, was genau das ist, was Node’s http.createServer erwartet. Aus diesem Grund funktioniert request(app) ohne zusätzlichen Adapter. Fastify benötigt app.server oder ein await-basiertes app.ready(), während ein nackter Node-Handler direkt funktioniert.
Eine Anfrage stellen
Eine Anfrage beginnt mit request(app) und der HTTP-Methode. Alle von superagent unterstützten Methoden stehen zur Verfügung, und die Kette gibt dasselbe Request-Objekt zurück, sodass Aufrufe gestapelt werden können.
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 serialisiert ein Objekt zu JSON und setzt automatisch den Content-Type-Header. Ein String wird unverändert gesendet, was nützlich ist, wenn man absichtlich einen fehlerhaften Body testen möchte.
Header werden mit set gesetzt, entweder einzeln oder als Objekt. Query-Parameter lassen sich sauberer über query lösen, welche diese für Sie kodiert und anhängt.
await request(app)
.get("/posts")
.query({ page: 2, perPage: 10 })
.set("Accept", "application/json")
.set({ "X-Request-Id": "test-1" });
Die resultierende URL ist /posts?page=2&perPage=10. Wenn Sie einen Raw-Body benötigen – XML, ein einfacher String oder ein bewusst fehlerhaftes Payload – übergeben Sie set("Content-Type", ...) und send den String.
Assertions auf die Response
Die Response ist ein normales Objekt mit status, headers, body und text. Du kannst Assertions mit dem expect deines Test-Runners durchführen oder direkt in der Chain die .expect() von Supertest verwenden.
const res = await request(app).get("/posts").expect(200);
expect(res.headers["content-type"]).toMatch(/application\/json/);
expect(res.body).toHaveLength(3);
Das .expect() von Supertest ist praktisch, da es im Falle eines Fehlers die vollständige Response in der Fehlermeldung ausgibt. Das reicht meistens aus, um zu sehen, was schiefgelaufen ist, ohne dass man zusätzliche Log-Zeilen einfügen muss.
await request(app)
.get("/posts")
.expect("Content-Type", /json/)
.expect(200);
.expect() akzeptiert einen Statuscode, einen Header-Namen samt Wert, einen Body für einen Deep-Equality-Vergleich oder eine Funktion, die die Response erhält und einen Fehler werfen kann. Die Funktionsform ist das „Escape Hatch“, wenn eine Assertion Logik benötigt.
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");
}
});
Bevorzuge für alles, was über die Statuszeile und den Content-Type hinausgeht, den expect des Runners. Dieser bietet bessere Diffs, unterstützt Matcher wie toMatchObject und arrayContaining und hält den Assertions-Stil konsistent mit dem Rest deiner Test-Suite.
Supertest mit deinem Test-Runner kombinieren
Supertest ist kein Test-Runner. Es erstellt Requests und kann Assertions auf Responses ausführen, aber es entdeckt keine Tests, bietet kein describe und it, mockt keine Module und erstellt keine Ergebnisberichte. Diese Aufgabe übernehmen Vitest, Jest oder node:test.
Die beiden lassen sich sauber kombinieren, da ein Supertest-Request „thenable“ ist. Durch das Awaiten wird die Response aufgelöst, sodass ein Test einfach eine async-Funktion ist.
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");
});
});
Wenn du das Awaiten vergisst, wird der Test als bestanden gewertet, noch bevor der Request überhaupt gesendet wurde, und der Fehler taucht später als „unhandled rejection“ auf. Nutze await für jeden Request, selbst für diejenigen, deren einzige Assertion .expect() ist.
Authentifizierung testen
Die Authentifizierung besteht lediglich aus einem Header oder einem Cookie, weshalb beides einfach zu testen ist.
Verwenden Sie für Bearer-Token den Authorization Header. Signieren Sie ein Token mit demselben Test-Secret, das auch die App verwendet, anstatt den echten Identity Provider aufzurufen.
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);
Bei Session-Cookies behält request.agent(app) über mehrere Anfragen hinweg einen Cookie-Jar bei, was das Verhalten eines Browsers nach einem Login imitiert.
const agent = request.agent(app);
await agent
.post("/login")
.send({ email: "[email protected]", password: "secret" })
.expect(204);
await agent.get("/me").expect(200);
Wenn Sie bereits einen Cookie-Wert haben, setzen Sie diesen direkt. Cookies werden als Array oder als durch Semikolons getrennter String übergeben.
await request(app).get("/me").set("Cookie", ["session=abc123"]).expect(200);
Testen Sie die Negativfälle genauso sorgfältig wie den Happy Path: kein Token, ein abgelaufenes Token, ein Token für eine andere Audience und ein gültiges Token ohne den erforderlichen Scope. Diese vier Tests bieten Ihnen einen weitaus besseren Schutz als ein einziger Erfolgsfall.
Daten aufbauen und bereinigen
Supertest hat keine feste Meinung zur Datenbank. Das bedeutet, dass eine gemeinsam genutzte Datenbank Zustände zwischen Tests „leaken“ wird, sofern sie nicht zurückgesetzt wird. Es gibt drei gängige Strategien.
Vor jedem Test zurücksetzen. Leeren Sie die Tabellen, die die Suite berührt, in beforeEach. Dies ist einfach und vorhersehbar, und der Aufwand ist für kleine Suites akzeptabel.
beforeEach(async () => {
await db.query("TRUNCATE posts RESTART IDENTITY CASCADE");
await db.query("INSERT INTO posts (id, title) VALUES (1, 'Seeded')");
});
Eine Transaktion pro Test. Wenn Ihre App und Ihr Test dieselbe Verbindung teilen, kapseln Sie jeden Test in einer Transaktion und führen in afterEach einen Rollback durch. Dies ist schnell und lässt die Datenbank unberührt, funktioniert jedoch nur, wenn die App denselben Client verwendet – was über HTTP nicht immer der Fall ist.
Eine frische Datenbank pro Testdatei. Starten Sie für die Datei eine In-Memory- oder containerisierte Datenbank, führen Sie die Migrationen aus und verwerfen Sie diese am Ende. Dies bietet die stärkste Isolation und ist der Ansatz, bei dem sich die meisten Integration-Suites entscheiden, allerdings auf Kosten eines langsameren ersten Tests.
Egal wofür Sie sich entscheiden: Schließen Sie den Connection Pool in afterAll. Ein offener Pool hält den Node-Prozess am Laufen und verwandelt eine erfolgreiche Suite in einen hängenden CI-Job.
Die “Unhappy Paths” testen
Eine Route ist erst dann vollständig getestet, wenn auch ihre Fehlerfälle geprüft wurden. Der Statuscode ist Teil des API-Vertrags, daher sollte er explizit überprüft werden.
await request(app).get("/posts/999").expect(404);
await request(app).post("/posts").send({}).expect(422);
await request(app).get("/admin").expect(403);
Ein Validierungsfehler sollte genau angeben, welches Feld fehlerhaft ist, und nicht nur, dass allgemein etwas schiefgelaufen ist.
const res = await request(app)
.post("/posts")
.send({ title: "" })
.expect(422);
expect(res.body).toEqual({
error: "validation_error",
fields: { title: "required" },
});
Die Unterschiede sind entscheidend. 400 steht für eine fehlerhafte Anfrage, 401 bedeutet nicht authentifiziert, 403 bedeutet authentifiziert, aber nicht berechtigt, 404 bedeutet, dass die Ressource nicht existiert, und 422 bedeutet, dass der Body zwar geparst wurde, die Validierung jedoch fehlgeschlagen ist. Die Überprüfung des falschen Statuscodes ist ein Bug im Test, der einen Bug in der App verschleiert.
Datei-Uploads und multipart
Supertest erstellt multipart-Requests mit attach für Dateien und field für die dazugehörigen Formularfelder. Übergeben Sie entweder einen Buffer oder einen Pfad; wenn Sie einen Buffer verwenden, geben Sie einen Dateinamen an, damit der Server einen sinnvollen Namen erhält.
await request(app)
.post("/users/1/avatar")
.field("caption", "Profile picture")
.attach("avatar", Buffer.from("fake-image"), "avatar.png")
.expect(201);
Testen Sie auch die Ablehnungen: eine fehlende Datei, eine Datei, die das Größenlimit überschreitet, und ein nicht zulässiger MIME-Typ. Uploads sind eine der häufigsten Stellen, an denen sich Validierungslücken verstecken.
Pagination, Filterung und Sortierung testen
Query-Strings sind Teil des API-Vertrags und verdienen daher eigene Tests. query macht diese lesbar, und die Überprüfung der Länge sowie der Reihenfolge des Bodys hilft dabei, Off-by-one-Fehler und Bugs bei Standardwerten zu finden.
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);
});
Testen Sie die Grenzfälle, nicht nur den Normalfall: die erste Seite, die letzte Seite, eine Seite hinter dem Ende sowie einen ungültigen perPage, der entweder begrenzt oder abgelehnt werden sollte.
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);
Filterung und Sortierung sind ebenso testbar. Genau hier schlagen sich fehlende Indizes oder ein falscher ORDER BY oft als subtile Bugs nieder.
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);
Redirects, Cookies und weitere HTTP-Details
Nicht jede Antwort besteht aus einem JSON-Body. Statuscodes wie 301, 302 und 304 sowie Header wie Location, Set-Cookie und Cache-Control stellen oft das gesamte zu testende Verhalten dar.
Standardmäßig folgt supertest keinen Redirects, was genau richtig ist, wenn man den Redirect selbst validieren möchte.
const res = await request(app).get("/old-posts").expect(301);
expect(res.headers.location).toBe("/posts");
Wenn die Redirect-Kette entscheidend ist, folgt redirects(1) einem Hop und löst mit der finalen Antwort auf.
await request(app).get("/old-posts").redirects(1).expect(200);
Cookies sind in set-cookie sichtbar, und das Jar des Agents ermöglicht es, zu prüfen, ob ein Login die richtigen Attribute gesetzt hat, ohne den Wert dekodieren zu müssen.
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");
Bedingte Anfragen (Conditional Requests), Komprimierung und Caching-Header sind alle einen Test wert, wenn man sich auf sie verlässt, da ein Proxy oder CDN das Verhalten gerne ändert, wenn die Header falsch gesetzt sind.
Die Test-Suite beschleunigen
Eine Supertest-Suite ist normalerweise schnell, aber einige gute Gewohnheiten sorgen dafür, dass sie auch bei zunehmender Größe performant bleibt.
Wiederverwenden Sie aufwendige Setups in beforeAll und setzen Sie pro Test nur die veränderlichen Teile zurück. Wenn Sie einen Container starten oder ein Schema migrieren, anstatt dies für jeden einzelnen Test zu tun, können Sie bei einer großen Suite Minuten an Zeit einsparen.
Führen Sie Testdateien parallel aus. Vitest und Jest machen dies standardmäßig. Da jede Supertest-Anfrage einen ephemeren Port verwendet, gibt es keine Kollisionen, die gelöst werden müssten. Das Einzige, was Sie gewährleisten müssen, ist, dass die Dateien keine gemeinsamen Datenbankzeilen verwenden.
Verzichten Sie in Tests, die nur Daten lesen, auf nicht benötigte Arbeitsschritte. Wenn eine Route nur einen Benutzer und einen Post benötigt, seeden Sie nicht den gesamten Fixture-Satz. Kleinere Fixtures sind schneller zu erstellen und leichter nachvollziehbar.
# 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
Nutzen Sie schließlich Unit-Tests für die reine Logik und überlassen Sie den HTTP-Tests die Integration. Eine Suite, die jede einzelne Berechnung über eine vollständige Anfrage schickt, ist langsam, ohne dabei zusätzliche Sicherheit zu bieten.
Eine Test-Suite organisieren
Spiegeln Sie das Layout der Quelldateien wider, damit ein fehlgeschlagener Test auf eine Datei verweist, die Sie leicht finden können. Wenn die App src/routes/posts.ts hat, platzieren Sie test/posts.test.ts an der entsprechenden Stelle im Test-Baum.
Halten Sie gemeinsames Setup in einer geringen Anzahl von Helfern:
- Ein
test/app.ts, das die App mit der Test-Konfiguration aufbaut. - Ein
test/db.ts, das die Datenbank migriert, leert und schließt. - Ein
test/factories.tsmit Funktionen zum Erstellen von Benutzern, Posts und Tokens. - Ein
test/tokens.ts, das einen Token mit dem Test-Secret signiert.
// test/factories.ts
export async function createUser(overrides: Partial<User> = {}) {
return db.user.create({
data: { email: "[email protected]", role: "member", ...overrides },
});
}
Factories halten Tests lesbar, da der interessante Wert der Override ist. Ein Test, der createUser({ role: "admin" }) besagt, kommuniziert seine Absicht weitaus besser als eine Wand aus Literal-Feldern.
Tests unabhängig halten
Jeder Test sollte für sich allein und in beliebiger Reihenfolge bestehen. Diese Eigenschaft ermöglicht es einem Runner, Dateien zu parallelisieren, und verhindert, dass ein einzelner Fehler zu einem Dutzend irreführender Folgefehler führt.
Die Feinde der Unabhängigkeit sind gemeinsam genutzte, veränderbare Zustände (shared mutable state): ein Zähler auf Modulebene, ein vorbereiteter Datensatz, den ein anderer Test löscht, eine gemockte Uhr, die nie zurückgesetzt wird, oder eine Datenbank, die nur ein einziges Mal aufgesetzt wurde. Setzen Sie die Komponenten, von denen jeder Test abhängt, zurück, und verlassen Sie sich niemals darauf, dass ein vorheriger Test etwas erstellt hat.
Wenn eine Fixture tatsächlich ressourcenintensiv ist – wie eine migrierte Datenbank oder ein laufender Container –, erstellen Sie diese einmal in beforeAll und setzen Sie die veränderbaren Teile in beforeEach zurück. Die Unterscheidung liegt hier zwischen einem Setup, das schreibgeschützt ist, und einem Setup, das Änderungen vornimmt.
Best Practices
- Exportiere die App aus
app.tsund behaltelisten()inserver.ts. - Nutze
awaitfür jede Anfrage; ein fehlendesawaitführt zu einem stillen Fehler. - Überprüfe den Statuscode und den Content-Type, bevor du den Body validierst.
- Teste auch die negativen Pfade — 400, 401, 403, 404, 422 — und nicht nur den Happy Path.
- Signiere Test-Token mit einem Test-Secret, anstatt den echten Provider aufzurufen.
- Setze die Daten zurück, die in einem Test verändert wurden, und schließe den Pool in
afterAll. - Teste pro Testfall nur ein einziges Verhalten, damit ein Fehler genau benennt, was kaputtgegangen ist.
- Nutze für Body-Assertions bevorzugt
expectdes Runners und.expect()für die Statuszeile. - Führe die Test-Suite gegen denselben middleware Stack aus, der auch in der Produktion verwendet wird.
Häufige Fehler
- Der Aufruf von
listen()innerhalb des importierten Moduls, wodurch jede Testdatei einen Port öffnet. - Das Vergessen von
await, was dazu führt, dass ein Test besteht, noch bevor die Anfrage gesendet wurde. - Das Teilen einer Datenbankzeile zwischen Tests und die Abhängigkeit von der Ausführungsreihenfolge.
- Das Testen des Frameworks — also die Überprüfung, ob Express JSON parst — anstatt des eigenen Codes.
- Die Erwartung eines 200-Status, obwohl die Route einen 404 zurückgeben sollte.
- Das Offenlassen des Datenbank-Pools, sodass der Prozess niemals beendet wird.
- Ein zu starkes Mocking der Datenbank, sodass der Test nur beweist, dass der Mock funktioniert.
- Die Überprüfung des Bodys mittels String-Match, obwohl ein struktureller Matcher klarer wäre.
- Das Ignorieren von Headern wie
Location,Set-Cookieund Cache-Direktiven.
Wie geht es weiter?
Supertest deckt die HTTP-Grenze ab und lässt sich ideal mit allen umliegenden Tools kombinieren. Lies den Guide zu Vitest, um mehr über den Test-Runner zu erfahren, mit dem diese Tests ausgeführt werden, oder Jest, falls dein Projekt bereits Jest nutzt. Der Express-Guide erklärt das App-Objekt, das du an Supertest übergibst, und der Abschnitt zu REST behandelt die Statuscodes und die Semantik, die du in deinen Assertions kodierst. Sobald die API abgedeckt ist, zeigt End-to-End Testing, wie du dieselben User-Journeys über einen echten Browser nachweisen kannst.