TypeScript Testing Patterns: Unit-, Integrations- und E2E-Strategien die skalieren
Das Typsystem von TypeScript fängt viele Bugs zur Compile-Zeit ab, aber das eliminiert nicht die Notwendigkeit für umfassende Tests. Tatsächlich erfordern TypeScript-Anwendungen eine nuancierte Teststrategie, die sowohl die Vorteile der statischen Typisierung als auch traditionelle Testpraktiken nutzt, um Codequalität in großem Maßstab sicherzustellen.
Die Herausforderung liegt nicht nur im Schreiben von Tests – es geht darum, eine Testarchitektur aufzubauen, die mit der Codebasis mitwächst, ohne zum Wartungsalptraum zu werden. Dieser Leitfaden behandelt praktische Patterns für Unit-, Integrations- und End-to-End-Tests, die in echten Produktionsumgebungen funktionieren.
Warum TypeScript-Testing anders ist
TypeScript Unit-Testing unterscheidet sich grundlegend von herkömmlichem JavaScript-Testing. Das Typsystem eliminiert ganze Klassen von Laufzeitfehlern, was bedeutet, dass du deine Testbemühungen auf Geschäftslogik konzentrieren kannst, anstatt auf grundlegende Typfehler.
Das schafft jedoch neue Herausforderungen. Du brauchst Testkonfigurationen, die mit TypeScripts Kompilierungsprozess funktionieren, und musst entscheiden, wie sehr du auf Typen versus Laufzeitvalidierung in deinen Test-Assertions vertraust.
Die Belohnung ist erheblich: weniger Tests insgesamt, aber höherwertige Tests, die sich auf tatsächlichen Geschäftswert konzentrieren, anstatt triviale Fehler abzufangen.
Das Fundament für TypeScript-Testing aufbauen
Compiler-Konfiguration für Tests
Dein Testing-Setup braucht eine TypeScript-Konfiguration, die Kompilierungsgeschwindigkeit mit Debugging-Fähigkeiten ausbalanciert. Hier ist eine bewährte tsconfig.json-Konfiguration für Tests:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": true,
"outDir": "./dist"
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
Vermeide die outfile-Option in deiner Testkonfiguration – sie bricht die Testerkennung in den meisten IDEs.
Framework-Auswahlstrategie
Das TypeScript-Testing-Ökosystem bietet mehrere ausgereifte Optionen. Jest bleibt die beliebteste Wahl, aber Vitest gewinnt an Zugkraft durch seine native TypeScript-Unterstützung und schnellere Ausführung.
Für Jest mit TypeScript verwende diese Konfiguration:
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node', // oder 'jsdom' für Frontend
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.interface.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
}
};
Diese Konfiguration stellt sicher, dass ts-jest deine TypeScript-Dateien korrekt verarbeitet und ordentliche Source-Map-Unterstützung für Debugging aufrechterhält.
Unit-Testing-Patterns die skalieren
Das Arrange-Act-Assert-Pattern
Das AAA-Pattern erstellt wartbare Unit-Tests, indem es Testcode in drei verschiedene Abschnitte organisiert. So funktioniert es mit TypeScript:
// userService.test.ts
import { UserService } from '../services/UserService';
import { User } from '../types/User';
describe('UserService', () => {
let userService: UserService;
beforeEach(() => {
userService = new UserService();
});
it('should create user with valid data', () => {
// Arrange
const userData: Omit<User, 'id'> = {
email: 'test@example.com',
name: 'Test User',
role: 'user'
};
// Act
const result = userService.createUser(userData);
// Assert
expect(result).toHaveProperty('id');
expect(result.email).toBe(userData.email);
expect(result.name).toBe(userData.name);
expect(result.role).toBe(userData.role);
});
});
Testdaten-Management mit dem Prototype-Pattern
Das Prototype-Pattern hilft bei der effizienten Verwaltung von Testdaten, indem es dir erlaubt, Testobjekte zu klonen und zu modifizieren:
// testDataFactory.ts
export class TestDataFactory {
private static baseUser: User = {
id: '1',
email: 'default@example.com',
name: 'Default User',
role: 'user',
createdAt: new Date('2024-01-01')
};
static createUser(overrides: Partial<User> = {}): User {
return {
...this.baseUser,
...overrides,
id: overrides.id || Math.random().toString(36)
};
}
}
// Verwendung in Tests
const adminUser = TestDataFactory.createUser({
role: 'admin',
email: 'admin@example.com'
});
Mock-Komplexität reduzieren
Minimiere Mocks um brüchige Tests zu vermeiden. Anstatt jede Abhängigkeit zu mocken, verwende Dependency Injection und Test Doubles:
// Anstatt schwerer Mocks
interface EmailService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}
class MockEmailService implements EmailService {
public sentEmails: Array<{to: string, subject: string, body: string}> = [];
async sendEmail(to: string, subject: string, body: string): Promise<void> {
this.sentEmails.push({ to, subject, body });
}
}
// Sauberer Test ohne komplexe Jest-Mocks
it('should send welcome email on user creation', async () => {
const mockEmailService = new MockEmailService();
const userService = new UserService(mockEmailService);
await userService.createUser({
email: 'new@example.com',
name: 'New User'
});
expect(mockEmailService.sentEmails).toHaveLength(1);
expect(mockEmailService.sentEmails[0].to).toBe('new@example.com');
});
Integrationstest-Strategien
Integrationstests überprüfen, ob deine Anwendungskomponenten korrekt zusammenarbeiten. In TypeScript-Anwendungen fokussieren sich diese Tests oft auf API-Endpunkte, Datenbankinteraktionen und Service-Integrationen.
Datenbank-Integrationstests
// userRepository.integration.test.ts
import { UserRepository } from '../repositories/UserRepository';
import { TestDatabase } from '../test-utils/TestDatabase';
describe('UserRepository Integration', () => {
let repository: UserRepository;
let testDb: TestDatabase;
beforeAll(async () => {
testDb = new TestDatabase();
await testDb.setup();
repository = new UserRepository(testDb.connection);
});
afterAll(async () => {
await testDb.teardown();
});
beforeEach(async () => {
await testDb.clear();
});
it('should persist and retrieve user correctly', async () => {
const userData = {
email: 'integration@example.com',
name: 'Integration Test User'
};
const savedUser = await repository.save(userData);
const retrievedUser = await repository.findById(savedUser.id);
expect(retrievedUser).toBeDefined();
expect(retrievedUser!.email).toBe(userData.email);
expect(retrievedUser!.createdAt).toBeInstanceOf(Date);
});
});
API-Integrationstests
// userApi.integration.test.ts
import request from 'supertest';
import { app } from '../app';
import { TestDatabase } from '../test-utils/TestDatabase';
describe('User API Integration', () => {
let testDb: TestDatabase;
beforeAll(async () => {
testDb = new TestDatabase();
await testDb.setup();
});
afterAll(async () => {
await testDb.teardown();
});
it('should create user via POST /users', async () => {
const userData = {
email: 'api@example.com',
name: 'API Test User'
};
const response = await request(app)
.post('/users')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body.email).toBe(userData.email);
expect(response.body.name).toBe(userData.name);
});
});
End-to-End-Testing-Frameworks und Patterns
Framework-Auswahl für E2E-Testing
Die E2E-Testing-Landschaft bietet mehrere ausgereifte Optionen. Playwright hat sich als führende Wahl für TypeScript-Anwendungen etabliert, dank seiner nativen TypeScript-Unterstützung und umfassenden Browser-Abdeckung.
// 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 ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
],
webServer: {
command: 'npm run start',
port: 3000,
},
});
Page-Object-Pattern für wartbare E2E-Tests
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByTestId('email-input');
this.passwordInput = page.getByTestId('password-input');
this.loginButton = page.getByRole('button', { name: 'Login' });
this.errorMessage = page.getByTestId('error-message');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
}
async expectErrorMessage(message: string) {
await expect(this.errorMessage).toHaveText(message);
}
}
// tests/login.e2e.test.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test.describe('User Authentication', () => {
test('should display error for invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await page.goto('/login');
await loginPage.login('invalid@example.com', 'wrongpassword');
await loginPage.expectErrorMessage('Invalid email or password');
});
});
Testorganisation und -struktur
Ordnerstruktur die skaliert
Gute Teststruktur erfordert klare Namenskonventionen und logische Organisation:
src/
├── components/
│ ├── UserCard.tsx
│ └── __tests__/
│ └── UserCard.test.tsx
├── services/
│ ├── UserService.ts
│ └── __tests__/
│ ├── UserService.test.ts
│ └── UserService.integration.test.ts
├── utils/
│ ├── validation.ts
│ └── __tests__/
│ └── validation.test.ts
└── test-utils/
├── TestDatabase.ts
├── TestDataFactory.ts
└── setupTests.ts
e2e/
├── pages/
│ ├── LoginPage.ts
│ └── DashboardPage.ts
├── fixtures/
│ └── testData.ts
└── tests/
├── authentication.e2e.test.ts
└── userManagement.e2e.test.ts
Test-Namenskonventionen
Verwende aussagekräftige Testnamen, die das Szenario und das erwartete Ergebnis erklären:
describe('UserService', () => {
describe('createUser', () => {
it('should create user with generated ID when valid data provided', () => {
// Test-Implementierung
});
it('should throw ValidationError when email format is invalid', () => {
// Test-Implementierung
});
it('should throw ConflictError when email already exists', () => {
// Test-Implementierung
});
});
});
Performance- und Debugging-Strategien
IDE-Integration
Moderne IDEs bieten exzellente TypeScript-Testing-Unterstützung. IntelliJ IDEA und VS Code bieten beide eingebaute Test-Runner, die mit ts-node funktionieren und es dir ermöglichen, Tests ohne Kompilierung auszuführen und zu debuggen.
Für VS Code füge diese Konfiguration zu deiner .vscode/launch.json hinzu:
{
"type": "node",
"request": "launch",
"name": "Debug Jest Tests",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
Test-Performance-Optimierung
Verwende parallele Ausführung für schnellere Testläufe:
// jest.config.js
module.exports = {
preset: 'ts-jest',
maxWorkers: '50%', // Verwende die Hälfte der verfügbaren CPU-Kerne
testTimeout: 10000,
setupFilesAfterEnv: ['<rootDir>/src/test-utils/setupTests.ts'],
globalSetup: '<rootDir>/src/test-utils/globalSetup.ts',
globalTeardown: '<rootDir>/src/test-utils/globalTeardown.ts'
};
CI/CD-Integrations-Patterns
GitHub Actions Konfiguration
# .github/workflows/test.yml
name: Test Suite
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:13
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run type-check
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
run: npm run test:integration
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test
- name: Run E2E tests
run: npm run test:e2e
- name: Upload coverage reports
uses: codecov/codecov-action@v3
Eine skalierbare Teststrategie aufbauen
Der Schlüssel zu skalierbarem TypeScript-Testing liegt in der strategischen Schichtung deiner Testtypen. Unit-Tests sollten deine Geschäftslogik und Utility-Funktionen abdecken. Integrationstests sollten verifizieren, dass deine Services korrekt zusammenarbeiten. E2E-Tests sollten kritische User-Journeys validieren.
Beginne mit einem soliden Fundament aus Unit-Tests, füge Integrationstests für komplexe Interaktionen hinzu und verwende E2E-Tests sparsam für die wichtigsten User-Flows. Dieser Ansatz gibt dir Vertrauen in deinen Code, ohne eine Wartungslast zu schaffen.
Denk daran, dass TypeScripts Typsystem deine erste Verteidigungslinie gegen Bugs ist. Nutze es, um ganze Klassen von Tests zu eliminieren, und konzentriere dann deine Testbemühungen auf die Logik, die für deine Nutzer wirklich wichtig ist.
Die hier beschriebenen Testing-Patterns funktionieren in Produktionsumgebungen, weil sie umfassende Abdeckung mit Wartbarkeit ausbalancieren. Implementiere sie schrittweise, und du wirst eine Test-Suite aufbauen, die mit deiner Anwendung mitwächst, anstatt sie zu bremsen.