Zum Inhalt springen
CASOON

Testframeworks im Vergleich: Vitest, Playwright und Jest in der Praxis

Welches Framework für Unit-, Integration- und E2E-Tests in modernen Web-Projekten passt

11 Minuten
Testframeworks im Vergleich: Vitest, Playwright und Jest in der Praxis
#Testing #Vitest #JavaScript #Best Practices

Tests zu schreiben ist einfach. Die richtige Teststrategie zu finden – welche Ebene, welches Framework, wie viel – ist schwerer. In der Praxis kämpfen viele Teams nicht mit dem Schreiben von Tests, sondern damit, dass ihre Testsuite langsam läuft, ständig flaky ist oder bei echten Bugs nichts anschlägt.

Dieser Artikel beschäftigt sich nicht damit, ob man testen soll (ja, man soll), sondern womit und warum.

Die Testpyramide stimmt — aber die Grenzen sind fließender als gedacht

Das bekannte Bild: Viele Unit Tests an der Basis, weniger Integrationstests in der Mitte, wenige E2E-Tests an der Spitze. Das Prinzip ist richtig. Die Umsetzung ist in der Praxis unschärfer.

Was ist ein „Unit Test” bei einer React-Komponente, die einen API-Call macht, Daten transformiert und ein gerendertes DOM-Element zurückgibt? Man könnte die Transformation separat testen (echter Unit), die Komponente mit gemockten API-Calls rendern (Integration), oder die ganze Seite im Browser durchspielen (E2E).

Die sinnvollere Frage ist nicht “welche Ebene?” sondern “welche Risiken will ich abdecken?”:

  • Logik und Transformationen: Unit Tests, so isoliert wie möglich
  • Zusammenspiel von Modulen: Integrationstests, reale Dependencies wo möglich
  • User-Journeys und kritische Pfade: E2E-Tests, sparsam eingesetzt

Das Anti-Pattern, das am häufigsten Zeit verschwendet: Integrationstests, die eigentlich E2E-Tests sind, aber mit so vielen Mocks, dass sie nichts Reales mehr testen. Oder Unit Tests für reine Konfigurationsdateien, die nichts berechnen.

Eine bessere Heuristik ist risikobasiert:

RisikoPassende Testebene
Berechnung ist falschUnit Test
API und Datenbank sprechen falsch zusammenIntegrationstest
Nutzer kann Formular nicht abschickenPlaywright
CSS versteckt einen Button mobilPlaywright mit mobilem Viewport
Externer Dienst antwortet langsamIntegrationstest mit kontrolliertem Fake
Deployment liefert kaputte Meta-TagsBuild- oder E2E-Check

Nicht jede Datei braucht Tests. Jedes relevante Risiko braucht eine prüfbare Stelle.

Vitest ist nicht “Jest mit Vite” — der Unterschied zählt

Vitest hat sich in den letzten zwei Jahren als Standard für Vite-basierte Projekte durchgesetzt. Das liegt nicht nur daran, dass es schneller startet als Jest — der entscheidende Vorteil ist die geteilte Build-Konfiguration.

Bei Jest in einem Vite-Projekt hat man zwei Konfigurationsebenen: die Vite-Config für den Build und eine separate Jest-Config mit eigenem Babel/TypeScript-Transformer. Pfad-Aliase müssen doppelt gepflegt werden. ES-Module-Unterstützung ist ein eigenes Kapitel. Module, die auf Vite-spezifische Features wie import.meta.env zurückgreifen, brauchen spezielle Mocks.

Vitest nutzt dieselbe Vite-Pipeline. vite.config.ts gilt auch für Tests. Aliase funktionieren automatisch. import.meta.env ist ohne Mock verfügbar.

Vitest-Konfiguration für ein modernes Projekt

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  test: {
    environment: 'jsdom',        // 'node' für reinen Backend-Code
    globals: true,                // describe/it/expect ohne Import
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      exclude: ['**/*.config.*', 'src/test/**'],
    },
    // Parallele Ausführung — Standard-Einstellung beibehalten
    pool: 'threads',
  },
  resolve: {
    alias: {
      '@': resolve(__dirname, './src'),
    },
  },
});
// src/test/setup.ts — globale Test-Utilities
import '@testing-library/jest-dom';

// Globale Mocks die in allen Tests gelten
vi.mock('./src/lib/analytics', () => ({
  track: vi.fn(),
}));

Dieselbe API, unterschiedliches Verhalten

// user.test.ts — läuft in Vitest und Jest mit identischer Syntax
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Bei Jest: import { describe, it, expect, jest, beforeEach } from '@jest/globals';
// Oder mit globals: true in jest.config einfach global nutzen

import { createUser } from '../user';

describe('createUser', () => {
  beforeEach(() => {
    vi.clearAllMocks(); // Bei Jest: jest.clearAllMocks()
  });

  it('should hash the password before storing', async () => {
    const hashFn = vi.fn().mockResolvedValue('hashed-password');
    // Bei Jest: const hashFn = jest.fn().mockResolvedValue('hashed-password');

    const user = await createUser({ name: 'Alice', password: 'secret' }, hashFn);

    expect(hashFn).toHaveBeenCalledWith('secret');
    expect(user.password).toBe('hashed-password');
  });

  it('should reject when name is empty', async () => {
    await expect(createUser({ name: '', password: 'secret' }))
      .rejects.toThrow('Name is required');
  });
});

Der Hauptunterschied im Code: vi statt jest für Mocks. Die API ist bewusst kompatibel gehalten. Migration von Jest zu Vitest bedeutet meist nur Paket-Tausch und Konfigurationsanpassung, kein Test-Rewrite.

Jest: Wann es noch die richtige Wahl ist

Jest ist nicht überholt. Für reine Node.js-Backends ohne Vite, für Teams mit bestehender Jest-Infrastruktur und funktionierender Konfiguration, und für Projekte die auf Babel-Transformer angewiesen sind, macht ein Wechsel keinen Sinn.

Der konkrete Vorteil von Jest gegenüber Vitest: Es ist seit Jahren im Produktiveinsatz, hat eine riesige Ecosystem-Abdeckung und Dokumentation ist für fast jedes Problem vorhanden. Vitest ist schnell gewachsen, aber Edge-Cases in komplexen Mock-Szenarien können noch zu Überraschungen führen.

Die klare Empfehlung: Neuprojekte mit Vite → Vitest. Bestehende Node.js-Projekte ohne Vite → Jest beibehalten bis es einen konkreten Grund für Wechsel gibt.

Integrationstests: Die unterschätzte Mitte

Viele Teams springen von Unit Tests direkt zu Playwright. Dazwischen liegt die Ebene, die oft den größten Nutzen bringt: echte Module, aber kontrollierte Umgebung.

Beispiel für eine API-Route:

import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../src/app';
import { db } from '../src/db/test-client';

describe('POST /api/orders', () => {
  beforeEach(async () => {
    await db.reset();
  });

  it('creates an order and returns 201', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({
        productId: 'prod_123',
        quantity: 2,
      })
      .expect(201);

    expect(response.body).toMatchObject({
      status: 'pending',
      quantity: 2,
    });
  });
});

Dieser Test ist wertvoller als fünf isolierte Controller-Mocks, weil er Routing, Validierung, Geschäftslogik und Persistenz gemeinsam prüft. Er ist aber immer noch schneller und stabiler als ein Browser-Test.

Die Grenze: Externe Services sollten in Integrationstests kontrolliert ersetzt werden. Eine Testsuite, die vom echten Zahlungsanbieter oder Live-Mailversand abhängt, wird langsam, teuer und unzuverlässig.

Playwright: Warum Browser-Tests heute anders funktionieren

Playwright hat Cypress als De-facto-Standard für Browser-Tests weitgehend abgelöst — zumindest für neue Projekte. Der technische Grund: Playwright läuft außerhalb des Browsers, kommuniziert mit dem Browser-Protokoll (CDP), und unterstützt echte Parallelisierung über mehrere Browser-Instanzen hinweg.

Cypress läuft innerhalb des Browser-Kontexts. Das gibt einfachen Zugang zu bestimmten Interna, macht echte Parallelisierung auf einem Rechner aber komplex.

Playwright-Konfiguration für eine Astro-Site

// 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,
  workers: process.env.CI ? 2 : undefined,
  reporter: [
    ['list'],
    ['html', { open: 'never' }],
  ],
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:4321',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],
  webServer: {
    command: 'npm run build && npm run preview',
    url: 'http://localhost:4321',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
// e2e/navigation.spec.ts — Playwright-Test für kritische Navigation
import { test, expect } from '@playwright/test';

test.describe('Navigation', () => {
  test('Startseite lädt und enthält H1', async ({ page }) => {
    await page.goto('/');
    await expect(page.locator('h1')).toBeVisible();
  });

  test('Navigation zu Service-Seite funktioniert', async ({ page }) => {
    await page.goto('/');
    await page.getByRole('link', { name: /Webentwicklung/i }).click();
    await expect(page).toHaveURL(/\/webentwicklung\//);
    await expect(page.locator('h1')).toBeVisible();
  });

  test('Kontaktformular ist zugänglich', async ({ page }) => {
    await page.goto('/kontakt/');
    const form = page.locator('form');
    await expect(form).toBeVisible();
    await expect(page.getByLabel(/Name/i)).toBeVisible();
    await expect(page.getByLabel(/E-Mail/i)).toBeVisible();
  });
});

CI-Integration: Alle drei Frameworks in einer Pipeline

# .github/workflows/test.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/

  e2e-tests:
    runs-on: ubuntu-latest
    needs: unit-tests    # Erst Unit-Tests, dann E2E
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium firefox
      - run: npm run test:e2e
        env:
          CI: true
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Die Trennung in zwei Jobs hat einen praktischen Grund: E2E-Tests sind teuer. Wenn Unit-Tests schlagen, lohnt es sich nicht, Playwright-Browser zu starten. Der needs: unit-tests Block erzwingt die Reihenfolge ohne das gesamte Workflow-Konzept aufzublasen.

Flaky Tests systematisch reduzieren

Flaky Tests sind selten „ein Playwright-Problem”. Meist prüfen sie den falschen Zustand oder warten falsch.

Flaky-MusterBessere Lösung
waitForTimeout(1000)auf sichtbares Element, URL oder Netzwerkzustand warten
zufällige Testdaten ohne Cleanupdeterministische IDs oder isolierte Testdatenbank
Test hängt von Reihenfolge abjeder Test baut seinen eigenen Zustand auf
Animationen beeinflussen Klicksreduzierte Bewegung im Testmodus oder stabile Locator
Text-Locator auf MarketingcopyRollen, Labels und stabile Test-IDs für technische Ziele

Bei Playwright gilt: Nutzernahe Locator sind gut, aber nicht dogmatisch. Für rein technische Zustände ist ein bewusst gesetztes data-testid besser als ein fragiler Text-Match.

Vergleich: Welches Framework für welchen Zweck

KriteriumVitestJestPlaywright
TestebeneUnit / IntegrationUnit / IntegrationE2E / Browser
ESM-SupportNativExperimentell / AufwändigNativ
Startzeit (kalt)~500ms~2000ms~3000ms
Watch-ModeHMR-basiert, schnellVollständig, langsamerNicht sinnvoll
Browser-Supportjsdom (simuliert)jsdom (simuliert)Echte Browser
Vite-IntegrationNativSeparat konfigurierenSeparat
Parallele AusführungThreadsWorker ThreadsEchte Parallelität
Snapshot-TestsJaJaScreenshot-Diffs
Beste Wahl fürVite-ProjekteNode-Backends, LegacyUser-Journeys, A11y

Empfehlungen nach Projekttyp

Astro-Projekt: Vitest für Hilfslogik und Transformationen, Playwright für kritische User-Journeys (Formular-Submission, Navigation, SEO-Checks). Viele Astro-Sites haben wenig testbare Logik — Playwright-Tests für Rendering und Accessibility decken mehr echtes Risiko ab.

React-SPA: Vitest mit React Testing Library für Komponenten-Integrationstests. Playwright für kritische Workflows. Unit Tests für Hooks und Utility-Funktionen.

Node.js-Backend ohne Vite: Jest bleibt die sichere Wahl. Supertest für HTTP-Integration-Tests. Kein Playwright nötig, außer das Backend liefert rendered HTML aus.

Full-Stack TypeScript-Monorepo mit Vite: Vitest einheitlich im Monorepo, Playwright für den Frontend-Teil. Die geteilte Konfiguration spart erheblichen Setup-Aufwand.

Content-lastige Astro-Site: wenige Unit Tests, dafür Build-Checks, Linkprüfung, Accessibility-Smoke-Tests und ein kleiner Satz Playwright-Tests für Navigation, Suche, Theme und Formular. Bei solchen Sites ist kaputtes Rendering wahrscheinlicher als fehlerhafte Businesslogik.

SaaS-Dashboard: Vitest für Berechtigungslogik, Datenformatierung und Stores; Integrationstests für API-Routen; Playwright für Login, Kernworkflow und kritische Rollenrechte. Nicht jede Tabelle braucht einen Browser-Test.

Wie man Abhängigkeiten in diesen Projekten aktuell hält, ohne ständig Breaking Changes zu riskieren, zeigt der Artikel zu Renovate und Dependabot für automatisches Dependency-Management. Für API-Tests, die über Unit-Tests hinausgehen, gibt es im Artikel zu API-Design mit REST und tRPC Hinweise auf sinnvolle Testgrenzen.

Was eine gute Testsuite von einer teuren unterscheidet

Die häufigste Fehlinvestition in Tests: Zu viele Tests auf der falschen Ebene. E2E-Tests, die Implementierungsdetails prüfen. Unit Tests für reine Konfiguration. Integrationstests mit so vielen Mocks, dass nichts Reales mehr getestet wird.

Eine Testsuite ist wertvoll, wenn sie bei echten Bugs anschlägt und bei refactorings schweigt. Das klingt offensichtlich, ist aber praktisch schwer. Der Weg dorthin: Test-Scope klar an Risiko koppeln. Was ist das schlimmste, das bei diesem Code schiefgehen kann? Dafür einen Test schreiben. Nicht für alles.