Mock Frameworks für TypeScript & Vite: Ein Überlebensratgeber
Oder: Wie ich lernte, die Bombe zu lieben und aufhörte, mir Sorgen um ungetesteten Code zu machen
Einleitung
Willkommen in der wunderbaren Welt des Testens, wo dein Code entweder funktioniert oder – wie die meisten Beziehungen – in einem spektakulären Feuerball endet, sobald er in Produktion geht. Aber keine Sorge: Mit den richtigen Mock-Frameworks kannst du zumindest so tun, als hättest du alles unter Kontrolle.
Schreib Tests. Nicht zu viele. Hauptsächlich Integration. — Guillermo Rauch, der Mann, der offenbar nie versucht hat, einen Legacy-Monolithen zu testen
Teil 1: Vitest – Der neue Sheriff in der Stadt
Warum Vitest?
Jest ist tot. Okay, nicht wirklich – aber Vitest ist schneller, moderner und wurde speziell für Vite entwickelt. Es ist wie der Unterschied zwischen einem Fax und einer E-Mail: Beides funktioniert, aber nur eines davon lässt dich nicht wie ein Fossil aussehen.
Vitest bietet:
- Native ESM-Unterstützung (weil CommonJS so 2015 ist)
- Blitzschnelle Ausführung durch Vite's HMR
- Jest-kompatible API (für alle, die ihre Muskelgedächtnis nicht umtrainieren wollen)
- TypeScript-Support out of the box
Die heilige Dreifaltigkeit des Mockens
1. vi.fn() – Der Alleskönner
import {vi, describe, it, expect} from 'vitest';
const mockCallback = vi.fn((x: number) => x + 1);
describe('Mein brillanter Code', () => {
it('ruft den Callback auf (hoffentlich)', () => {
[1, 2, 3].forEach(mockCallback);
expect(mockCallback).toHaveBeenCalledTimes(3);
expect(mockCallback.mock.results[0].value).toBe(2);
});
});
2. vi.spyOn() – Der stille Beobachter
Wenn du wissen willst, was dein Code wirklich treibt, ohne ihn zu ersetzen. Wie eine Überwachungskamera, nur legal.
import {vi, describe, it, expect} from 'vitest';
const calculator = {
add: (a: number, b: number) => a + b,
};
describe('Calculator Surveillance', () => {
it('beobachtet die Addition', () => {
const spy = vi.spyOn(calculator, 'add');
calculator.add(2, 3);
expect(spy).toHaveBeenCalledWith(2, 3);
spy.mockRestore(); // Spuren verwischen
});
});
3. vi.mock() – Der Modul-Terminator
Für wenn du ein ganzes Modul ersetzen musst, weil es sich schlecht benimmt:
import {vi} from 'vitest';
import type * as ApiModule from './api';
// Wird an den Anfang der Datei gehoistet – wie dein Kater nach einer Firmenfeier
vi.mock('./api', async () => {
const actual = await vi.importActual<typeof ApiModule>('./api');
return {
...actual,
fetchData: vi.fn().mockResolvedValue({data: 'gemockte Daten'}),
};
});
TypeScript-Typsicherheit beim Mocken
Das Problem: TypeScript weiß nicht, dass deine gemockte Funktion plötzlich mockReturnValue kann.
Die Lösung: vi.mocked()
import {vi, describe, it, expect} from 'vitest';
import {fetchUser} from './userService';
vi.mock('./userService');
describe('User Service Tests', () => {
it('gibt einen gemockten User zurück', async () => {
// TypeScript ist jetzt glücklich
vi.mocked(fetchUser).mockResolvedValue({
id: 1,
name: 'Hans Testermann',
});
const user = await fetchUser(1);
expect(user.name).toBe('Hans Testermann');
});
});
Teil 2: Mock Service Worker (MSW) – Der Netzwerk-Ninja
Das Problem mit traditionellem API-Mocking
Du könntest vi.mock('axios') verwenden. Du könntest auch mit einem Löffel ein Loch durch eine Wand graben. Beides
funktioniert technisch, aber nur eines davon ist intelligent.
Warum MSW?
MSW fängt HTTP-Requests auf der Netzwerkebene ab. Das bedeutet:
- Dein Code bleibt unberührt – Kein Stubbing von
fetchoderaxios - DevTools zeigen die Requests – Debugging ohne Blindflug
- Ein Mock für alles – Browser, Node.js, Tests, Entwicklung
Setup für Vitest + MSW
// src/mocks/handlers.ts
import {http, HttpResponse} from 'msw';
export const handlers = [
http.get('https://api.example.com/users', () => {
return HttpResponse.json([
{id: 1, name: 'Alice'},
{id: 2, name: 'Bob (der mit dem Bug)'},
]);
}),
http.post('https://api.example.com/users', async ({request}) => {
const body = await request.json();
return HttpResponse.json(
{id: 3, ...body},
{status: 201}
);
}),
// Fehler simulieren – weil Murphy's Law real ist
http.get('https://api.example.com/unstable', () => {
return HttpResponse.error();
}),
];
// src/mocks/server.ts
import {setupServer} from 'msw/node';
import {handlers} from './handlers';
export const server = setupServer(...handlers);
// vitest.setup.ts
import {beforeAll, afterEach, afterAll} from 'vitest';
import {server} from './src/mocks/server';
beforeAll(() => server.listen({onUnhandledRequest: 'error'}));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Request-Override pro Test
import {http, HttpResponse} from 'msw';
import {server} from '../mocks/server';
it('behandelt Server-Fehler wie ein Profi', async () => {
// Temporärer Override für diesen Test
server.use(
http.get('https://api.example.com/users', () => {
return HttpResponse.json(
{message: 'Alles ist kaputt'},
{status: 500}
);
})
);
// Test deine Error-Handling-Logik
await expect(fetchUsers()).rejects.toThrow();
});
Teil 3: Frontend-Tests – Wo ziehen wir die Grenze?
Die Testing Trophy vs. Testing Pyramid
Die alte Pyramide sagte: „Viele Unit-Tests, wenige E2E-Tests."
Kent C. Dodds kam 2018 und sagte: „Quatsch. Integration ist König."
Die Testing Trophy priorisiert:
🏆
E2E
╔═════════╗
║ Integ- ║
║ ration ║ ← Hier ist die Party
╚═════════╝
╔═════════╗
║ Unit ║
╚═════════╝
╔═════════╗
║ Static ║ ← TypeScript & ESLint
╚═════════╝
Die goldene Regel
„Je mehr deine Tests dem echten Benutzerverhalten ähneln, desto mehr Vertrauen geben sie dir."
Wo sind die sinnvollen Grenzen?
✅ Was du testen solltest
| Kategorie | Beispiel | Warum |
|---|---|---|
| Business-Logik | Preisberechnung, Validierung | Hier verstecken sich die teuren Bugs |
| User Flows | Login, Checkout | Der Benutzer macht das täglich |
| Edge Cases | Leere Listen, Netzwerkfehler | Murphy schläft nie |
| Accessibility | Keyboard-Navigation | Weil wir keine Monster sind |
❌ Was du NICHT testen solltest
| Kategorie | Beispiel | Warum nicht |
|---|---|---|
| Implementierungsdetails | CSS-Klassennamen | Ändern sich ständig, Tests brechen |
| Third-Party-Bibliotheken | React Router funktioniert | Die testen das selbst |
| Triviale Getter/Setter | getName() { return this.name } | Wenn das kaputt geht, hast du größere Probleme |
| Snapshot-Tests für alles | Jede Komponente | Snapshot-Blindheit ist real |
Die 80/20-Regel für Frontend-Tests
Aufwand vs. Vertrauen
100% Coverage ════════════════════════╗
║ ← Wahnsinn liegt hier
80% Coverage ════════════════╗ ║
║ ║
║ Dimishing
║ Returns
60% Coverage ════════╗ ║ ║
║ ║ ║
║ ║ ║
Sweet ║ ║ ║
Spot ║ ║ ║
──────────────────────╩───────╩───────╩────→ Aufwand
Teil 4: Best Practices – Von Leuten, die schon gelitten haben
1. Arrange-Act-Assert (AAA)
it('fügt einen Artikel zum Warenkorb hinzu', () => {
// Arrange – Bereite die Bühne vor
const cart = new ShoppingCart();
const product = {id: 1, name: 'Kaffee', price: 9.99};
// Act – Führe die Aktion aus
cart.addItem(product);
// Assert – Überprüfe das Ergebnis
expect(cart.items).toHaveLength(1);
expect(cart.total).toBe(9.99);
});
2. Jeder Test sollte isoliert sein
// ❌ SCHLECHT: Tests beeinflussen sich gegenseitig
let counter = 0;
it('Test 1', () => {
counter++;
expect(counter).toBe(1);
});
it('Test 2', () => {
expect(counter).toBe(0); // BOOM! Fehlschlag
});
// ✅ GUT: Setup in beforeEach
describe('Counter Tests', () => {
let counter: number;
beforeEach(() => {
counter = 0;
});
it('Test 1', () => {
counter++;
expect(counter).toBe(1);
});
it('Test 2', () => {
expect(counter).toBe(0); // Funktioniert!
});
});
3. Testing Library: User-First
import {render, screen, userEvent} from '@testing-library/react';
import {LoginForm} from './LoginForm';
it('zeigt Fehlermeldung bei ungültiger E-Mail', async () => {
const user = userEvent.setup();
render(<LoginForm / >);
// ❌ SCHLECHT: Implementation-Detail
// const input = container.querySelector('input.email-input');
// ✅ GUT: Wie ein User
const emailInput = screen.getByLabelText(/e-mail/i);
await user.type(emailInput, 'keine-email');
await user.click(screen.getByRole('button', {name: /anmelden/i}));
expect(screen.getByRole('alert')).toHaveTextContent(/ungültige e-mail/i);
});
4. Mock nur das Nötigste
// ❌ SCHLECHT: Alles mocken
vi.mock('react');
vi.mock('react-dom');
vi.mock('./utils');
vi.mock('./helpers');
vi.mock('./services');
// Herzlichen Glückwunsch, du testest jetzt hauptsächlich deine Mocks
// ✅ GUT: Nur externe Abhängigkeiten
vi.mock('./api'); // API-Calls mocken
// Den Rest laufen lassen
Teil 5: TDD – Test Driven Development
Der Red-Green-Refactor-Zyklus
╔═══════════════════════════════════════════════════════╗
║ ║
▼ ║
┌───────┐ ┌───────┐ ┌───────────┐ ║
│ RED │───▶│ GREEN │───▶│ REFACTOR │────────────────────╯
└───────┘ └───────┘ └───────────┘
Schreib Schreib Verbessere
einen Code, der den Code
Test, der den Test (Tests
fehlschlägt besteht bleiben grün)
TDD im Frontend: Ein Beispiel
Anforderung: Eine Suchfunktion, die Ergebnisse filtert
// Schritt 1: RED – Test schreiben
import {filterProducts} from './search';
describe('filterProducts', () => {
it('filtert Produkte nach Suchbegriff', () => {
const products = [
{id: 1, name: 'Laptop'},
{id: 2, name: 'Lapislazuli'},
{id: 3, name: 'Telefon'},
];
const result = filterProducts(products, 'Lap');
expect(result).toHaveLength(2);
expect(result[0].name).toBe('Laptop');
});
});
// Schritt 2: GREEN – Minimaler Code
export function filterProducts(
products: Product[],
query: string
): Product[] {
return products.filter((p) =>
p.name.toLowerCase().includes(query.toLowerCase())
);
}
// Schritt 3: REFACTOR – Verbessern
export function filterProducts(
products: Product[],
query: string
): Product[] {
if (!query.trim()) return products;
const normalizedQuery = query.toLowerCase();
return products.filter((product) =>
product.name.toLowerCase().includes(normalizedQuery)
);
}
Wann TDD sinnvoll ist
| Situation | TDD empfohlen? | Begründung |
|---|---|---|
| Neue Business-Logik | ✅ Ja | Klare Anforderungen, testbar |
| Bug-Fix | ✅ Ja | Erst Test, der Bug reproduziert |
| UI-Prototyping | ❌ Nein | Zu viel Fluktuation |
| Performance-Optimierung | ⚠️ Vielleicht | Benchmark-Tests sind anders |
| Third-Party-Integration | ❌ Nein | Deren Code, deren Tests |
Teil 6: CI/CD – Automatisierung ist kein Luxus
Die Pipeline des Vertrauens
# .github/workflows/test.yml
name: Test Pipeline
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
# Statische Analyse – Der erste Verteidigungswall
- name: TypeScript Check
run: npx tsc --noEmit
- name: ESLint
run: npm run lint
# Unit & Integration Tests
- name: Run Tests
run: npm run test:ci
# Coverage Report
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
e2e:
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run E2E Tests
run: npm run test:e2e
Vitest CI-Konfiguration
// vitest.config.ts
import {defineConfig} from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
exclude: [
'node_modules/**',
'src/**/*.test.ts',
'src/**/*.d.ts',
'src/mocks/**',
],
thresholds: {
// Nicht zu ambitioniert – wir sind realistisch
lines: 70,
functions: 70,
branches: 60,
statements: 70,
},
},
// Für CI-Umgebungen
...(process.env.CI && {
minThreads: 1,
maxThreads: 2,
reporters: ['verbose', 'junit'],
outputFile: './test-results/junit.xml',
}),
},
});
Pre-Commit Hooks mit Husky + lint-staged
// package.json
{
"scripts": {
"prepare": "husky install"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"vitest related --run"
]
}
}
Teil 7: Alternativen und Ergänzungen
API-Mocking Alternativen
| Tool | Anwendungsfall | Pros | Cons |
|---|---|---|---|
| MSW | Frontend + Node.js | Netzwerk-Level, ein Mock überall | Lernkurve |
| Nock | Node.js Backend | Einfach, etabliert | Nur Node.js |
| Mockoon | Desktop GUI | Visuell, OpenAPI-Import | Separater Server |
| WireMock | Enterprise, Java | Mächtig, Contract Testing | Java-lastig |
| Mirage.js | Frontend mit Data Layer | Eigene DB-Simulation | Komplexer |
Test-Runner Alternativen
| Tool | Stärke | Beste für |
|---|---|---|
| Vitest | Geschwindigkeit, Vite-Integration | Moderne Vite-Projekte |
| Jest | Ökosystem, Dokumentation | Legacy, Create React App |
| Playwright Test | E2E + Unit | Wenn Playwright schon da ist |
Contract Testing
Für Microservices und API-First-Development:
// Mit Pact
import {Pact} from '@pact-foundation/pact';
const provider = new Pact({
consumer: 'Frontend',
provider: 'UserService',
});
describe('User API Contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
it('erwartet einen User mit id und name', async () => {
await provider.addInteraction({
state: 'ein User existiert',
uponReceiving: 'eine Anfrage nach User 1',
withRequest: {
method: 'GET',
path: '/users/1',
},
willRespondWith: {
status: 200,
body: {
id: 1,
name: Matchers.string('Alice'),
},
},
});
const user = await fetchUser(1);
expect(user.id).toBe(1);
});
});
Fazit: Die Kunst des pragmatischen Testens
Die wichtigsten Takeaways
- Vitest + MSW = Das Dream-Team für moderne TypeScript/Vite-Projekte
- Integration > Unit für die meisten Frontend-Szenarien
- Teste Verhalten, nicht Implementierung
- 80% Coverage ist besser als 100% Burnout
- Automatisiere alles in CI/CD – dein zukünftiges Ich wird dir danken
Der ultimative Test-Entscheidungsbaum
Soll ich das testen?
│
▼
Kann es kaputt gehen?
│
┌────┴────┐
│ │
Nein Ja
│ │
▼ ▼
Nicht Wenn es kaputt geht,
testen ist es dann peinlich?
│
┌────┴────┐
│ │
Nein Ja
│ │
▼ ▼
Vielleicht TESTEN!
testen (Jetzt!)
Weiterführende Ressourcen
Geschrieben mit Koffein, Verzweiflung und der stillen Hoffnung, dass dieser Code nie wieder angefasst werden muss.
Letzte Aktualisierung: November 2025