← Alle Beiträge

TypeScript Testing Patterns: Unit-, Integrations- und E2E-Strategien die skalieren

Matthias Bruns · · 8 Min. Lesezeit
TypeScript Testing E2E Quality

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.

Lesebarkeit

Schriftgröße