JavaScript Testing komplett – Jest, Vitest & Playwright (2025)

~18 Min. Lesezeit · Veröffentlicht am
Keine Tests zu schreiben ist wie ohne Helm Fahrrad fahren – geht meistens gut, bis es das nicht mehr tut. Dieser Guide zeigt dir, wie du JavaScript-Code professionell testest: Von einfachen Unit Tests über Integration Tests bis zu vollständigen E2E-Tests mit modernen Tools.
Die Testing-Pyramide verstehen
Die klassische Testing-Pyramide zeigt das optimale Verhältnis verschiedener Test-Arten:
/\
/E2E\ ← Wenige, teure Tests (10%)
/------\
/Integr. \ ← Mittlere Anzahl (30%)
/----------\
/Unit Tests \ ← Viele, schnelle Tests (60%)
/______________\
| Test-Art | Was wird getestet? | Geschwindigkeit | Kosten | Tool-Empfehlung |
|---|---|---|---|---|
| Unit Tests | Einzelne Funktionen/Klassen | Sehr schnell (ms) | Niedrig | Jest/Vitest |
| Integration Tests | Zusammenspiel von Modulen | Schnell (Sekunden) | Mittel | Jest/Vitest + Testing Library |
| E2E Tests | Komplette User Journeys | Langsam (Minuten) | Hoch | Playwright/Cypress |
Jest vs. Vitest – Welches Framework wählen?
| Feature | Jest | Vitest |
|---|---|---|
| Performance | Gut | Exzellent (ESM-nativ) |
| Vite-Integration | Nein | Perfekt |
| TypeScript | Via Babel/ts-jest | Out-of-the-box |
| Community | Riesig | Wachsend |
| Watch Mode | ✅ | ✅ (schneller) |
| Snapshot Testing | ✅ | ✅ |
Unit Testing mit Jest – Die Basics
Installation und Setup
# Jest installieren
npm install --save-dev jest @types/jest
# Für TypeScript zusätzlich
npm install --save-dev ts-jest typescript
# package.json anpassen
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
} Jest Konfiguration (jest.config.js)
module.exports = {
// Test-Umgebung
testEnvironment: 'node', // oder 'jsdom' für Browser-Code
// Coverage-Settings
collectCoverage: true,
coverageDirectory: 'coverage',
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
// Dateien die getestet werden
testMatch: [
'**/__tests__/**/*.[jt]s?(x)',
'**/?(*.)+(spec|test).[jt]s?(x)'
],
// Module transformieren
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
// Setup-Dateien
setupFilesAfterEnv: ['/tests/setup.js'],
}; Dein erster Unit Test
// math.js - Die zu testende Funktion
export function calculateTotal(items) {
if (!Array.isArray(items)) {
throw new Error('Items must be an array');
}
return items.reduce((sum, item) => {
const price = item.price || 0;
const quantity = item.quantity || 1;
const discount = item.discount || 0;
return sum + (price * quantity * (1 - discount));
}, 0);
}
// math.test.js - Der Test
import { calculateTotal } from './math';
describe('calculateTotal', () => {
it('should calculate total for simple items', () => {
const items = [
{ price: 10, quantity: 2 },
{ price: 5, quantity: 1 }
];
expect(calculateTotal(items)).toBe(25);
});
it('should apply discounts correctly', () => {
const items = [
{ price: 100, quantity: 1, discount: 0.2 } // 20% Rabatt
];
expect(calculateTotal(items)).toBe(80);
});
it('should handle empty arrays', () => {
expect(calculateTotal([])).toBe(0);
});
it('should throw error for non-arrays', () => {
expect(() => calculateTotal('not an array')).toThrow('Items must be an array');
});
it('should handle missing properties with defaults', () => {
const items = [
{ price: 10 }, // quantity defaults to 1
{ quantity: 3 } // price defaults to 0
];
expect(calculateTotal(items)).toBe(10);
});
}); Testing mit Vitest – Der moderne Ansatz
# Vitest installieren
npm install --save-dev vitest
# Mit UI (optional)
npm install --save-dev @vitest/ui Vitest Konfiguration (vite.config.js)
import { defineConfig } from 'vite';
export default defineConfig({
test: {
// Jest-kompatible Globals
globals: true,
// Browser-ähnliche Umgebung
environment: 'happy-dom',
// Coverage mit c8
coverage: {
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'tests/',
],
},
// Setup-Files
setupFiles: './tests/setup.ts',
// Reporter
reporters: ['verbose'],
// Watch ignorieren
watchExclude: ['node_modules', 'dist']
},
}); Fortgeschrittenes Testing: Mocks und Spies
// api.js - API-Client
export class ApiClient {
constructor(baseURL) {
this.baseURL = baseURL;
}
async fetchUser(id) {
const response = await fetch(`${this.baseURL}/users/${id}`);
if (!response.ok) {
throw new Error(`User ${id} not found`);
}
return response.json();
}
}
// userService.js - Service der getestet wird
import { ApiClient } from './api';
export class UserService {
constructor(apiClient) {
this.apiClient = apiClient;
}
async getUserWithPosts(userId) {
const user = await this.apiClient.fetchUser(userId);
const posts = await this.apiClient.fetchPosts(userId);
return {
...user,
posts,
postCount: posts.length
};
}
}
// userService.test.js - Test mit Mocks
import { describe, it, expect, vi } from 'vitest';
import { UserService } from './userService';
describe('UserService', () => {
it('should fetch user with posts', async () => {
// Mock ApiClient
const mockApiClient = {
fetchUser: vi.fn().mockResolvedValue({
id: 1,
name: 'John Doe',
email: 'john@example.com'
}),
fetchPosts: vi.fn().mockResolvedValue([
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' }
])
};
const userService = new UserService(mockApiClient);
const result = await userService.getUserWithPosts(1);
// Assertions
expect(result).toEqual({
id: 1,
name: 'John Doe',
email: 'john@example.com',
posts: [
{ id: 1, title: 'Post 1' },
{ id: 2, title: 'Post 2' }
],
postCount: 2
});
// Verify mock calls
expect(mockApiClient.fetchUser).toHaveBeenCalledWith(1);
expect(mockApiClient.fetchPosts).toHaveBeenCalledWith(1);
expect(mockApiClient.fetchUser).toHaveBeenCalledTimes(1);
});
it('should handle API errors', async () => {
const mockApiClient = {
fetchUser: vi.fn().mockRejectedValue(new Error('Network error'))
};
const userService = new UserService(mockApiClient);
await expect(userService.getUserWithPosts(1))
.rejects.toThrow('Network error');
});
}); DOM Testing mit Testing Library
# Installation
npm install --save-dev @testing-library/dom @testing-library/jest-dom // todoList.js - DOM Komponente
export function createTodoList(container) {
const todos = [];
function render() {
container.innerHTML = `
Todo List (${todos.filter(t => !t.done).length} left)
${todos.map((todo, index) => `
-
${todo.text}
`).join('')}
`;
attachEventListeners();
}
function attachEventListeners() {
const input = container.querySelector('#todo-input');
const addBtn = container.querySelector('#add-btn');
addBtn?.addEventListener('click', () => {
if (input.value.trim()) {
todos.push({ text: input.value, done: false });
input.value = '';
render();
}
});
container.querySelectorAll('input[type="checkbox"]').forEach((cb, index) => {
cb.addEventListener('change', () => {
todos[index].done = cb.checked;
render();
});
});
}
render();
return { todos, render };
}
// todoList.test.js - DOM Tests
import { screen, fireEvent } from '@testing-library/dom';
import '@testing-library/jest-dom';
import { createTodoList } from './todoList';
describe('TodoList', () => {
let container;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
it('should add a new todo', () => {
createTodoList(container);
const input = container.querySelector('#todo-input');
const addBtn = container.querySelector('#add-btn');
// Neues Todo hinzufügen
fireEvent.change(input, { target: { value: 'Write tests' } });
fireEvent.click(addBtn);
// Prüfen ob Todo erscheint
expect(container.querySelector('li span')).toHaveTextContent('Write tests');
expect(input.value).toBe('');
});
it('should mark todo as done', () => {
const { todos } = createTodoList(container);
// Todo hinzufügen
const input = container.querySelector('#todo-input');
const addBtn = container.querySelector('#add-btn');
fireEvent.change(input, { target: { value: 'Test task' } });
fireEvent.click(addBtn);
// Checkbox anklicken
const checkbox = container.querySelector('input[type="checkbox"]');
fireEvent.click(checkbox);
expect(todos[0].done).toBe(true);
expect(container.querySelector('li')).toHaveClass('done');
});
it('should show correct count of remaining todos', () => {
createTodoList(container);
const input = container.querySelector('#todo-input');
const addBtn = container.querySelector('#add-btn');
// 2 Todos hinzufügen
fireEvent.change(input, { target: { value: 'Task 1' } });
fireEvent.click(addBtn);
fireEvent.change(input, { target: { value: 'Task 2' } });
fireEvent.click(addBtn);
expect(container.querySelector('h2')).toHaveTextContent('Todo List (2 left)');
// Einen als done markieren
const firstCheckbox = container.querySelector('input[type="checkbox"]');
fireEvent.click(firstCheckbox);
expect(container.querySelector('h2')).toHaveTextContent('Todo List (1 left)');
});
}); E2E Testing mit Playwright
Playwright ist das modernste E2E-Testing-Tool mit Support für alle Browser und eingebauter Parallelisierung:
# Playwright installieren
npm init playwright@latest
# Oder manuell
npm install --save-dev @playwright/test
# Browser installieren
npx playwright install Playwright Konfiguration (playwright.config.js)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Test-Verzeichnis
testDir: './e2e',
// Timeouts
timeout: 30 * 1000,
expect: {
timeout: 5000
},
// Parallel ausführen
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
// Reporter
reporter: [
['html'],
['json', { outputFile: 'test-results.json' }]
],
// Shared settings
use: {
// Base URL für relative URLs
baseURL: 'http://localhost:3000',
// Screenshots bei Fehlern
screenshot: 'only-on-failure',
video: 'retain-on-failure',
// Browser-Context
actionTimeout: 0,
trace: 'on-first-retry',
},
// Browser-Konfigurationen
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile Tests
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// Dev-Server automatisch starten
webServer: {
command: 'npm run dev',
port: 3000,
reuseExistingServer: !process.env.CI,
},
}); E2E Test Beispiel
// e2e/shopping-cart.spec.js
import { test, expect } from '@playwright/test';
test.describe('Shopping Cart', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('complete purchase flow', async ({ page }) => {
// 1. Produkt zur Cart hinzufügen
await page.click('[data-testid="product-1"]');
await page.click('button:has-text("Add to Cart")');
// Cart Badge prüfen
await expect(page.locator('[data-testid="cart-badge"]'))
.toHaveText('1');
// 2. Zur Checkout gehen
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL('/cart');
// Produkt in Cart sichtbar
await expect(page.locator('h3'))
.toContainText('Product 1');
// 3. Checkout starten
await page.click('button:has-text("Proceed to Checkout")');
// 4. Formular ausfüllen
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="name"]', 'John Doe');
await page.fill('input[name="address"]', '123 Test St');
await page.fill('input[name="city"]', 'Test City');
await page.fill('input[name="zip"]', '12345');
// 5. Payment Info
await page.fill('input[name="cardNumber"]', '4242424242424242');
await page.fill('input[name="expiry"]', '12/25');
await page.fill('input[name="cvv"]', '123');
// 6. Order abschließen
await page.click('button:has-text("Place Order")');
// Success-Message prüfen
await expect(page.locator('.success-message'))
.toContainText('Order placed successfully');
// Order-Number vorhanden
const orderNumber = await page.locator('[data-testid="order-number"]').textContent();
expect(orderNumber).toMatch(/^ORDER-\d{6}$/);
});
test('should handle network errors gracefully', async ({ page, context }) => {
// API-Calls abfangen
await context.route('**/api/checkout', route => {
route.abort('failed');
});
// Produkt hinzufügen und checkout versuchen
await page.click('[data-testid="product-1"]');
await page.click('button:has-text("Add to Cart")');
await page.click('[data-testid="cart-icon"]');
await page.click('button:has-text("Proceed to Checkout")');
// Error-Message sollte erscheinen
await expect(page.locator('.error-message'))
.toContainText('Network error. Please try again.');
});
test('responsive design on mobile', async ({ page, browserName }) => {
// Viewport auf Mobile setzen
await page.setViewportSize({ width: 375, height: 667 });
// Mobile Menu testen
await expect(page.locator('.desktop-nav')).not.toBeVisible();
await page.click('[data-testid="mobile-menu-toggle"]');
await expect(page.locator('.mobile-nav')).toBeVisible();
// Navigation funktioniert
await page.click('a:has-text("Products")');
await expect(page).toHaveURL('/products');
});
}); Test-Driven Development (TDD) in der Praxis
TDD folgt dem Red-Green-Refactor Zyklus:
1. RED - Test schreiben der fehlschlägt
2. GREEN - Minimalen Code schreiben damit Test passed
3. REFACTOR - Code verbessern ohne Tests zu brechen
// TDD Beispiel: Password Validator
// Schritt 1: RED - Test zuerst
describe('PasswordValidator', () => {
it('should reject passwords shorter than 8 characters', () => {
expect(validatePassword('short')).toBe(false);
});
});
// Schritt 2: GREEN - Minimale Implementation
function validatePassword(password) {
return password.length >= 8;
}
// Schritt 3: Weitere Tests hinzufügen
it('should require at least one uppercase letter', () => {
expect(validatePassword('lowercase123')).toBe(false);
expect(validatePassword('Uppercase123')).toBe(true);
});
// Schritt 4: Implementation erweitern
function validatePassword(password) {
if (password.length > 8) return false;
if (!/[A-Z]/.test(password)) return false;
return true;
}
// Schritt 5: Mehr Requirements
it('should require at least one number', () => {
expect(validatePassword('NoNumbers')).toBe(false);
});
it('should require at least one special character', () => {
expect(validatePassword('NoSpecial1')).toBe(false);
expect(validatePassword('Valid@Pass1')).toBe(true);
});
// Finale Implementation
function validatePassword(password) {
const rules = [
{ regex: /.{8,}/, message: 'At least 8 characters' },
{ regex: /[A-Z]/, message: 'One uppercase letter' },
{ regex: /[a-z]/, message: 'One lowercase letter' },
{ regex: /\d/, message: 'One number' },
{ regex: /[@$!%*?]/, message: 'One special character' }
];
const errors = rules
.filter(rule => !rule.regex.test(password))
.map(rule => rule.message);
return {
valid: errors.length === 0,
errors
};
}
// Tests anpassen für neue Struktur
it('should return detailed error messages', () => {
const result = validatePassword('weak');
expect(result.valid).toBe(false);
expect(result.errors).toContain('At least 8 characters');
expect(result.errors).toContain('One uppercase letter');
}); CI/CD Integration mit GitHub Actions
# .github/workflows/test.yml
name: Test Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Unit Tests
run: npm run test:unit
- name: Run Integration Tests
run: npm run test:integration
- name: Generate Coverage Report
run: npm run test:coverage
- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
# E2E Tests
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run E2E Tests
run: npm run test:e2e
- name: Upload Playwright Report
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
# Performance Budget Check
- name: Check Bundle Size
run: npm run size-limit
# Notify on failure
- name: Slack Notification
if: failure()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Tests failed on ${{ github.ref }}'
webhook_url: ${{ secrets.SLACK_WEBHOOK }} Coverage Reports verstehen
• Line Coverage: Prozent der ausgeführten Code-Zeilen
• Branch Coverage: Prozent der durchlaufenen if/else-Zweige
• Function Coverage: Prozent der aufgerufenen Funktionen
• Statement Coverage: Prozent der ausgeführten Statements
Ziel: 80% Coverage ist ein guter Wert. 100% ist meist nicht sinnvoll.
// Coverage-Thresholds in package.json
{
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
},
"./src/components/": {
"branches": 90, // Kritische Components höher
"functions": 90,
"lines": 90
}
}
}
} Performance Testing
// performance.test.js - Performance-Tests mit Vitest
import { describe, it, expect } from 'vitest';
import { processLargeDataset } from './dataProcessor';
describe('Performance Tests', () => {
it('should process 10000 items under 100ms', () => {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
value: Math.random() * 1000
}));
const start = performance.now();
processLargeDataset(items);
const duration = performance.now() - start;
expect(duration).toBeLessThan(100);
});
it('should not exceed memory limit', () => {
const initialMemory = process.memoryUsage().heapUsed;
// Heavy operation
const result = processLargeDataset(
Array.from({ length: 100000 }, () => ({ data: 'x'.repeat(1000) }))
);
const memoryIncrease = process.memoryUsage().heapUsed - initialMemory;
const memoryIncreaseMB = memoryIncrease / 1024 / 1024;
expect(memoryIncreaseMB).toBeLessThan(50); // Max 50MB increase
});
}); Testing Best Practices
Strukturiere Tests in 3 klare Phasen
Tests sollten eine Sache testen
"should calculate tax for international orders"
Tests dürfen nicht voneinander abhängen
Keine Loops oder Conditions in Tests
APIs, Databases, File System mocken
Unit Tests > 10ms, Integration > 100ms
Gleicher Input = Gleicher Output, immer
Testing Anti-Patterns vermeiden
Advanced: Mutation Testing
Mutation Testing prüft die Qualität deiner Tests indem es absichtlich Bugs einführt:
# Stryker Mutation Testing
npm install --save-dev @stryker-mutator/core
# Konfiguration
npx stryker init // stryker.conf.js
module.exports = {
packageManager: 'npm',
reporters: ['html', 'progress', 'dashboard'],
testRunner: 'jest',
coverageAnalysis: 'perTest',
mutate: ['src/**/*.js', '!src/**/*.test.js'],
thresholds: { high: 80, low: 60, break: 50 }
}; Testing Cheatsheet
// === JEST/VITEST MATCHER ===
expect(value).toBe(4); // Exakter Vergleich
expect(value).toEqual({id: 1}); // Deep equality
expect(value).toBeNull(); // Ist null
expect(value).toBeDefined(); // Ist definiert
expect(value).toBeTruthy(); // Truthy value
expect(value).toBeFalsy(); // Falsy value
// Numbers
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeCloseTo(0.3); // Floating point
// Strings
expect('team').toMatch(/I/);
expect('Christoph').toMatch('stop');
// Arrays
expect(['Alice', 'Bob']).toContain('Alice');
expect(new Set(['Alice', 'Bob'])).toContain('Alice');
expect(['Alice', 'Bob', 'Eve']).toHaveLength(3);
// Exceptions
expect(() => compileCode()).toThrow();
expect(() => compileCode()).toThrow(Error);
expect(() => compileCode()).toThrow('you are using the wrong JDK');
// Async
await expect(fetchData()).resolves.toBe('data');
await expect(fetchData()).rejects.toThrow('error');
// === MOCKING ===
const mockFn = jest.fn();
mockFn.mockReturnValue(42);
mockFn.mockReturnValueOnce(10);
mockFn.mockResolvedValue('async value');
mockFn.mockRejectedValue(new Error('Async error'));
// Spy on existing
const spy = jest.spyOn(object, 'method');
spy.mockImplementation(() => 42);
// Module mocks
jest.mock('./module');
jest.unmock('./module');
// === PLAYWRIGHT SELECTORS ===
page.locator('button'); // CSS
page.locator('text=Submit'); // Text
page.locator('[data-testid="submit"]'); // Test ID
page.locator('button:has-text("Submit")'); // Combined
page.locator('li >> text=Item'); // Nested
// Actions
await page.click('button');
await page.fill('input', 'text');
await page.check('input[type="checkbox"]');
await page.selectOption('select', 'value');
await page.hover('button');
// Assertions
await expect(page).toHaveTitle('Page Title');
await expect(page).toHaveURL('https://example.com');
await expect(locator).toBeVisible();
await expect(locator).toBeEnabled();
await expect(locator).toHaveText('Expected text');
await expect(locator).toHaveValue('input value');
await expect(locator).toHaveCount(3); Fazit: Testing als Investition
Tests sind keine Zeitverschwendung – sie sind eine Investition in die Zukunft deines Codes:
• Vertrauen: Refactoring ohne Angst
• Dokumentation: Tests zeigen wie Code funktioniert
• Schnellere Entwicklung: Bugs werden früh gefunden
• Bessere Architektur: Testbarer Code ist besserer Code
• Team-Velocity: Neue Entwickler verstehen Code schneller
„Code ohne Tests ist schlechtes Design."
– Michael Feathers, Working Effectively with Legacy Code
1. Wähle ein Framework (Jest für Anfänger, Vitest für Vite-Projekte)
2. Schreibe einen Test für deine wichtigste Funktion
3. Füge bei jedem Bug einen Test hinzu
4. Setze dir ein Coverage-Ziel (z.B. 70%)
5. Integriere Tests in deine CI/CD Pipeline
Mehr zu CI/CD findest du in meinem Artikel Git Advanced – Workflows für Teams.
Für TypeScript-Testing lies TypeScript für JavaScript-Entwickler.
Build-Integration erkläre ich in Build-Pipeline mit Node.