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.
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.
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...?
Literatura pokrewna
- Zastępowanie Jest wewnętrznym runnerem testowym Node w Node 24 — Przykład z praktyki pokazuje, jak wbudowany runner testowy Node 24 oraz wrodzona obsługa TypeScript skracają czas CI, jednocześnie eliminując cztery zależności.
- Projektowanie warstwowe API w Node.js: od obsługiwaczy o dużej objętości do czystej architektury — Dowiedz się, jak przeredagować API w Node.js na warstwy obsługiwaczy, usług i dostępu do danych, aby rozwiązać problemy z skomplikowaną logiką biznesową, niejednolitymi błędami oraz trudnościami w skalowaniu.