Skip to main content
backend2. November 202511 Min. Lesezeit

Unit Testing 2026: Keine Ausreden mehr

Die Tools haben aufgeholt. KI schreibt Test-Gerueste, Frameworks sind schnell, und es gibt keinen guten Grund mehr, ungetesteten Code auszuliefern.

testingvitestjest
Unit Testing 2026: Keine Ausreden mehr

Vor drei Jahren waren die Ausreden, keine Tests zu schreiben, zumindest plausibel. Test-Frameworks waren langsam. Mocks aufzusetzen war muehsam. Tests zu schreiben dauerte laenger als den Code zu schreiben. Der ROI war fuer ein 3-Personen-Startup im Sprint zum Product-Market Fit nicht offensichtlich. Ich stimmte diesen Ausreden nicht zu, aber ich verstand sie.

2026 sind diese Ausreden verschwunden. Vitest fuehrt eine vollstaendige Testsuite in der Zeit aus, die Jest frueher zum Hochfahren brauchte. KI-Tools erstellen umfassende Testdateien aus einer Funktionssignatur. TypeScript faengt ganze Kategorien von Bugs zur Compile-Zeit ab und grenzt ein, was tatsaechlich getestet werden muss. Docker Compose startet echte Datenbanken fuer Integrationstests in Sekunden. Die Luecke zwischen "keine Tests" und "gut getestet" war noch nie kleiner.

Die moderne Testing-Landschaft

Das Testing-Oekosystem hat sich um einige exzellente Tools konsolidiert, und die Fragmentierung, die das Test-Setup frueher zu einem Forschungsprojekt machte, ist weitgehend verschwunden.

Vitest vs. Jest: Die Entscheidung ist gefallen

Fuer jedes neue Projekt ist Vitest die Standardwahl. Nicht weil Jest schlecht ist — Jest ist ein solides, kampferprobtes Framework — sondern weil Vitest in jeder Dimension, die fuer moderne Entwicklung zaehlt, messbar besser ist.

Geschwindigkeit: Vitest verwendet esbuild fuer die Transformation und fuehrt Tests in Worker-Threads mit nativer ESM-Unterstuetzung aus. In meinen Projekten laeuft die gleiche Testsuite unter Vitest 3-5x schneller als unter Jest. Eine Suite, die in Jest 45 Sekunden brauchte, ist in Vitest in 12 Sekunden fertig.

Konfiguration: Vitest verwendet Ihre Vite-Konfiguration wieder. Wenn Sie Vite bereits fuer Ihren Build verwenden (und 2026 tun das die meisten Projekte), gibt es null zusaetzliche Konfiguration. Vergleichen Sie das mit Jests moduleNameMapper, transform, transformIgnorePatterns und der unvermeidlichen Fehlersuche, warum ein ESM-Paket nicht funktioniert.

Kompatibilitaet: Vitest implementiert eine Jest-kompatible API. describe, it, expect, beforeEach, afterEach, vi.fn(), vi.mock() — alles funktioniert wie erwartet. Die Migration von Jest zu Vitest ist groesstenteils ein Suchen-und-Ersetzen von jest.fn() zu vi.fn().

// vitest.config.ts — this is often all you need
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'dist/', '**/*.d.ts', '**/*.config.*'],
    },
  },
});

Wann bei Jest bleiben: Wenn Sie eine grosse bestehende Jest-Testsuite (500+ Tests) haben und keine unmittelbaren Schmerzpunkte, ist die Migration nicht dringend. Jest erhaelt weiterhin Updates und funktioniert einwandfrei. Aber fuer neue Projekte oder Projekte mit weniger als 100 Tests ist Vitest die offensichtliche Wahl.

Die Test-Pyramide in der Praxis

Die traditionelle Test-Pyramide (viele Unit-Tests, weniger Integrationstests, wenige E2E-Tests) bleibt im Prinzip korrekt, aber die Grenzen haben sich verschoben.

Unit-Tests verifizieren einzelne Funktionen, Utility-Methoden und reine Geschaeftslogik. Sie sollten schnell sein (unter 10ms pro Stueck), keine externen Abhaengigkeiten haben und Verhalten statt Implementierung testen.

Integrationstests verifizieren, dass Komponenten zusammenarbeiten — API-Routen mit Datenbankabfragen, Service-Methoden mit externen API-Aufrufen, React-Komponenten mit ihrem State-Management. Diese sind langsamer (100-500ms pro Stueck), fangen aber Bugs, die Unit-Tests uebersehen.

E2E-Tests verifizieren vollstaendige Nutzer-Workflows durch die tatsaechliche Anwendung. Diese sind am langsamsten (5-30 Sekunden pro Stueck) und am fragilst, fangen aber Bugs, die nichts anderes findet. Tools wie Playwright haben E2E-Tests deutlich zuverlaessiger gemacht als in der Selenium-Aera.

Das Verhaeltnis, das ich anstrebe: 70 % Unit, 20 % Integration, 10 % E2E. Die exakten Zahlen sind weniger wichtig als eine Abdeckung auf jeder Ebene.

Was testen und was ueberspringen

Nicht jeder Code braucht die gleiche Testtiefe. Zu wissen, wo man Testaufwand investiert, ist genauso wichtig wie zu wissen, wie man Tests schreibt.

Immer testen

Geschaeftslogik und Domaenenregeln. Wenn der Code eine Geschaeftsregel implementiert — Preisberechnung, Berechtigungspruefung, Datenvalidierung, State-Machine-Uebergang — braucht er Tests. Das sind die Regeln, die, wenn sie falsch sind, Geld kosten oder Sicherheitsluecken schaffen.

// pricing.ts
export function calculateOrderTotal(items: OrderItem[], discount?: Discount): number {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  if (!discount) return subtotal;

  if (discount.type === 'percentage') {
    return subtotal * (1 - discount.value / 100);
  }

  if (discount.type === 'fixed') {
    return Math.max(0, subtotal - discount.value);
  }

  return subtotal;
}
// pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateOrderTotal } from './pricing';

describe('calculateOrderTotal', () => {
  const items: OrderItem[] = [
    { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
    { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
  ];

  it('calculates subtotal without discount', () => {
    expect(calculateOrderTotal(items)).toBe(30.97);
  });

  it('applies percentage discount', () => {
    const discount = { type: 'percentage' as const, value: 10 };
    expect(calculateOrderTotal(items, discount)).toBeCloseTo(27.873);
  });

  it('applies fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 5 };
    expect(calculateOrderTotal(items, discount)).toBe(25.97);
  });

  it('does not go below zero with fixed discount', () => {
    const discount = { type: 'fixed' as const, value: 50 };
    expect(calculateOrderTotal(items, discount)).toBe(0);
  });

  it('handles empty items array', () => {
    expect(calculateOrderTotal([])).toBe(0);
  });
});

Datentransformationsfunktionen. Jede Funktion, die Daten von einer Form in eine andere transformiert — API-Response-Mapper, Formular-Daten-Serialisierer, CSV-Parser — braucht Tests mit repraesentativen Eingaben und Randfaellen.

Fehlerbehandlungspfade. Was passiert, wenn die API einen 500er zurueckgibt? Wenn die Eingabe null ist? Wenn die Datei nicht existiert? Fehlerpfade sind dort, wo Bugs sich verstecken, weil Entwickler den Happy Path manuell testen und annehmen, der Fehlerpfad funktioniert.

Utility-Funktionen. String-Formatierer, Datums-Helfer, Array-Utilities, Validierungsfunktionen. Diese werden ueberall verwendet, und ein Bug in einer Utility-Funktion multipliziert sich ueber die Codebasis.

Ueberspringen oder leicht testen

Direkte Framework-Wrapper. Wenn Ihre Funktion ein duenner Wrapper um eine gut getestete Framework-Methode ist, testet der Test das Framework, nicht Ihren Code. Eine React-Komponente, die <h1>{title}</h1> rendert, braucht keinen Test, der verifiziert, dass das h1 gerendert wird.

Konfigurationsdateien. Statische Konfiguration, Konstanten, Typ-Definitionen — die brauchen keine Tests. TypeScript validiert sie bereits zur Compile-Zeit.

Integration-Glue fuer Drittanbieter-Bibliotheken. Code, der stripe.charges.create() mit Parametern aus Ihrem Datenmodell aufruft, braucht keinen Unit-Test, der Stripe mockt und verifiziert, dass Sie es aufgerufen haben. Er braucht einen Integrationstest, der den End-to-End-Charge-Flow verifiziert.

Mocking-Strategien

Mocking ist dort, wo Tests von "Verhalten verifizieren" zu "Implementierungsdetails testen" uebergehen. Das Ziel ist, so wenig wie moeglich zu mocken und dabei Tests schnell und deterministisch zu halten.

Der Dependency-Injection-Ansatz

Statt Module zu mocken, uebergeben Sie Abhaengigkeiten als Parameter. Das macht das Testen natuerlich und vermeidet die vi.mock()-Magie, die Tests an die Modulstruktur koppelt.

// user-service.ts
export function createUserService(db: Database, emailClient: EmailClient) {
  return {
    async createUser(data: CreateUserInput): Promise<User> {
      const user = await db.users.create({ data });
      await emailClient.send({
        to: user.email,
        template: 'welcome',
        data: { name: user.name },
      });
      return user;
    },
  };
}
// user-service.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUserService } from './user-service';

describe('createUserService', () => {
  it('creates user and sends welcome email', async () => {
    const mockUser = { id: '1', name: 'Jane', email: 'jane@example.com' };
    const db = {
      users: { create: vi.fn().mockResolvedValue(mockUser) },
    };
    const emailClient = {
      send: vi.fn().mockResolvedValue(undefined),
    };

    const service = createUserService(db as any, emailClient as any);
    const result = await service.createUser({ name: 'Jane', email: 'jane@example.com' });

    expect(result).toEqual(mockUser);
    expect(emailClient.send).toHaveBeenCalledWith({
      to: 'jane@example.com',
      template: 'welcome',
      data: { name: 'Jane' },
    });
  });
});

Keine vi.mock()-Aufrufe, keine Modulpfad-Strings, kein reihenfolgeabhaengiges Setup. Der Test ist explizit darueber, was real und was gefaelscht ist.

Wann vi.mock() verwenden

Modul-Level-Mocking ist angemessen, wenn Sie die Dependency Injection nicht kontrollieren koennen — typischerweise beim Testen von React-Komponenten, die Module direkt importieren, oder beim Testen von Code, der umgebungsspezifische Globals verwendet.

// When you genuinely need module mocking
vi.mock('./api-client', () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test User' }),
}));

MSW fuer API-Mocking

Fuer Tests, die HTTP-Anfragen machen, faengt Mock Service Worker (MSW) Netzwerkanfragen auf Service-Worker-Ebene ab. Das ist dem Mocken von fetch oder axios ueberlegen, weil es Ihren tatsaechlichen HTTP-Client-Code testet.

import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

const server = setupServer(
  http.get('https://api.example.com/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
      email: 'test@example.com',
    });
  }),

  http.post('https://api.example.com/orders', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 'order-1', ...body, status: 'created' },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

KI-gestuetzte Testgenerierung

KI-Tools haben die Wirtschaftlichkeit des Testschreibens wirklich veraendert. Das Generieren des anfaenglichen Testgeruests — den Boilerplate, die grundlegenden Happy-Path-Faelle, die Standard-Randfaelle — ist genau die Art von repetitiver, musterbasierter Arbeit, die LLMs gut bewaeltigen.

Was KI gut macht

  • Testdateien aus Funktionssignaturen und Typ-Definitionen generieren
  • Randfaelle vorschlagen, an die Sie vielleicht nicht denken (leere Arrays, Null-Werte, Grenzwert-Zahlen)
  • Repetitiven Setup/Teardown-Boilerplate schreiben
  • Mock-Objekte erstellen, die Interface-Formen entsprechen
  • Parametrisierte Testfaelle fuer Funktionen mit vielen Eingabekombinationen generieren

Was KI schlecht macht

  • Geschaeftskontext verstehen ("das sollte fehlschlagen, weil Nutzer nach Mitternacht nicht bestellen koennen" ist Domaenenwissen)
  • Komplexe State-Interaktionen ueber mehrere Funktionsaufrufe testen
  • Aussagekraeftige Integrationstests schreiben, die echte Systemgrenzen pruefen
  • Entscheiden, was getestet und was nicht getestet werden soll
  • Tests schreiben, die Verhalten statt Implementierung verifizieren

Der praktische Workflow

Mein Workflow mit KI-gestuetztem Testen:

  1. Die Funktion oder das Modul schreiben.
  2. Die KI bitten, eine Testdatei mit grundlegenden Faellen zu generieren.
  3. Die generierten Tests ueberpruefen und korrigieren — KI-generierte Tests testen oft Implementierungsdetails statt Verhalten.
  4. Domaenenspezifische Faelle hinzufuegen, die die KI verpasst hat.
  5. Die Tests ausfuehren und verifizieren, dass sie tatsaechlich Bugs fangen, indem man den Code voruebergehend bricht.

Die KI erledigt Schritt 2 (der frueher der muehsame Teil war) und ich konzentriere mich auf Schritte 3-5 (die Urteilsvermoegen erfordern). Das reduziert die Testschreibzeit um ungefaehr 50-60 % fuer Standard-Utility- und Service-Code.

React-Komponenten testen

Komponententests haben sich erheblich weiterentwickelt. Der Wechsel von Enzymes Shallow Rendering zu React Testing Librarys nutzerzentrierter Testphilosophie hat veraendert, wie ich ueber Komponententests denke.

Was in Komponenten testen

  • Rendert die Komponente korrekt mit verschiedenen Props?
  • Loesen Nutzerinteraktionen (Klick, Tippen, Auswaehlen) das erwartete Verhalten aus?
  • Bewaeltigt die Komponente Lade-, Fehler- und Leer-Zustaende?
  • Funktioniert bedingtes Rendering korrekt?

Was in Komponenten nicht testen

  • Interne State-Werte (testen Sie, was der Nutzer sieht, nicht was React trackt)
  • Implementierungsdetails (welche Funktion aufgerufen wurde, wie viele Re-Renders passiert sind)
  • Styling (verwenden Sie visuelles Regressionstesting dafuer)
// OrderSummary.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { OrderSummary } from './OrderSummary';

describe('OrderSummary', () => {
  const defaultProps = {
    items: [
      { id: '1', name: 'Burger', price: 12.99, quantity: 2 },
      { id: '2', name: 'Fries', price: 4.99, quantity: 1 },
    ],
    onCheckout: vi.fn(),
  };

  it('displays order items with prices', () => {
    render(<OrderSummary {...defaultProps} />);

    expect(screen.getByText('Burger')).toBeInTheDocument();
    expect(screen.getByText('$25.98')).toBeInTheDocument(); // 12.99 * 2
    expect(screen.getByText('Fries')).toBeInTheDocument();
    expect(screen.getByText('$4.99')).toBeInTheDocument();
  });

  it('displays total', () => {
    render(<OrderSummary {...defaultProps} />);
    expect(screen.getByText('Total: $30.97')).toBeInTheDocument();
  });

  it('calls onCheckout when checkout button is clicked', async () => {
    const user = userEvent.setup();
    render(<OrderSummary {...defaultProps} />);

    await user.click(screen.getByRole('button', { name: /checkout/i }));
    expect(defaultProps.onCheckout).toHaveBeenCalledOnce();
  });

  it('shows empty state when no items', () => {
    render(<OrderSummary {...defaultProps} items={[]} />);
    expect(screen.getByText(/your cart is empty/i)).toBeInTheDocument();
    expect(screen.queryByRole('button', { name: /checkout/i })).not.toBeInTheDocument();
  });

  it('disables checkout button during loading', () => {
    render(<OrderSummary {...defaultProps} isLoading={true} />);
    expect(screen.getByRole('button', { name: /checkout/i })).toBeDisabled();
  });
});

Beachten Sie: kein getByTestId wenn nicht noetig, keine Pruefung des internen States, keine Verifizierung von CSS-Klassen. Die Tests lesen sich wie eine Beschreibung dessen, was ein Nutzer sieht und tut.

API-Routen testen

API-Routen-Tests sind Integrationstests, die verifizieren, dass Ihr HTTP-Handler korrekt mit seiner Middleware, Validierung und Antwortformatierung funktioniert.

// Using supertest with an Express app
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import request from 'supertest';
import { app } from '../app';
import { db } from '../database';

describe('POST /api/orders', () => {
  let authToken: string;

  beforeAll(async () => {
    await db.migrate.latest();
    await db.seed.run();
    // Get auth token for test user
    const loginResponse = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'testpassword' });
    authToken = loginResponse.body.token;
  });

  afterAll(async () => {
    await db.destroy();
  });

  it('creates an order with valid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'prod-1', quantity: 2 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'pending',
      items: expect.arrayContaining([
        expect.objectContaining({ productId: 'prod-1', quantity: 2 }),
      ]),
    });
  });

  it('returns 401 without auth token', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ items: [{ productId: 'prod-1', quantity: 2 }] });

    expect(response.status).toBe(401);
  });

  it('returns 400 with invalid data', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({ items: [] });

    expect(response.status).toBe(400);
    expect(response.body.errors).toBeDefined();
  });

  it('returns 400 when product does not exist', async () => {
    const response = await request(app)
      .post('/api/orders')
      .set('Authorization', `Bearer ${authToken}`)
      .send({
        items: [{ productId: 'nonexistent', quantity: 1 }],
        deliveryAddress: '123 Main St',
      });

    expect(response.status).toBe(400);
    expect(response.body.message).toContain('not found');
  });
});

Coverage-Metriken, die zaehlen

Code-Coverage ist ein nuetzliches Signal, aber ein schreckliches Ziel.

Die Coverage-Falle

Optimierung auf 100 % Coverage fuehrt zu Tests, die existieren, um unabgedeckte Zeilen zu treffen, statt Verhalten zu verifizieren.

Sinnvolle Coverage-Ziele

Statt einer pauschalen Coverage-Schwelle verwende ich unterschiedliche Ziele fuer verschiedene Code-Kategorien:

Kategorie Ziel Begruendung
Geschaeftslogik / Domaene 90%+ Bugs hier kosten Geld
API-Routen / Controller 80%+ Integrationsgrenzen
Utility-Funktionen 95%+ Weit verbreitet, hohe Auswirkung
UI-Komponenten 70%+ Visuelle Bugs durch E2E gefangen
Konfiguration / Setup Kein Ziel Statisch, bricht selten

Konfigurieren Sie Coverage-Schwellen pro Verzeichnis:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      thresholds: {
        'src/domain/**': { statements: 90, branches: 85 },
        'src/api/**': { statements: 80, branches: 75 },
        'src/utils/**': { statements: 95, branches: 90 },
      },
    },
  },
});

Die Mutation-Testing-Alternative

Wenn Coverage-Zahlen sich hohl anfuehlen, bietet Mutation Testing eine ehrlichere Bewertung. Tools wie Stryker fuehren kleine Aenderungen (Mutationen) in Ihren Code ein und verifizieren, dass Ihre Tests sie fangen. Eine Mutation, die ueberlebt (Tests bestehen trotzdem), zeigt eine Luecke in Ihrer Testsuite an.

npx stryker run

Mutation Testing ist langsam (fuehrt Ihre gesamte Testsuite fuer jede Mutation aus), aber aufschlussreich. Ich fuehre es monatlich aus statt bei jedem Commit.

Die Investition zahlt Zinseszinsen

Jeder Test, den Sie schreiben, hat eine Lebensdauer, die in Jahren gemessen wird. Ein heute geschriebener Test faengt Regressionen beim Refactoring naechste Woche, bei der Feature-Erweiterung naechsten Monat und beim Framework-Upgrade naechstes Jahr. Die 10 Minuten, die Sie fuer das Schreiben eines Tests aufwenden, sparen 2 Stunden Debugging in sechs Monaten, aber Sie sehen diese Ersparnis nie explizit — Sie haben den Bug einfach nie.

Der schwierigste Teil des Testens ist nicht das Tooling oder die Techniken. Es ist die Disziplin, den Test jetzt zu schreiben, solange der Code frisch im Kopf ist, statt ihn zum Backlog hinzuzufuegen, wo er leise stirbt. Die Tooling-Ausrede ist weg. Die Geschwindigkeits-Ausrede ist weg. Die einzige verbleibende Ausrede ist Priorisierung, und getesteten Code auszuliefern sollte nicht verhandelbar sein.

DU

Danil Ulmashev

Full Stack Developer

Interesse an einer Zusammenarbeit?