Strona główna / Artykuły / Testowanie w Enterprise Node.js: nazewnictwo AAA, wskaźnik pokrycia oraz fabryki testów

Testowanie w Enterprise Node.js: nazewnictwo AAA, wskaźnik pokrycia oraz fabryki testów

Dowiedz się, jak strukturyzować testy w Node.js przy użyciu wzorca AAA, zrównoważyć zakres testów jednostkowych i integracyjnych, sprawdzić pięć kluczowych wyników w domenie oraz izolować testy za pomocą fabryk.

1401 słów

Nawet najstarannie zaprojektowana baza kodu z czasem ulegnie degradacji, jeśli nikt nie sprawdza jej automatycznie. W miarę rozwoju zespołów i ciągłego powiększania się zestawu funkcji, solidny zestaw testów staje się główną linią obrony przed regresjami wdrożonymi do produkcji.

W tym rozdziale skupiamy się na praktykach testowania na poziomie korporacyjnym dla Node.js: jak pisać testy, które są łatwe do zrozumienia, jak utrzymać niezależność testów od siebie, jak osiągnąć odpowiedni balans pomiędzy pokryciem na poziomie jednostek a integracji oraz jak potwierdzić pięć kluczowych wyników, które może dostarczyć każda część logiki biznesowej.

1. Struktura i nazewnictwo testów: wzorzec AAA

Traktuj swoje testy jako żywą dokumentację reguł biznesowych, które obejmują. Gdy jeden z nich zawiedzie w pipeline CI/CD w środku nocy, osoba pełniąca dyżur musi natychmiast zrozumieć, co się nie udało, w jakich okolicznościach oraz co powinno zamiast tego się wydarzyć.

Wzorzec AAA (Arrange-Act-Assert)

Zorganizuj każdy test tak, aby składał się z trzech wyraźnie oddzielonych etapów:

┌─────────────────────────────────────────────────────────┐
│ 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 nazewnictwa testów opisowych

it('works') lub it('should test user service'). Zamiast tego przyjmij konwencję nazewnictwa, która od razu wyjaśnia cel testu:

given [warunek wstępny/kontekst], when [działanie], then [oczekiwany wynik]

Zła metoda: tajemnicze definicje testów

// 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();
  });
});

Prawidłowy sposób: samodokumentujące się testy AAA

// 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. Testy jednostkowe a integracyjne: znalezienie odpowiedniej równowagi

Jednym z często pojawiających się pytań w testowaniu w Node.js jest to, jak rozdzielić wysiłki pomiędzy testami jednostkowymi, które działają szybko i w dużej mierze polegają na mockach, a testami integracyjnymi, które działają wolniej, ale wykorzystują rzeczywiste bazy danych i połączenia sieciowe.

                      ┌──────────────────────────┐
                      │    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)
                      └──────────────────────────┘

Testy jednostkowe: czysta logika domeny

Testy jednostkowe mają na celu sprawdzanie logiki biznesowej znajdującej się w usługach domeny w całkowitej izolacji. Wszystko, co łączy się z zewnętrznym światem, takie jak repozytoria bazy danych czy zewnętrzni klienci API, powinno być zastąpione stubami lub mockami.

  • Co one obejmują: usługi domeny, logikę obliczeniową oraz wspólne narzędzia pomocnicze.
  • Jak szybko działają: zazwyczaj poniżej milisekundy na test.
  • Czego nie wolno robić: zakaz dotykania sieci, brak dostępu do bazy danych w czasie rzeczywistym oraz brak możliwości dostępu do systemu plików.
  • Testy integracyjne: rzeczywiste połączenia i infrastruktura

    Testy integracyjne potwierdzają, że twój kod prawidłowo współpracuje z rzeczywistymi systemami zewnętrznymi (PostgreSQL, Redis, RabbitMQ) oraz z frameworkiem webowym, którego używasz (Express lub Fastify).

    • Czego dotyczą: zapytania do repozytoriów, punkty końcowe API (przez supertest) oraz elementy obsługujące kolejki.
    • Jak szybko się wykonują: mniej więcej od kilkunastu do setek milisekund każdy.
    • Na czym polegają: na bazach danych w formie kontenerów uruchamianych za pomocą narzędzi takich jak Testcontainers lub Docker Compose, dzięki czemu weryfikowana jest rzeczywista eksploatacja zapytań SQL lub NoSQL.

    3. 5 kluczowych rezultatów usług domenowych

    Gdy piszesz testy jednostkowe lub integracyjne dla metody usługi biznesowej, ta funkcja domeny może generować aż pięć różnych rodzajów wyników. Kompleksowy zestaw testów musi obejmować każdy wynik, który ma zastosowanie do testowanej operacji:

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

    Przykład: Testowanie wszystkich 5 wyników

    Rozważmy, jak przetestować pełną operację domeny, taką jak orderService.checkoutOrder.

    // 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. Zapobieganie wzajemnej zależności testów za pomocą fabryk testów

    Jedną z głównych przyczyn niewiarygodnych zestawów testów jest wspólny stan zmienny — testy, które polegają na globalnych ustawieniach, wspólnych rekordach w bazie danych lub pozostałościach z poprzedniego wykonywania testów.

    Problem wspólnych ustawień

    // ❌ 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!
    

    Rozwiązanie: Dynamiczne fabryki testów

    Rozwiązaniem jest korzystanie z fabryk testowych, które przy każdym wywołaniu generują świeże, unikalne dane, zamiast ponownego używania statycznych obiektów w różnych plikach.

    // 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;
    

    Użycie w testach integracyjnych

    Dzięki temu podejściu każdy test ma swój własny, izolowany zbiór danych, co oznacza, że narzędzia do wykonywania testów takie jak Jest, Vitest czy wbudowany Node Test Runner mogą uruchamiać testy równolegle bez występowania sytuacji konkurencyjnych:

    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);
    });
    

    Lista kontrolna architektury dla części 3

    Zanim uznasz, że konfiguracja testów jest kompletna, sprawdź swoją bazę kodu pod kątem poniższej listy kontrolnej:

    • Struktura AAA: Czy każdy test jest wyraźnie podzielony na sekcje Arrange, Act i Assert?
    • Opcjonalne nazewnictwo: Czy tytuły testów opisują kontekst, działanie i oczekiwany wynik, stosując schemat given... when... then...?
  • Zrównoważona piramida: Czy polegasz na szybkich testach jednostkowych dla logiki domeny, zachowując jednocześnie testy integracyjne na granicach z bazą danych i protokołem HTTP?
  • Czy w razie potrzeby sprawdzasz wartości zwracane, zmiany w bazie danych, wywołania do stron trzecich, emitowane zdarzenia oraz zachowanie logowania?
  • Czy fabryki generują izolowane rekordy dla każdego testu, zamiast gdyby testy korzystały ze wspólnych ustawień globalnych?
  • Literatura pokrewna