Startseite / Artikel / Enterprise Node.js Testing: AAA-Namensgebung, Abdeckungsgrad und Testfabriken

Enterprise Node.js Testing: AAA-Namensgebung, Abdeckungsgrad und Testfabriken

Erfahren Sie, wie Sie Tests mit Node.js mithilfe des AAA-Musters strukturieren, die Abdeckung von Unit- und Integrationstests ausbalancieren, fünf zentrale Ergebnisse des Anwendungsbereichs überprüfen sowie Tests mit Hilfe von Factories isolieren.

1401 Wörter

Auch die sorgfältig gestrukturierteste Codebasis wird im Laufe der Zeit zerfallen, wenn nichts automatisch darauf überwacht. Wenn Teams wachsen und das Funktionsangebot weiter erweitert wird, wird ein solides Testumfeld zur wichtigsten Verteidigungslinie dagegen, dass Regressionen in die Produktion gelangen.

In diesem Teil konzentrieren wir uns auf unternehmensreife Testpraktiken für Node.js: Wie man Tests schreibt, die klar lesbar sind, wie man die Tests voneinander unabhängig hält, wie man das richtige Gleichgewicht zwischen Unit- und Integrationstests findet sowie wie man die fünf Kernergebnisse überprüft, die jede Art von Domänenlogik erzielen kann.

1. Teststruktur & Benennung: Das AAA-Muster

Betrachten Sie Ihre Tests als lebendige Dokumentation der Geschäftsregeln, die sie abdecken. Wenn mitten in der Nacht ein Test innerhalb eines CI/CD-Pipelines fehlschlägt, muss die Person, die im Dienst ist, sofort verstehen was fehlgeschlagen ist, unter welchen Umständen und was stattdessen passieren sollte.

Das AAA-Muster (Arrange-Act-Assert)

Strukturieren Sie jeden Test so, dass er in drei klar voneinander getrennte Phasen unterteilt ist:

┌─────────────────────────────────────────────────────────┐
│ 1. ARRANGE                                              │
│    Set up preconditions, create inputs, mock dependencies│
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 2. ACT                                                  │
│    Execute the single domain operation being tested      │
└───────────────────────────┬─────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│ 3. ASSERT                                               │
│    Verify returned results, DB state, and side-effects  │
└─────────────────────────────────────────────────────────┘

Standard für beschreibende Testnamen

Machen Sie einen Bogen um vage Bezeichnungen wie it('works') oder it('should test user service'). Verwenden Sie stattdessen eine Namenskonvention, die von vornherein den Zweck des Tests klar macht:

given [Voraussetzung/Kontext], when [Aktion], then [erwartetes Ergebnis]

Die falsche Methode: Rätselhafte Testdefinitionen

// orders.service.test.js
describe('orders service', () => {
  it('creates order', async () => {
    // Everything mashed together, no context
    const res = await orderService.createOrder({ userId: '123', items: [] });
    expect(res).toBeDefined();
  });
});

Die richtige Vorgehensweise: Selbst dokumentierende AAA-Tests

// components/orders/orders.service.test.js
const { orderService } = require('./orders.service');
const { ValidationError } = require('../../shared/errors/AppError');
const userFactory = require('../../../test/factories/user.factory');

describe('OrderService.createOrder', () => {
  it('given an empty cart, when creating an order, then it throws a ValidationError', async () => {
    // ARRANGE
    const user = await userFactory.build();
    const payload = { userId: user.id, items: [] };

    // ACT & ASSERT
    await expect(orderService.createOrder(payload))
      .rejects
      .toThrow(ValidationError);
  });
});

2. Unit-Tests gegen Integrationstests: Das richtige Gleichgewicht finden

Eine häufig gestellte Frage bei der Testschreibung in Node.js ist, wie man die Arbeit zwischen Unit-Tests aufteilen soll – diese laufen schnell und verlassen sich stark auf Mocks – sowie Integrationstests, die langsamer laufen, aber echte Datenbanken und Netzwerkaufrufe nutzen.

                      ┌──────────────────────────┐
                      │    End-to-End (E2E)      │  ~10% of tests
                      │  (Full stack / Cypress)  │  (Slowest, high confidence)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │    Integration Tests     │  ~40% of tests
                      │ (Real DB / HTTP Endpoints│  (Medium speed, real wiring)
                      └────────────┬─────────────┘
                                   │
                      ┌────────────┴─────────────┐
                      │       Unit Tests         │  ~50% of tests
                      │ (Isolated Domain Logic)  │  (Fastest, instant feedback)
                      └──────────────────────────┘

Unit-Tests: Reine Domänenlogik

Unit-Tests dienen dazu, die in Domänendiensten enthaltene Geschäftslogik völlig isoliert zu überprüfen. Alles, was nach außen verweist – wie Datenbankrepositorien oder externe API-Klienten – sollte durch Stubs oder Mocks ersetzt werden.

  • Was sie abdecken: Domänendienste, Berechnungslogik sowie gemeinsame Hilfsfunktionen.
  • Wie schnell sie laufen: in der Regel unter einem Millisekunde pro Test.
  • Was verboten ist: Kein Kontakt zum Netzwerk, keine Verwendung einer laufenden Datenbank, kein Zugriff auf das Dateisystem.
  • Integrationstests: Reale Verbindungen und Infrastruktur

    Integrationstests stellen sicher, dass Ihr Code korrekt mit echten externen Systemen (PostgreSQL, Redis, RabbitMQ) sowie mit dem von Ihnen verwendeten Web-Framework (Express oder Fastify) zusammenarbeitet.

    • Was sie abdecken: Abfragen an Repositorien, API-Endpunkte (über supertest) sowie Verarbeitung von Warteschlangeninhalten.
    • Wie schnell sie laufen: In der Regel zehn bis hundert Millisekunden pro Test.
    • Worauf sie sich stützen: Containerisierte Datenbanken, die mithilfe von Tools wie Testcontainers oder Docker Compose gestartet werden, damit eine tatsächliche Ausführung von SQL- oder NoSQL-Anfragen überprüft werden kann.

    3. Die 5 Kernergebnisse von Domänendiensten

    Wenn Sie Unit- oder Integrationstests für eine Methode eines Geschäftsservices schreiben, kann diese Domänefunktion bis zu fünf verschiedene Arten von Ergebnissen erzeugen. Ein umfassender Testsuite muss jedes auf die getestete Operation anwendbare Ergebnis abdecken:

    ┌────────────────────────────────────────────────────────────────────────┐
    │                        Domain Service Operation                        │
    └──────┬──────────────┬──────────────┬──────────────────┬────────────────┘
           │              │              │                  │
           ▼              ▼              ▼                  ▼
    ┌─────────────┐┌─────────────┐┌─────────────┐┌─────────────────────┐┌──────────────┐
    │  1. Return  ││  2. State   ││ 3. Outgoing ││ 4. Events Published ││ 5. Telemetry │
    │    Value    ││   Changes   ││  API Calls  ││  (Broker / Queue)   ││   / Logs     │
    └─────────────┘└─────────────┘└─────────────┘└─────────────────────┘└──────────────┘
    

    Beispiel: Testen aller 5 Ergebnisse

    Betrachten Sie, wie man eine vollständige Domänenoperation wie orderService.checkoutOrder testen würde.

    // components/orders/orders.service.test.js
    
    describe('OrderService.checkoutOrder', () => {
      it('given a valid order, when checkout occurs, then fulfills all 5 outcomes', async () => {
        // -------------------------------------------------------------
        // ARRANGE: Setup Mocks & Dependencies
        // -------------------------------------------------------------
        const mockOrderRepo = {
          findById: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PENDING', total: 100 }),
          updateStatus: jest.fn().mockResolvedValue({ id: 'ord_123', status: 'PAID', total: 100 })
        };
    
        const mockPaymentGateway = {
          charge: jest.fn().mockResolvedValue({ transactionId: 'txn_999', success: true })
        };
    
        const mockEventBus = {
          publish: jest.fn().mockResolvedValue(true)
        };
    
        const mockLogger = {
          info: jest.fn()
        };
    
        const orderService = createOrderService({
          orderRepo: mockOrderRepo,
          paymentGateway: mockPaymentGateway,
          eventBus: mockEventBus,
          logger: mockLogger
        });
    
        // -------------------------------------------------------------
        // ACT: Execute Domain Action
        // -------------------------------------------------------------
        const result = await orderService.checkoutOrder({ orderId: 'ord_123', paymentToken: 'tok_visa' });
    
        // -------------------------------------------------------------
        // ASSERT: Verify All 5 Outcomes
        // -------------------------------------------------------------
    
        // Outcome 1: Verify Return Value
        expect(result).toEqual(expect.objectContaining({
          id: 'ord_123',
          status: 'PAID'
        }));
    
        // Outcome 2: Verify Database State Change
        expect(mockOrderRepo.updateStatus).toHaveBeenCalledWith('ord_123', 'PAID');
    
        // Outcome 3: Verify Outgoing Third-Party Call
        expect(mockPaymentGateway.charge).toHaveBeenCalledWith({
          amount: 100,
          token: 'tok_visa'
        });
    
        // Outcome 4: Verify Message Queue / Event Emission
        expect(mockEventBus.publish).toHaveBeenCalledWith(
          'order.completed',
          expect.objectContaining({ orderId: 'ord_123' })
        );
    
        // Outcome 5: Verify Telemetry / Observability
        expect(mockLogger.info).toHaveBeenCalledWith(
          expect.stringContaining('Order ord_123 successfully checked out')
        );
      });
    });
    

    4. Vermeidung von Testabhängigkeiten mit Test-Factories

    Eine der größten Ursachen für unzuverlässige Testsuites ist gemeinsamer, veränderlicher Zustand – Tests, die von globalen Fixtures, gemeinsamen Datensätzen in einer Datenbank oder Überresten aus einem vorherigen Testlauf abhängen.

    Das Problem mit gemeinsamen Fixtures

    // ❌ BAD: Hardcoded shared static data across test files
    const testUser = { id: '123', email: 'john@example.com' };
    
    // If Test A mutates testUser.email, Test B fails unpredictably!
    

    Die Lösung: Dynamische Test-Factories

    Die Lösung besteht darin, sich auf Test-Fabriken zu verlassen, die bei jeder Aufrufung frische, einzigartige Daten erzeugen, anstatt statische Objekte über verschiedene Dateien hinweg zu wiederverwenden.

    // test/factories/user.factory.js
    const { crypto } = require('crypto');
    
    class UserFactory {
      static build(overrides = {}) {
        const randomId = Math.random().toString(36).substring(7);
    
        return {
          id: `usr_${randomId}`,
          email: `user_${randomId}@example.com`,
          role: 'CUSTOMER',
          createdAt: new Date(),
          ...overrides // Allow callers to override specific properties
        };
      }
    
      static async create(dbClient, overrides = {}) {
        const user = this.build(overrides);
        await dbClient.query(
          'INSERT INTO users (id, email, role, created_at) VALUES ($1, $2, $3, $4)',
          [user.id, user.email, user.role, user.createdAt]
        );
        return user;
      }
    }
    
    module.exports = UserFactory;
    

    Verwendung in Integrationstests

    Mit diesem Ansatz erhält jeder Test sein eigenes, isoliertes Datenset, was bedeutet, dass Testausführer wie Jest, Vitest oder der eingebaute Node Test Runner die Tests parallel ausführen können, ohne auf Race Conditions zu stoßen:

    it('given an admin user, when fetching reports, then returns data', async () => {
      // Generates fresh, isolated database record with admin role override
      const adminUser = await UserFactory.create(dbClient, { role: 'ADMIN' });
    
      const response = await request(app)
        .get('/api/v1/reports')
        .set('x-user-id', adminUser.id);
    
      expect(response.status).toBe(200);
    });
    

    Architektur-Überprülliste für Teil 3

    Bevor Sie Ihre Testeinrichtung als abgeschlossen betrachten, überprüfen Sie Ihren Codebase anhand der folgenden Überprülliste:

    • AAA-Struktur: Ist jeder Test klar in die Abschnitte Arrange, Act und Assert unterteilt?
    • Beschreibende Benennung: Beschreiben die Testtitel den Kontext, die Aktion und das erwartete Ergebnis in Form von given... when... then...?
  • Gleichgewichtige Pyramide: Setzen Sie auf schnelle Unit-Tests für die Domänenlogik und reservieren Sie Integrationstests für Datenbank- sowie HTTP-Grenzen?
  • Alle fünf Ergebnisse überprüft: Prüfen Sie bei Bedarf Rückgabewerte, Datenbankänderungen, Aufrufe an Dritte, ausgesendete Ereignisse sowie das Logging-Verhalten?
  • Kein gemeinsamer Zustand: Erstellen die Factory-Funktionen für jeden Test isolierte Datensätze, anstatt dass die Tests globale Fixtures teilen?
  • Zusätzliche Literatur