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-Analogie

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-react

Erstelle 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);
    }
  });
});
Rails-Analogie
Ruby on Rails
# 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
Next.js
// 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-Report

Playwright — 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/test

Die 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();
  });
});
Rails-Analogie
Ruby on Rails
# 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
Next.js
// 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 öffnen

Der --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 Logic

Diese Pyramide gilt in Rails genauso wie in Next.js: Viele schnelle Unit-Tests, wenige langsame E2E-Tests.


Vitest einrichten und ersten Test schreiben
  1. Installiere Vitest im next-tutorial-Projekt:
npm install --save-dev vitest @vitejs/plugin-react
  1. Erstelle vitest.config.ts im Projektwurzelverzeichnis:
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
  },
});
  1. Füge ein test-Script in package.json hinzu:
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}
  1. Lege lib/validators.ts an (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"),
});
  1. Erstelle lib/__tests__/validators.test.ts mit einem Test für einen gültigen und einen ungültigen Input (leerer Titel).

  2. Führe die Tests aus:

npx vitest run

Deine 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 NoteSchema in lib/validators.ts einrichten und die Validierungsregeln testen (Pflichtfelder, Längenbeschränkungen, Tag-Format)
  • Test-Factories für User und Note in test/factories.ts anlegen, 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-Tests

Ein guter erster Schritt: Führe npx vitest run aus und sieh, was passiert — der Fehler zeigt dir genau, was noch fehlt.