Siddhant Deval
Siddhant Deval
backend5 min read

Testing Strategy for Domain-Driven Design: Unit, Integration & Contract Tests

A Clean Architecture codebase enables a testing pyramid where the domain layer (pure business logic) is covered by fast, in-memory unit tests; the application layer is tested with repository and event bus doubles; and only the infrastructure layer needs slow integration tests with real databases. This article defines the exact test strategy for the billing domain.

Testing Strategy for Domain-Driven Design: Unit, Integration & Contract Tests

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. This architectural property has a direct consequence for testing: because the Domain layer has zero infrastructure dependencies, it can be tested in pure in-memory unit tests that run in milliseconds. Because the Infrastructure layer implements well-defined interfaces, it can be tested against contract suites that validate behavioral equivalence. Because the Application layer depends only on ports, it can be tested with in-memory doubles for all external dependencies.

This article builds the complete testing strategy for the billing engine across all four layers — defining the test pyramid, implementing every test category, and assembling the suite that validates 237 behaviors in under 6 seconds.

Architectural Note

Prerequisites: All previous parts, particularly Part 4 (Repository Contract Tests), Part 5 (SpyEventBus), and Part 9 (Test Container). This article consolidates, extends, and codifies the testing patterns introduced individually throughout the series into a single, authoritative reference.


1. The Test Pyramid for Clean Architecture

Tier Count Time Dependencies Runs In
Domain Unit 142 0.8s None vitest run on every save
Application Unit 67 1.2s In-memory doubles CI pre-merge
Infrastructure Contract 28 4.1s Docker Compose (Postgres) CI pre-merge
E2E / HTTP 12 2.3s Supertest + test container CI pre-merge
Total 249 ~8.4s

The 80% principle: 80% of behaviors are covered by domain and application tests that require zero infrastructure. Infrastructure contract tests cover the remaining 20% — the persistence, serialization, and adapter translation behaviors that cannot be tested without a real database.


2. Domain Unit Tests: Pure Business Logic

Domain unit tests are the fastest and most valuable tests in the suite. They test the business rules embedded in Aggregates, Value Objects, and Specifications — with zero setup, zero mocking, and zero infrastructure.

2.1 Testing Value Objects

TYPESCRIPT
// tests/unit/domain/payment/Money.test.ts
import { Money } from '../../../../src/domain/payment/Money';
import { DomainError } from '../../../../src/domain/shared/DomainError';

describe('Money', () => {
  describe('construction', () => {
    it('should create with valid amount and currency', () => {
      const m = Money.of('USD', 49.99);
      expect(m.amount).toBeCloseTo(49.99);
      expect(m.currency).toBe('USD');
      expect(m.amountCents).toBe(4999);
    });

    it('should round to nearest cent (floating-point safety)', () => {
      const m = Money.of('USD', 0.1 + 0.2); // 0.30000000000000004 in JS
      expect(m.amountCents).toBe(30); // Stored as 30 cents — no floating-point error
      expect(m.amount).toBeCloseTo(0.30);
    });

    it('should reject negative amounts', () => {
      expect(() => Money.of('USD', -1)).toThrow(DomainError);
    });

    it('should reject unsupported currencies', () => {
      expect(() => Money.of('MONOPOLY' as any, 10)).toThrow(DomainError);
    });

    it('should reject NaN and Infinity', () => {
      expect(() => Money.of('USD', NaN)).toThrow(DomainError);
      expect(() => Money.of('USD', Infinity)).toThrow(DomainError);
    });
  });

  describe('arithmetic', () => {
    it('should add two Money values of the same currency', () => {
      const result = Money.of('USD', 10.00).add(Money.of('USD', 5.00));
      expect(result.amountCents).toBe(1500);
    });

    it('should throw when adding different currencies', () => {
      expect(() => Money.of('USD', 10).add(Money.of('EUR', 10))).toThrow(DomainError);
    });

    it('should throw when subtraction would produce a negative result', () => {
      expect(() => Money.of('USD', 5).subtract(Money.of('USD', 10))).toThrow(DomainError);
    });

    it('should multiply correctly without floating-point drift', () => {
      // 3 × $33.33 = $99.99 (not $99.99000000000001)
      const result = Money.of('USD', 33.33).multiply(3);
      expect(result.amountCents).toBe(9999);
    });
  });

  describe('equality', () => {
    it('should be equal to another Money with same amount and currency', () => {
      expect(Money.of('USD', 99.99).equals(Money.of('USD', 99.99))).toBe(true);
    });

    it('should not be equal to Money with different currency', () => {
      expect(Money.of('USD', 99.99).equals(Money.of('EUR', 99.99))).toBe(false);
    });
  });
});

2.2 Testing Aggregate Invariants

TYPESCRIPT
// tests/unit/domain/order/Order.test.ts
import { Order } from '../../../../src/domain/order/Order';
import { OrderStatus } from '../../../../src/domain/order/OrderStatus';
import { Money } from '../../../../src/domain/payment/Money';
import { DomainError } from '../../../../src/domain/shared/DomainError';

const customerId = 'cust_001' as CustomerId;
const productId  = 'prod_A' as ProductId;
const testAddr   = Address.create({ street: '1 Main', city: 'NYC', state: 'NY', postalCode: '10001', countryCode: 'US' });

describe('Order Aggregate', () => {
  describe('Order.create()', () => {
    it('should create with PENDING status and empty items', () => {
      const order = Order.create(customerId);
      expect(order.status).toBe(OrderStatus.PENDING);
      expect(order.items).toHaveLength(0);
      expect(order.id).toBeDefined();
    });

    it('should reject creation without a customerId', () => {
      expect(() => Order.create('' as CustomerId)).toThrow(DomainError);
    });
  });

  describe('addItem()', () => {
    it('should add a line item to a PENDING order', () => {
      const order = Order.create(customerId);
      order.addItem(productId, 2, Money.of('USD', 49.99));
      expect(order.items).toHaveLength(1);
      expect(order.items[0].quantity).toBe(2);
    });

    it('should merge quantities when the same productId is added twice', () => {
      const order = Order.create(customerId);
      order.addItem(productId, 2, Money.of('USD', 49.99));
      order.addItem(productId, 3, Money.of('USD', 49.99));
      expect(order.items).toHaveLength(1);
      expect(order.items[0].quantity).toBe(5);
    });

    it('should reject adding items after order is placed', () => {
      const order = Order.create(customerId);
      order.addItem(productId, 1, Money.of('USD', 10));
      order.setShippingAddress(testAddr);
      order.place();
      expect(() => order.addItem('prod_B' as ProductId, 1, Money.of('USD', 5))).toThrow(DomainError);
    });
  });

  describe('place()', () => {
    it('should transition to PLACED and raise OrderPlaced event', () => {
      const order = Order.create(customerId);
      order.addItem(productId, 1, Money.of('USD', 50));
      order.setShippingAddress(testAddr);
      order.place();
      expect(order.status).toBe(OrderStatus.PLACED);
      const events = order.pullDomainEvents();
      expect(events).toHaveLength(1);
      expect(events[0].eventType).toBe('order.placed');
    });

    it('should reject placement with no items', () => {
      const order = Order.create(customerId);
      order.setShippingAddress(testAddr);
      expect(() => order.place()).toThrow(/no line items/i);
    });

    it('should reject placement without a shipping address', () => {
      const order = Order.create(customerId);
      order.addItem(productId, 1, Money.of('USD', 50));
      expect(() => order.place()).toThrow(/shipping address/i);
    });

    it('should reject double-placement', () => {
      const order = Order.create(customerId);
      order.addItem(productId, 1, Money.of('USD', 50));
      order.setShippingAddress(testAddr);
      order.place();
      expect(() => order.place()).toThrow(DomainError);
    });
  });

  describe('cancel()', () => {
    it('should cancel a PENDING order and raise OrderCancelled event', () => {
      const order = Order.create(customerId);
      order.cancel('customer request');
      expect(order.status).toBe(OrderStatus.CANCELLED);
      const events = order.pullDomainEvents();
      expect(events[0].eventType).toBe('order.cancelled');
    });

    it('should reject cancellation of a SHIPPED order', () => {
      const order = makeShippedOrder();
      expect(() => order.cancel('too late')).toThrow(DomainError);
    });
  });

  describe('total()', () => {
    it('should sum all line item subtotals correctly', () => {
      const order = Order.create(customerId);
      order.addItem('prod_A' as ProductId, 2, Money.of('USD', 10.00)); // $20
      order.addItem('prod_B' as ProductId, 3, Money.of('USD', 5.00));  // $15
      expect(order.total.amountCents).toBe(3500); // $35.00
    });
  });
});

2.3 Testing Specifications

TYPESCRIPT
// tests/unit/domain/discount/specifications.test.ts
import { HasMinimumOrderAmount } from '../../../../src/domain/discount/specifications/HasMinimumOrderAmount';
import { loyaltyDiscountEligibility } from '../../../../src/domain/discount/EligibilityRules';
import { Money } from '../../../../src/domain/payment/Money';

describe('HasMinimumOrderAmount', () => {
  const threshold = Money.of('USD', 100);
  const spec = new HasMinimumOrderAmount(threshold);

  it('satisfied at threshold', () => expect(spec.isSatisfiedBy(makeOrder(Money.of('USD', 100)))).toBe(true));
  it('satisfied above threshold', () => expect(spec.isSatisfiedBy(makeOrder(Money.of('USD', 200)))).toBe(true));
  it('not satisfied below threshold', () => expect(spec.isSatisfiedBy(makeOrder(Money.of('USD', 50)))).toBe(false));
});

describe('loyaltyDiscountEligibility (composite)', () => {
  const cases: [string, Partial<CustomerCtx>, boolean][] = [
    ['VIP, no orders, no subscription', { tier: 'VIP', orders: 0, subscription: false }, true],
    ['Standard, 7 orders, subscribed', { tier: 'STANDARD', orders: 7, subscription: true }, true],
    ['Standard, 10 orders, NOT subscribed', { tier: 'STANDARD', orders: 10, subscription: false }, false],
    ['Standard, 3 orders, subscribed', { tier: 'STANDARD', orders: 3, subscription: true }, false],
  ];

  it.each(cases)('%s', (_, ctx, expected) => {
    expect(loyaltyDiscountEligibility.isSatisfiedBy(buildCtx(ctx))).toBe(expected);
  });
});

Parameterized tests with it.each — each scenario is a single data row. Adding a new boundary case is one line.


3. Application Layer Tests: Use Cases with In-Memory Doubles

Application tests validate orchestration behavior — that the use case calls the right domain methods, saves the Aggregate, and dispatches the correct events. They use real domain objects but in-memory infrastructure doubles.

TYPESCRIPT
// tests/unit/application/PlaceOrderUseCase.test.ts
import { PlaceOrderUseCase } from '../../../../src/application/order/PlaceOrderUseCase';
import { InMemoryOrderRepository } from '../../../../src/infrastructure/persistence/InMemoryOrderRepository';
import { InMemoryCustomerRepository } from '../../../../src/infrastructure/persistence/InMemoryCustomerRepository';
import { MockPaymentProcessor } from '../../../../src/infrastructure/payment/MockPaymentProcessor';
import { SpyEventBus } from '../../../../src/infrastructure/messaging/SpyEventBus';
import { DiscountCalculationService } from '../../../../src/domain/discount/DiscountCalculationService';

describe('PlaceOrderUseCase', () => {
  let orderRepo:    InMemoryOrderRepository;
  let customerRepo: InMemoryCustomerRepository;
  let payment:      MockPaymentProcessor;
  let eventBus:     SpyEventBus;
  let useCase:      PlaceOrderUseCase;

  beforeEach(() => {
    orderRepo    = new InMemoryOrderRepository();
    customerRepo = new InMemoryCustomerRepository();
    payment      = new MockPaymentProcessor();
    eventBus     = new SpyEventBus();
    useCase      = new PlaceOrderUseCase(
      orderRepo,
      customerRepo,
      new DiscountCalculationService(),
      [],              // No discount strategies for base tests
      eventBus,
    );

    customerRepo.seed(Customer.create(
      'cust_001' as CustomerId,
      'Alice',
      EmailAddress.create('alice@test.com'),
    ));
  });

  it('should persist order and publish OrderPlaced', async () => {
    const orderId = await useCase.execute(validCommand());

    const saved = await orderRepo.findById(orderId);
    expect(saved).not.toBeNull();
    expect(saved!.status).toBe(OrderStatus.PLACED);
    expect(eventBus.wasPublished('order.placed')).toBe(true);
  });

  it('should not publish events when domain rejects the command', async () => {
    const cmd = { ...validCommand(), items: [] }; // Empty items triggers DomainError
    await expect(useCase.execute(cmd)).rejects.toThrow(DomainError);
    expect(eventBus.publishedCount).toBe(0);
    expect(orderRepo.size).toBe(0);
  });

  it('should not publish events when persistence fails', async () => {
    orderRepo.save = async () => { throw new Error('DB down'); };
    await expect(useCase.execute(validCommand())).rejects.toThrow('DB down');
    expect(eventBus.publishedCount).toBe(0);
  });

  it('should apply a discount when eligible strategy exists', async () => {
    const vipCustomer = Customer.create('cust_vip' as CustomerId, 'Bob', EmailAddress.create('bob@test.com'), CustomerTier.VIP);
    customerRepo.seed(vipCustomer);
    const discountUseCase = new PlaceOrderUseCase(
      orderRepo, customerRepo,
      new DiscountCalculationService(),
      [new VIPDiscountStrategy()],
      eventBus,
    );

    const orderId = await discountUseCase.execute({ ...validCommand(), customerId: 'cust_vip' as CustomerId });
    const saved = await orderRepo.findById(orderId);
    // VIP gets 20% off — $100 → $80
    expect(saved!.total.amountCents).toBe(8000);
  });
});

4. Infrastructure Contract Tests: Behavioral Equivalence

Contract tests run both InMemoryOrderRepository and PrismaOrderRepository against the same suite, ensuring behavioral equivalence. From Part 4:

TYPESCRIPT
// tests/unit/infrastructure/InMemoryOrderRepository.contract.test.ts
import { InMemoryOrderRepository } from '../../../../src/infrastructure/persistence/InMemoryOrderRepository';
import { orderRepositoryContract } from '../../../shared/OrderRepositoryContract';

describe('InMemoryOrderRepository — contract', () => {
  let repo: InMemoryOrderRepository;
  orderRepositoryContract(
    () => repo,
    async () => { repo = new InMemoryOrderRepository(); repo.clear(); },
  );
});
TYPESCRIPT
// tests/integration/PrismaOrderRepository.contract.test.ts
import { PrismaClient } from '@prisma/client';
import { PrismaOrderRepository } from '../../../../src/infrastructure/persistence/PrismaOrderRepository';
import { orderRepositoryContract } from '../../../shared/OrderRepositoryContract';

const prisma = new PrismaClient();

describe('PrismaOrderRepository — contract', () => {
  let repo: PrismaOrderRepository;
  orderRepositoryContract(
    () => repo,
    async () => {
      repo = new PrismaOrderRepository(prisma);
      await prisma.orderItem.deleteMany();
      await prisma.order.deleteMany();
    },
  );

  afterAll(() => prisma.$disconnect());
});

Both suites run the same 12 assertions. If PrismaOrderRepository passes all 12, it is behaviorally equivalent to InMemoryOrderRepository and can be used interchangeably in production.


5. E2E HTTP Tests: Full Stack with Supertest

E2E tests drive the HTTP interface with supertest, using the test container from Part 9 (all in-memory doubles):

TYPESCRIPT
// tests/e2e/orders.e2e.test.ts
import request from 'supertest';
import express from 'express';
import { buildTestContainer } from '../../src/infrastructure/container/testContainer';
import { TYPES } from '../../src/infrastructure/container/TYPES';
import { registerRoutes } from '../../src/interface/http/routes';

describe('POST /orders (E2E)', () => {
  let app: express.Application;
  let container: ReturnType<typeof buildTestContainer>;

  beforeEach(() => {
    container = buildTestContainer();
    app = express();
    app.use(express.json());
    registerRoutes(app, container);

    // Seed the customer
    const customerRepo = container.get<InMemoryCustomerRepository>(TYPES.ICustomerRepository);
    customerRepo.seed(Customer.create('cust_001' as CustomerId, 'Alice', EmailAddress.create('alice@test.com')));
  });

  it('should return 201 with orderId on valid request', async () => {
    const res = await request(app)
      .post('/orders')
      .send({
        customerId: 'cust_001',
        items: [{ productId: 'prod_A', quantity: 2, unitPrice: 49.99, currency: 'USD' }],
        shippingAddress: { street: '1 Main', city: 'NYC', state: 'NY', postalCode: '10001', countryCode: 'US' },
      });

    expect(res.status).toBe(201);
    expect(res.body.orderId).toMatch(/^ord_/);
  });

  it('should return 422 when items array is empty', async () => {
    const res = await request(app)
      .post('/orders')
      .send({ customerId: 'cust_001', items: [], shippingAddress: testAddr() });

    expect(res.status).toBe(422);
    expect(res.body.error).toMatch(/no line items/i);
  });

  it('should return 422 for a suspended customer', async () => {
    const customerRepo = container.get<InMemoryCustomerRepository>(TYPES.ICustomerRepository);
    customerRepo.seed(Customer.createSuspended('cust_suspended' as CustomerId));

    const res = await request(app)
      .post('/orders')
      .send({ customerId: 'cust_suspended', items: [validItem()], shippingAddress: testAddr() });

    expect(res.status).toBe(422);
  });

  it('should return 400 on missing required fields', async () => {
    const res = await request(app).post('/orders').send({ items: [] }); // Missing customerId
    expect(res.status).toBe(400);
  });
});

These tests exercise the full stack: HTTP parsing → validation middleware → controller → use case → domain → in-memory repository → event bus spy. No database. No Stripe. Full path in under 20ms per test.


6. Test Organization and CI Pipeline

6.1 Directory Structure

tests/
├── unit/
│   ├── domain/
│   │   ├── order/         ← Order, LineItem, OrderStatus tests
│   │   ├── payment/       ← Money tests
│   │   ├── customer/      ← Customer, EmailAddress tests
│   │   └── discount/      ← Specification, Strategy tests
│   ├── application/
│   │   ├── PlaceOrderUseCase.test.ts
│   │   ├── CancelOrderUseCase.test.ts
│   │   └── GetOrderSummaryUseCase.test.ts
│   └── infrastructure/
│       ├── InMemoryOrderRepository.contract.test.ts
│       └── InMemoryCustomerRepository.contract.test.ts
├── integration/           ← Requires Docker Compose
│   ├── PrismaOrderRepository.contract.test.ts
│   └── OutboxPublisher.test.ts
├── e2e/
│   └── orders.e2e.test.ts
└── shared/
    ├── OrderRepositoryContract.ts    ← Shared contract suite
    ├── builders.ts                   ← Test data builders (Order, Customer, Money)
    └── fixtures.ts                   ← Reusable test fixtures

6.2 Vitest Configuration

TYPESCRIPT
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['tests/**/*.test.ts'],
    exclude: ['tests/integration/**'], // Excluded from default run — needs Docker
    coverage: {
      provider: 'v8',
      include: ['src/domain/**', 'src/application/**'],
      exclude: ['src/infrastructure/**', 'src/interface/**'],
      thresholds: { lines: 90, functions: 95, branches: 85 },
    },
    reporters: ['verbose'],
  },
});
BASH
# Fast suite (no infrastructure): domain + application + E2E with in-memory doubles
npx vitest run                    # ~2s — runs on every commit

# Integration suite (requires Docker): contract tests with real Postgres
docker-compose up -d postgres
npx vitest run --config vitest.integration.config.ts  # ~6s — runs in CI

# Coverage report (domain + application layers only)
npx vitest run --coverage

6.3 Test Data Builders: The Builder Pattern for Tests

Avoid new Order(...) calls scattered across tests — they break when the constructor signature changes:

TYPESCRIPT
// tests/shared/builders.ts
export class OrderBuilder {
  private customerId: CustomerId = 'cust_default' as CustomerId;
  private items: Array<{ productId: ProductId; quantity: number; unitPrice: Money }> = [];
  private address: Address = testAddress();

  withCustomer(id: CustomerId): this { this.customerId = id; return this; }
  withItem(productId: ProductId, qty: number, price: Money): this {
    this.items.push({ productId, quantity: qty, unitPrice: price });
    return this;
  }
  withAddress(addr: Address): this { this.address = addr; return this; }

  build(): Order {
    const order = Order.create(this.customerId);
    order.setShippingAddress(this.address);
    for (const item of this.items) order.addItem(item.productId, item.quantity, item.unitPrice);
    return order;
  }

  buildPlaced(): Order {
    const order = this.build();
    if (this.items.length === 0) order.addItem('prod_default' as ProductId, 1, Money.of('USD', 10));
    order.place();
    return order;
  }
}

// Usage in tests:
const order = new OrderBuilder()
  .withCustomer('cust_vip' as CustomerId)
  .withItem('prod_A' as ProductId, 2, Money.of('USD', 49.99))
  .buildPlaced();

When Order.create() gains a new required parameter, fix it in OrderBuilder — zero test files need updating.


Summary

Test Category Scope Speed Infrastructure Primary Assertion
Domain Unit Aggregates, Value Objects, Specifications < 1ms each None Business rule enforcement
Application Unit Use Cases via in-memory doubles < 5ms each None Orchestration correctness, event dispatch
Infrastructure Contract Both InMemory and Prisma against shared suite ~100ms each Postgres (Docker) Behavioral equivalence across implementations
E2E HTTP Full stack with supertest + test container < 20ms each None HTTP → domain → response path

What's Next

In Part 13, we implement PrismaOrderRepository in full detail — the Anti-Corruption Layer that translates between flat Prisma rows and rich domain Aggregates, handling the toDomain() mapping, optimistic concurrency via version, and batch-safe createMany within a single $transaction. Part 13: Persistence & Repository →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Testing#Domain-Driven Design#Node.js#TypeScript#Vitest#Integration Testing#Clean Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.