Home / Articles / Enterprise Node.js Testing: AAA Naming, Coverage, and Test Factories

This article is published in English.

Enterprise Node.js Testing: AAA Naming, Coverage, and Test Factories

Learn how to structure Node.js tests with the AAA pattern, balance unit vs integration coverage, verify five core domain outcomes, and isolate tests using factories.

1401 words

Even the most thoughtfully layered codebase will erode over time if nothing is checking it automatically. As teams scale and the feature set keeps expanding, a solid test suite becomes the main line of defense against regressions slipping into production.

This installment focuses on enterprise-grade testing practices for Node.js: how to write tests that read clearly, how to keep tests independent from one another, how to strike the right balance between unit and integration coverage, and how to confirm the five core outcomes that any piece of domain logic can produce.

1. Test Structure & Naming: The AAA Pattern

Think of your tests as living documentation of the business rules they cover. When one fails inside a CI/CD pipeline in the middle of the night, whoever is on call needs to immediately grasp what failed, under which circumstances, and what should have happened instead.

The AAA Pattern (Arrange-Act-Assert)

Structure every test so it falls into three clearly separated stages:

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

Descriptive Test Naming Standard

Steer clear of vague labels such as it('works') or it('should test user service'). Instead, adopt a naming convention that spells out the intent of the test up front:

given [precondition/context], when [action], then [expected outcome]

The Bad Way: Cryptic Test Definitions

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

The Right Way: Self-Documenting 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 vs. Integration Tests: Finding the Right Balance

One recurring question in Node.js testing is how to divide effort between unit tests, which run fast and rely heavily on mocks, and integration tests, which run slower but exercise real databases and network calls.

                      ┌──────────────────────────┐
                      │    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: Pure Domain Logic

Unit tests are meant to exercise the business logic living inside domain services in total isolation. Anything that reaches outward, such as database repositories or external API clients, should be replaced with stubs or mocks.

  • What they cover: domain services, calculation logic, and shared utility helpers.
  • How fast they run: typically under a millisecond per test.
  • What's off limits: no touching the network, no live database, no file system access.

Integration Tests: Real Wiring & Infrastructure

Integration tests confirm that your code cooperates correctly with real external systems (PostgreSQL, Redis, RabbitMQ) and with the web framework you're using (Express or Fastify).

  • What they cover: repository queries, API endpoints (via supertest), and queue consumers.
  • How fast they run: roughly tens to hundreds of milliseconds each.
  • What they rely on: containerized databases, spun up through tools like Testcontainers or Docker Compose, so that actual SQL or NoSQL execution is being verified.

3. The 5 Core Outcomes of Domain Services

When you write unit or integration tests for a business service method, that domain function can generate as many as five separate kinds of outcomes. A thorough test suite needs to cover each outcome that applies to the operation being tested:

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

Example: Testing All 5 Outcomes

Consider how you'd test a full domain operation such as 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. Preventing Test Interdependence with Test Factories

One of the biggest causes of unreliable test suites is shared mutable state — tests that depend on global fixtures, shared rows in a database, or leftovers from a previous test run.

The Problem with Shared 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!

The Solution: Dynamic Test Factories

The fix is to rely on test factories that produce fresh, unique data on every invocation, rather than reusing static objects across files.

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

Usage in Integration Tests

With this approach, every test gets its own isolated dataset, which means test runners such as Jest, Vitest, or the built-in Node Test Runner can run specs in parallel without hitting race conditions:

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

Architecture Checklist for Part 3

Before you consider your test setup complete, review your codebase against the following checklist:

  • AAA structure: Is each test clearly split into Arrange, Act, and Assert sections?
  • Descriptive naming: Do test titles spell out the context, the action, and the expected outcome, following a given... when... then... shape?
  • Balanced pyramid: Are you leaning on fast unit tests for domain logic while reserving integration tests for database and HTTP boundaries?
  • All five outcomes checked: Where relevant, are you asserting on return values, database mutations, outbound calls to third parties, emitted events, and logging behavior?
  • No shared state: Are factories generating isolated records for each test instead of tests sharing global fixtures?