Testing-Leitfaden
Dieser Leitfaden beschreibt das Testing-Setup und Best Practices für dieses Projekt.
Übersicht
Das Projekt verwendet:
- Vitest - Schneller Unit-Test-Runner mit ESM-Unterstützung
- Testing Library - React-Komponenten-Tests
- Playwright - End-to-End-Browser-Tests
- MSW (Mock Service Worker) - API-Mocking
Verfügbare Befehle
Unit-Tests (Vitest)
# Watch-Modus (empfohlen während der Entwicklung)
pnpm test
# Alle Tests einmalig ausführen
pnpm test:run
# Tests mit interaktiver UI
pnpm test:ui
# Coverage-Report generieren
pnpm test:coverage
E2E-Tests (Playwright)
Läuft gegen eine eigene Test-Datenbank + Mailpit (SMTP-Capture), nie gegen die Entwicklungs-DB — siehe Abschnitt "E2E-Tests: lokale Infrastruktur und Seed" weiter unten, bevor du pnpm test:e2e zum ersten Mal ausführst.
# E2E-Tests ausführen (nur Chromium, Standard)
pnpm test:e2e
# Alle Browser (Chromium, Firefox, WebKit) — nicht der Standard-Lauf
pnpm test:e2e:all-browsers
# E2E-Tests mit interaktiver UI
pnpm test:e2e:ui
# E2E-Tests im Debug-Modus
pnpm test:e2e:debug
Projektstruktur
webapp/ # Hauptanwendung im Monorepo school-sync
├── tests/ # Unit-Tests
│ ├── setup.ts # Globale Test-Konfiguration
│ ├── mocks/ # MSW-Handler
│ │ ├── handlers.ts # API-Mock-Definitionen
│ │ └── server.ts # MSW-Server-Setup
│ ├── utils/ # Test-Utilities
│ │ └── test-utils.tsx # Custom Render mit Providern
│ ├── components/ # Komponenten-Tests
│ │ └── ui/
│ │ └── button.test.tsx
│ └── lib/ # Business-Logic-Tests
│ └── registration-schema.test.ts
├── e2e/ # E2E-Tests
│ ├── fixtures/ # test-data.ts, mailpit.ts, dates.ts
│ ├── portal/ # Login, Isolation, Entschuldigung, ...
│ ├── backend/ # Kleiner Rauchtest-Satz
│ ├── auth.setup.ts # storageState je Rolle (einmaliger Login)
│ └── global-setup.ts # DB-Sicherheitscheck + Migration + Seed
├── docker-compose.e2e.yml # Lokale Postgres- + Mailpit-Container
├── vitest.config.mts # Vitest-Konfiguration
└── playwright.config.ts # Playwright-Konfiguration
Best Practices
1. Unit-Tests
Zod-Schemas testen
import { describe, it, expect } from 'vitest';
import { mySchema } from '@/lib/schemas/my-schema';
describe('mySchema', () => {
it('sollte gültige Daten akzeptieren', () => {
const result = mySchema.safeParse({ name: 'Test' });
expect(result.success).toBe(true);
});
it('sollte ungültige Daten ablehnen', () => {
const result = mySchema.safeParse({ name: '' });
expect(result.success).toBe(false);
});
});
Komponenten testen
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@/tests/utils/test-utils';
import userEvent from '@testing-library/user-event';
import { MyComponent } from '@/components/MyComponent';
describe('MyComponent', () => {
it('sollte korrekt gerendert werden', () => {
render(<MyComponent />);
expect(screen.getByText('Hallo')).toBeInTheDocument();
});
it('sollte auf Klicks reagieren', async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<MyComponent onClick={handleClick} />);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
Server Actions testen
import { describe, it, expect, vi } from 'vitest';
import { myServerAction } from '@/lib/actions';
// Mock Prisma — der Client wird als benannter Export `prisma` aus @/lib/prisma bereitgestellt
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
create: vi.fn()
}
}
}));
describe('myServerAction', () => {
it('sollte Daten korrekt verarbeiten', async () => {
const formData = new FormData();
formData.set('name', 'Test');
const result = await myServerAction({}, formData);
expect(result.message).toBe('Erfolg');
});
});
2. E2E-Tests
Locators über Rolle/Label (getByRole, getByLabel), nicht über CSS-Klassen. Feste IDs/E-Mails aus e2e/fixtures/test-data.ts, Datumswerte relativ über e2e/fixtures/dates.ts:
import { test, expect } from '@playwright/test';
import { PARENT_A } from '../fixtures/test-data';
test.describe('Portal-Login', () => {
test('sollte erfolgreich einloggen', async ({ page }) => {
await page.goto('/portal/login');
await page.getByLabel('E-Mail-Adresse').fill(PARENT_A.email);
await page.getByLabel('Passwort').fill(PARENT_A.password);
await page.getByRole('button', { name: 'Anmelden' }).click();
await expect(page).toHaveURL(/\/portal\/dashboard/);
});
});
Für Tests, die eine bereits eingeloggte Rolle voraussetzen (z. B. Isolationstests), die in auth.setup.ts gespeicherte Session verwenden statt sich erneut einzuloggen:
import { test } from '@playwright/test';
import { STORAGE_STATE } from '../fixtures/test-data';
test.use({ storageState: STORAGE_STATE.parentA });
3. API-Mocking mit MSW
Handler in tests/mocks/handlers.ts definieren:
import { http, HttpResponse } from 'msw';
export const handlers = [
// NextAuth Session (bereits vorkonfiguriert)
http.get('/api/auth/session', () => {
return HttpResponse.json({
user: {
id: 'test-user-id',
email: 'test@example.com',
name: 'Test User'
},
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
});
}),
http.get('/api/users', () => {
return HttpResponse.json([{ id: '1', name: 'John Doe' }]);
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: '2', ...body }, { status: 201 });
})
];
In Tests verwenden:
import { server } from '@/tests/mocks/server';
import { http, HttpResponse } from 'msw';
it('sollte Fehler behandeln', async () => {
// Überschreibe Handler für diesen Test
server.use(
http.get('/api/users', () => {
return new HttpResponse(null, { status: 500 });
})
);
// Test mit Fehler-Response
});
it('sollte mit nicht-authentifiziertem User testen', async () => {
// Session-Handler überschreiben für Nicht-Auth-Szenario
server.use(
http.get('/api/auth/session', () => {
return HttpResponse.json(null);
})
);
// Test ohne Session
});
Testziele gemäß Projektregeln
Laut .cursor/rules/testing-quality.mdc:
- Zod-Schemas validieren - Happy & Error-Pfade
- Server Actions testen - Erfolg und Fehlerbehandlung
- Prisma-Funktionen - Mit Seeds testen
- CI-Checks - Die CI (
.github/workflows/ci.yml) führt bei Pull Requests gegendevelopundmainpnpm typecheck,pnpm test:runundpnpm buildaus. Ein ESLint-Check ist aktuell nicht eingerichtet (keinlint-Script, keine ESLint-Konfiguration).
Nützliche Testing-Library-Queries
// By Role (bevorzugt)
screen.getByRole('button', { name: /speichern/i });
// By Label Text
screen.getByLabelText('E-Mail');
// By Placeholder
screen.getByPlaceholderText('Name eingeben');
// By Test ID (nur wenn nötig)
screen.getByTestId('submit-button');
Debugging
Vitest
# Einzelne Test-Datei ausführen
pnpm test button.test
# Im Debug-Modus (mit --inspect)
node --inspect-brk node_modules/.bin/vitest
Playwright
# Mit Browser-UI
pnpm test:e2e:ui
# Mit Debug-Modus (pausiert bei jedem Schritt)
pnpm test:e2e:debug
# Headed-Modus (Browser sichtbar)
pnpm test:e2e --headed
Coverage-Ziele
- Mindestens 70% Coverage für kritische Business-Logic
- 100% für Validierungs-Schemas
- Server Actions: Happy Path + häufigste Error-Pfade
E2E-Tests: lokale Infrastruktur und Seed
E2E-Tests laufen gegen eine eigene Postgres-Datenbank und einen Mailpit-Container (SMTP-Capture, fängt alle Mails statt sie zu versenden) — niemals gegen die Entwicklungs-DB. e2e/global-setup.ts bricht ab, wenn DATABASE_URL nicht "e2e" oder "test" im Host- oder Datenbanknamen enthält.
- Infrastruktur starten:
docker compose -f docker-compose.e2e.yml up -d .env.e2e.examplenach.env.e2ekopieren (wird von Git ignoriert).- Vor dem Testlauf laden und E2E-Tests ausführen:
set -a; source .env.e2e; set +a
pnpm test:e2e
global-setup.ts spielt dabei automatisch alle Migrationen ein (prisma migrate deploy) und sät die deterministischen Testdaten (prisma/seed/seed-e2e.ts, auch einzeln über pnpm db:seed:e2e aufrufbar). Feste Logins, IDs und das Passwort stehen in e2e/fixtures/test-data.ts — u. a. ein Schul-Admin, eine Klassenlehrkraft und zwei Elternteile in Mandant A (eines davon mit Zugriffsstufe LIMITED) sowie ein Elternteil in einem zweiten Mandanten B, ausschließlich für Cross-Tenant-Isolationstests.
Mailpit-Oberfläche zum Nachsehen abgefangener Mails (Ports aus docker-compose.e2e.yml): http://localhost:8026.
Neue Isolationstests schreiben
Jede neue Portal-Datenfunktion mit Schüler-Kontext braucht einen Test, der mit einem fremden Schüler denselben Zugriff versucht (anderer Elternteil/gleicher Mandant und anderer Mandant) und eine Ablehnung über eine echte URL oder REST-Route erwartet — siehe e2e/portal/isolation.spec.ts. Server Actions ohne REST-Äquivalent werden stattdessen über Vitest auf Funktionsebene geprüft (siehe lib/portal/__tests__/access.test.ts).
Troubleshooting
"Cannot find module" bei Tests
→ Prüfe Path-Aliases in vitest.config.mts
MSW-Handler werden nicht aufgerufen
→ Stelle sicher, dass server.listen() in tests/setup.ts ausgeführt wird
Playwright findet Elemente nicht
→ Nutze page.pause() für interaktives Debugging oder --headed-Modus
E2E-Lauf bricht mit "DATABASE_URL ... ist nicht eindeutig als Test-Datenbank erkennbar" ab
→ .env.e2e nicht geladen oder DATABASE_URL zeigt auf eine andere DB — siehe Abschnitt oben. Das ist ein bewusster Schutz vor einem versehentlichen Reset der Entwicklungsdatenbank, kein Bug.
E2E-Tests finden keine Mail in Mailpit
→ Prüfe, ob der Mailpit-Container läuft (docker compose -f docker-compose.e2e.yml ps) und SMTP_HOST/SMTP_PORT/MAILPIT_URL in .env.e2e auf dessen Ports zeigen.