Testing in Next.js
Als Rails-Entwickler bist du das Testen gewohnt: bundle exec rspec, grüne Dots,
rotes Terminal wenn etwas kaputt ist. Das Testprinzip in Next.js ist exakt dasselbe —
was sich ändert, sind die Werkzeuge und ein paar Besonderheiten, die sich aus dem
Server-/Client-Modell ergeben.
Dieser Abschnitt gibt dir einen vollständigen Überblick: Unit-Tests mit Vitest, End-to-End-Tests mit Playwright, und Strategien für Server Components und Datenbank-Tests.
Testing in Next.js — Überblick
In Rails hast du wahrscheinlich einen Stack wie diesen: RSpec für Unit- und Integration-Tests, Capybara (mit Selenium oder Cuprite) für Browser-Tests, FactoryBot für Test-Fixtures und eine separate Test-Datenbank, die vor jeder Test-Suite zurückgesetzt wird. Das funktioniert gut, und Next.js bietet einen direkt vergleichbaren Stack.
Die empfohlene Kombination für Next.js-Projekte:
- Vitest — Unit- und Integrationstests, Vite-basiert, extrem schnell
- Playwright — End-to-End-Tests im echten Browser (Chromium, Firefox, WebKit)
- DB-Seed-Skripte — anstatt FactoryBot; einfache TypeScript-Funktionen, die Testdaten anlegen
Der konzeptuelle Unterschied zu Rails: In Next.js gibt es keine einheitliche
"Test-Datenbank-Strategie" out of the box. Du konfigurierst selbst eine
TEST_DATABASE_URL und sorgst für das Zurücksetzen zwischen Tests.
Mehr dazu im Abschnitt "Testing mit der DB" weiter unten.
Rails: RSpec + Capybara + FactoryBot + separate test-Datenbank (per RAILS_ENV=test)
Next.js: Vitest + Playwright + DB-Seed-Skripte + separate Datenbank (per TEST_DATABASE_URL)
Der Stack ist different, aber die Denkweise ist identisch: schnelle Unit-Tests laufen isoliert, langsame Browser-Tests laufen gegen eine echte Instanz deiner App.
Ein wichtiger konzeptueller Unterschied: In Rails testest du oft Controller-Actions und Models als Integrationsebene. In Next.js hast du Server Components und Server Actions — beides sind asynchrone Funktionen, die du direkt importieren und aufrufen kannst. Das macht bestimmte Tests sogar einfacher als in Rails, weil kein HTTP-Request-Overhead entsteht.
Vitest — Unit Tests
Vitest ist das De-facto-Standard-Tool für Unit-Tests in Vite-basierten Projekten.
Es versteht TypeScript nativ, unterstützt ES Modules ohne Konfigurationsaufwand und
ist konzeptionell mit Jest identisch — wenn du mal jest.config.js gesehen hast,
wirst du dich sofort zurechtfinden.
Installation
npm install --save-dev vitest @vitejs/plugin-reactErstelle eine vitest.config.ts im Projektwurzelverzeichnis:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "node", // "jsdom" wenn du DOM brauchst
globals: true, // describe/it/expect ohne Import
setupFiles: ["./vitest.setup.ts"],
},
});Die vitest.setup.ts ist optional, aber nützlich für globale Mocks:
// vitest.setup.ts
import { vi } from "vitest";
// Beispiel: Next.js-Navigation mocken
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/",
}));Ein Beispiel-Test für NoteSchema
Angenommen, du hast ein Zod-Schema in lib/validators.ts:
// lib/validators.ts
import { z } from "zod";
export const NoteSchema = z.object({
title: z.string().min(1, "Titel darf nicht leer sein").max(200),
content: z.string().min(1, "Inhalt darf nicht leer sein"),
tags: z.array(z.string()).optional().default([]),
});
export type NoteInput = z.infer<typeof NoteSchema>;Der Test dazu liegt in lib/__tests__/validators.test.ts:
// lib/__tests__/validators.test.ts
import { describe, it, expect } from "vitest";
import { NoteSchema } from "../validators";
describe("NoteSchema", () => {
it("akzeptiert eine gültige Note", () => {
const result = NoteSchema.safeParse({
title: "Meine erste Note",
content: "Hier steht der Inhalt.",
});
expect(result.success).toBe(true);
if (result.success) {
// tags sollte auf [] defaulten
expect(result.data.tags).toEqual([]);
}
});
it("lehnt einen leeren Titel ab", () => {
const result = NoteSchema.safeParse({
title: "",
content: "Inhalt ist vorhanden.",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe("Titel darf nicht leer sein");
}
});
it("lehnt fehlendes content-Feld ab", () => {
const result = NoteSchema.safeParse({ title: "Nur ein Titel" });
expect(result.success).toBe(false);
});
it("nimmt Tags als optionales Array entgegen", () => {
const result = NoteSchema.safeParse({
title: "Getaggte Note",
content: "Mit Tags.",
tags: ["next.js", "typescript"],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.tags).toHaveLength(2);
}
});
});# spec/models/note_spec.rb
RSpec.describe Note, type: :model do
describe "validations" do
it "ist ungültig ohne Titel" do
note = build(:note, title: "")
expect(note).not_to be_valid
expect(note.errors[:title]).to include(
"darf nicht leer sein"
)
end
end
end// lib/__tests__/validators.test.ts
describe("NoteSchema", () => {
it("lehnt einen leeren Titel ab", () => {
const result = NoteSchema.safeParse({
title: "",
content: "Inhalt.",
});
expect(result.success).toBe(false);
expect(
result.error?.issues[0].message
).toBe("Titel darf nicht leer sein");
});
});Die describe/it-Struktur ist absichtlich identisch mit RSpec. Vitest nutzt
dieselbe Konvention — du kannst auch test() statt it() schreiben, aber it
liest sich natürlicher.
Tests starten mit:
npx vitest run # einmalig
npx vitest # watch mode
npx vitest run --coverage # mit Coverage-ReportPlaywright — End-to-End Tests
Während Vitest isolierte Funktionen testet, prüft Playwright das Gesamtverhalten deiner App im echten Browser. Ein Playwright-Test startet Chromium (oder Firefox, oder WebKit), navigiert zur laufenden App und interagiert wie ein echter Nutzer: Links klicken, Formulare ausfüllen, auf Elemente warten.
Installation
npx playwright install --with-deps
npm install --save-dev @playwright/testDie Konfiguration in playwright.config.ts:
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
// App vor den Tests starten
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});Vollständiges E2E-Beispiel: Login → Note anlegen → Liste prüfen
// e2e/notes.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Note-Verwaltung", () => {
test.beforeEach(async ({ page }) => {
// Zur Login-Seite navigieren und einloggen
await page.goto("/login");
await page.getByLabel("E-Mail").fill("test@example.com");
await page.getByLabel("Passwort").fill("geheim1234");
await page.getByRole("button", { name: "Anmelden" }).click();
// Warten bis die Weiterleitung abgeschlossen ist
await expect(page).toHaveURL("/dashboard");
});
test("eine neue Note anlegen und in der Liste sehen", async ({ page }) => {
// Zur Notizen-Übersicht navigieren
await page.goto("/notes");
// "Neue Note"-Button klicken
await page.getByRole("link", { name: "Neue Note" }).click();
await expect(page).toHaveURL("/notes/new");
// Formular ausfüllen
const title = `Test-Note ${Date.now()}`; // eindeutiger Titel
await page.getByLabel("Titel").fill(title);
await page.getByLabel("Inhalt").fill("Das ist der Inhalt meiner Test-Note.");
// Speichern
await page.getByRole("button", { name: "Speichern" }).click();
// Weiterleitung zur Übersicht prüfen
await expect(page).toHaveURL("/notes");
// Note in der Liste finden
await expect(page.getByText(title)).toBeVisible();
});
test("eine Note löschen", async ({ page }) => {
// Voraussetzung: mindestens eine Note vorhanden (via Seed)
await page.goto("/notes");
const noteRow = page.getByTestId("note-row").first();
const noteTitle = await noteRow.getByRole("heading").textContent();
await noteRow.getByRole("button", { name: "Löschen" }).click();
// Bestätigungsdialog abwarten
await page.getByRole("button", { name: "Ja, löschen" }).click();
// Note sollte verschwunden sein
await expect(page.getByText(noteTitle!)).not.toBeVisible();
});
});# spec/features/notes_spec.rb
RSpec.feature "Note-Verwaltung" do
background do
@user = create(:user)
login_as(@user, scope: :user)
end
scenario "eine Note anlegen" do
visit notes_path
click_link "Neue Note"
fill_in "Titel", with: "Meine Note"
fill_in "Inhalt", with: "Inhalt hier"
click_button "Speichern"
expect(page).to have_text("Meine Note")
end
end// e2e/notes.spec.ts
test.describe("Note-Verwaltung", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.getByLabel("E-Mail").fill("test@example.com");
await page.getByLabel("Passwort").fill("geheim1234");
await page.getByRole("button", { name: "Anmelden" }).click();
});
test("eine Note anlegen", async ({ page }) => {
await page.goto("/notes");
await page.getByRole("link", { name: "Neue Note" }).click();
await page.getByLabel("Titel").fill("Meine Note");
await page.getByLabel("Inhalt").fill("Inhalt hier");
await page.getByRole("button", { name: "Speichern" }).click();
await expect(page.getByText("Meine Note")).toBeVisible();
});
});Playwright-Tests starten mit:
npx playwright test # alle Tests
npx playwright test notes # nur notes.spec.ts
npx playwright test --ui # interaktive UI (sehr empfehlenswert)
npx playwright show-report # HTML-Report im Browser öffnenDer --ui-Modus ist besonders hilfreich: Du siehst in Echtzeit, was der Browser
macht, kannst Steps einzeln durchklicken und Screenshots zu jedem Schritt inspizieren.
Testing Server Components
Server Components sind — im Gegensatz zu Client Components — einfache async-Funktionen. Das hat einen praktischen Vorteil: Du kannst sie direkt importieren und als Funktion aufrufen, ohne einen Test-Renderer oder ein gemocktes Browser-Environment.
// app/notes/page.tsx (Server Component)
import { db } from "@/lib/db";
export default async function NotesPage() {
const notes = await db.note.findMany({ orderBy: { createdAt: "desc" } });
return (
<main>
<h1>Notizen</h1>
{notes.map((note) => (
<article key={note.id}>{note.title}</article>
))}
</main>
);
}Im Test mockst du einfach das Datenbankmodul:
// app/notes/__tests__/page.test.tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { db } from "@/lib/db";
import NotesPage from "../page";
// Prisma-Client mocken — kein echter DB-Aufruf
vi.mock("@/lib/db", () => ({
db: {
note: {
findMany: vi.fn(),
},
},
}));
describe("NotesPage (Server Component)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("rendert eine Liste von Notizen", async () => {
// Arrange: Mock-Daten vorbereiten
vi.mocked(db.note.findMany).mockResolvedValue([
{ id: "1", title: "Erste Note", content: "...", createdAt: new Date(), updatedAt: new Date(), userId: "u1" },
{ id: "2", title: "Zweite Note", content: "...", createdAt: new Date(), updatedAt: new Date(), userId: "u1" },
]);
// Act: Server Component direkt als Funktion aufrufen
const element = await NotesPage();
// Assert: JSX-Struktur prüfen
// (vereinfacht — in der Praxis nutzt man @testing-library/react)
expect(element).toBeTruthy();
expect(vi.mocked(db.note.findMany)).toHaveBeenCalledOnce();
});
});Für anspruchsvollere Assertions kannst du @testing-library/react installieren und
render() mit await nutzen:
npm install --save-dev @testing-library/react @testing-library/jest-dom// vitest.config.ts — environment auf jsdom umstellen für DOM-Tests
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
}// vitest.setup.ts
import "@testing-library/jest-dom";Damit kannst du expect(element).toBeInTheDocument(), getByRole() etc. nutzen —
die gleiche API, die du aus anderen React-Test-Setups kennst.
Testing mit der DB
Echte Datenbankintegrations-Tests brauchen eine separate Testdatenbank. Das
Prinzip entspricht exakt Rails: RAILS_ENV=test zeigt auf die _test-Datenbank,
hier nutzt du eine eigene Umgebungsvariable.
Konfiguration
Leg eine .env.test an (nicht in Git einchecken!):
# .env.test
DATABASE_URL="postgresql://localhost:5432/devnotes_test"In vitest.config.ts lädst du diese Datei:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { config } from "dotenv";
config({ path: ".env.test" }); // vor dem Export ausführen
export default defineConfig({
test: {
environment: "node",
globals: true,
},
});Datenbank zurücksetzen
Für saubere Tests zwischen den Runs brauchst du ein Setup-Skript. Mit Prisma:
// vitest.setup.ts
import { beforeEach, afterAll } from "vitest";
import { db } from "@/lib/db";
beforeEach(async () => {
// Alle Tabellen in der richtigen Reihenfolge leeren
// (Prisma truncate in reverse dependency order)
await db.$transaction([
db.note.deleteMany(),
db.user.deleteMany(),
]);
});
afterAll(async () => {
await db.$disconnect();
});Seed-Funktionen als FactoryBot-Ersatz
Statt FactoryBot schreibst du einfache Helper-Funktionen:
// test/factories.ts
import { db } from "@/lib/db";
import type { User, Note } from "@prisma/client";
export async function createUser(overrides: Partial<User> = {}): Promise<User> {
return db.user.create({
data: {
email: overrides.email ?? `user-${Date.now()}@example.com`,
name: overrides.name ?? "Test User",
passwordHash: "hashed_password_for_tests",
...overrides,
},
});
}
export async function createNote(
userId: string,
overrides: Partial<Note> = {}
): Promise<Note> {
return db.note.create({
data: {
title: overrides.title ?? "Test-Note",
content: overrides.content ?? "Test-Inhalt",
userId,
...overrides,
},
});
}Diese Factories kannst du dann in Tests direkt nutzen:
// lib/__tests__/notes.test.ts
import { describe, it, expect } from "vitest";
import { createUser, createNote } from "@/test/factories";
import { getNotesByUser } from "@/lib/notes";
describe("getNotesByUser", () => {
it("gibt nur Notes des angegebenen Users zurück", async () => {
const alice = await createUser({ email: "alice@example.com" });
const bob = await createUser({ email: "bob@example.com" });
await createNote(alice.id, { title: "Alices Note" });
await createNote(bob.id, { title: "Bobs Note" });
const aliceNotes = await getNotesByUser(alice.id);
expect(aliceNotes).toHaveLength(1);
expect(aliceNotes[0].title).toBe("Alices Note");
});
});Der Unterschied zu FactoryBot: Keine DSL, keine "trait"-Magie — nur TypeScript. Das ist weniger magisch, dafür vollständig typsicher und refactoring-freundlich.
Was testen in Next.js?
Die Frage "was teste ich womit?" ist genauso wichtig wie die technische Einrichtung. Hier eine pragmatische Heuristik:
Vitest — schnelle, isolierte Tests
Lib-Funktionen und Schemas sind die primären Kandidaten. Alles in lib/ ist
pure TypeScript ohne Framework-Abhängigkeiten — perfekt für Vitest:
lib/validators.ts → NoteSchema, UserSchema testen
lib/formatters.ts → Datum/Uhrzeit-Formatierung
lib/permissions.ts → Zugriffslogik (darf User X auf Resource Y zugreifen?)Server Actions lassen sich mit gemockter Datenbank testen. Da Server Actions normale async-Funktionen sind, importierst du sie direkt:
// actions/__tests__/createNote.test.ts
import { describe, it, expect, vi } from "vitest";
import { db } from "@/lib/db";
import { createNoteAction } from "../createNote";
vi.mock("@/lib/db");
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
describe("createNoteAction", () => {
it("legt eine neue Note an und gibt sie zurück", async () => {
const mockNote = { id: "1", title: "Neue Note", content: "Inhalt" };
vi.mocked(db.note.create).mockResolvedValue(mockNote as any);
const result = await createNoteAction({
title: "Neue Note",
content: "Inhalt",
});
expect(result.success).toBe(true);
expect(db.note.create).toHaveBeenCalledOnce();
});
});Playwright — vollständige Flows
E2E-Tests sind wertvoll für kritische User-Journeys, die mehrere Seiten und Systemteile verbinden. Gute Kandidaten:
- Registrierung und Login — Auth ist zu komplex für Unit-Tests
- Kern-CRUD-Flow — Note anlegen, bearbeiten, löschen
- Fehlerszenarien — Formular mit falschen Daten abschicken, Fehlermeldung prüfen
E2E-Tests sind langsamer und flaky-anfälliger als Unit-Tests. Halte die Suite klein und fokussiert auf das Wesentliche — nicht jede Variante eines Formulars braucht einen E2E-Test.
Die Pyramide
/\
/ \ Playwright (E2E)
/----\ wenige, kritische Flows
/ \
/--------\ Vitest + DB (Integration)
/ \ Lib-Funktionen, Actions
/------------\
/ \ Vitest (Unit)
/----------------\ Schemas, Formatter, Pure LogicDiese Pyramide gilt in Rails genauso wie in Next.js: Viele schnelle Unit-Tests, wenige langsame E2E-Tests.
- Installiere Vitest im
next-tutorial-Projekt:
npm install --save-dev vitest @vitejs/plugin-react- Erstelle
vitest.config.tsim Projektwurzelverzeichnis:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
},
});- Füge ein
test-Script inpackage.jsonhinzu:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}- Lege
lib/validators.tsan (falls nicht vorhanden):
import { z } from "zod";
export const NoteSchema = z.object({
title: z.string().min(1, "Titel darf nicht leer sein").max(200),
content: z.string().min(1, "Inhalt darf nicht leer sein"),
});-
Erstelle
lib/__tests__/validators.test.tsmit einem Test für einen gültigen und einen ungültigen Input (leerer Titel). -
Führe die Tests aus:
npx vitest runDeine Ausgabe sollte so aussehen:
✓ lib/__tests__/validators.test.ts (2)
✓ NoteSchema > akzeptiert eine gültige Note
✓ NoteSchema > lehnt einen leeren Titel ab
Test Files 1 passed (1)
Tests 2 passed (2)Im Capstone: DevNotes
Das DevNotes-Capstone-Projekt kommt ohne vorinstallierten Test-Stack — das ist Absicht. Du hast jetzt das Wissen, um den Stack selbst aufzusetzen:
- Vitest für das
NoteSchemainlib/validators.tseinrichten und die Validierungsregeln testen (Pflichtfelder, Längenbeschränkungen, Tag-Format) - Test-Factories für
UserundNoteintest/factories.tsanlegen, analog zu FactoryBot-Factories in Rails - Playwright für den kritischsten Flow einrichten: Login → Note anlegen → Note in der Liste sehen → Abmelden
Die Verzeichnisstruktur ist bereits vorbereitet:
devnotes/
├── lib/
│ └── validators.ts ← hier beginnen mit Vitest
├── test/
│ └── factories.ts ← DB-Seed-Helpers
└── e2e/
└── notes.spec.ts ← Playwright-TestsEin guter erster Schritt: Führe npx vitest run aus und sieh, was passiert —
der Fehler zeigt dir genau, was noch fehlt.