orders-service works, but we only know that because we followed six steps with curl. In a monolith, a test suite that starts the whole application against a database covers almost everything; with microservices that stops being possible: the behavior of "create an order" depends on three services, a broker and two databases, and we cannot spin all of that up on every run in every repository. The answer is to spread confidence across levels: unit tests for the domain, component tests for the isolated service with doubles, integration tests with the real dependencies of that service (PostgreSQL, RabbitMQ) in ephemeral containers, contract tests that guarantee Orders and Catalog keep understanding each other without deploying together, and very few end-to-end tests. In this lesson we set up each level with real code on top of what we built in 04-02 and 04-04, and we fix TechCorp's strategy.

Contents

  1. The test pyramid adapted to microservices
  2. Tools and suite organization
  3. Unit tests for the domain
  4. Component tests with Supertest and doubles
  5. Integration tests with Testcontainers: outbox and saga consumer
  6. Consumer-driven contract tests with Pact
  7. Event tests: the envelope and payload contract
  8. End-to-end tests: few and purposeful
  9. Test doubles, deterministic data and TechCorp's strategy

  1. The test pyramid adapted to microservices

Level What it tests What it does NOT need Speed Maintenance cost Where it runs
Unit One function or domain module (Order, transition, productTranslator) No DB, no network, no Express ms Low Laptop and CI, on every commit
Component (or service) The whole service in-process (createApp) with its external dependencies replaced by doubles No real DB, no other services, no port tens of ms Low-medium Laptop and CI, on every commit
Integration The service against its real infrastructure dependencies (PostgreSQL, RabbitMQ) in ephemeral containers Other services seconds Medium CI on every PR; laptop on demand
Contract That the consumer's expectations of a provider (Orders → Catalog) hold, without deploying both The other service running seconds Medium CI of both repositories
End-to-end (E2E) A complete business flow with every service deployed minutes High: brittle, slow, hard to diagnose A test environment, before promoting to production

The shape is still a pyramid: many unit and component tests, some integration and contract tests, very few E2E. E2E tests are the only ones that exercise the real system, but each one depends on everything (seven services, two DBs, the broker, consistent data) and fails for reasons unrelated to what it wants to test; with twenty services, a large E2E suite becomes the bottleneck of every deployment. Contract tests are the piece that makes it possible to reduce them: they give the integration guarantee between pairs of services at the cost of a unit test.

  1. Tools and suite organization

Tool Use Level
Jest (or Vitest, equivalent) Runner, assertions, mocks All
Supertest HTTP requests to an Express app without opening a port Component
Testcontainers (@testcontainers/postgresql, @testcontainers/rabbitmq) Starts real containers from the test and destroys them when done Integration
Pact (@pact-foundation/pact) Consumer-provider contracts Contract
Ajv Validate event payloads against JSON Schema Events
Docker Compose Bring up the full system (05-01) E2E
npm install -D jest supertest @testcontainers/postgresql @testcontainers/rabbitmq @pact-foundation/pact ajv

Layout in orders-service and scripts that replace the "test": "jest" from 04-02:

tests/
├── unit/             domain.order.test.js, productTranslator.test.js
├── component/        orders.api.test.js
├── integration/      outbox.test.js, sagaConsumer.test.js
├── contract/         catalog.consumer.pact.test.js
├── events/           orderCreated.schema.test.js
├── doubles/          inMemoryOrderRepository.js, catalogClientDouble.js, customersClientDouble.js
└── fixtures/         products.js (p-501, p-777), customers.js (c-1024), orderRequest.js
"scripts": {
  "test": "jest tests/unit tests/component tests/events",
  "test:integration": "jest tests/integration --runInBand",
  "test:contract": "jest tests/contract --runInBand",
  "test:all": "npm test && npm run test:integration && npm run test:contract"
}

npm test is what runs on every save and on every commit: no Docker, in a few seconds. Integration and contract tests need Docker and run on every PR (05-03).

  1. Unit tests for the domain

The domain from 04-04 (domain/order.js, domain/orderStateMachine.js) imports nothing from infrastructure, so it is tested with pure functions. The deterministic fixtures are reused at every level:

// tests/fixtures/products.js — the Map returned by catalogClient.getProducts (04-04)
const products = new Map([
  ['p-501', { productId: 'p-501', name: 'BT X200 Headphones', unitPrice: 59.90, available: true }],
  ['p-777', { productId: 'p-777', name: 'USB-C Cable 2 m', unitPrice: 9.90, available: true }]
]);
const customer = { customerId: 'c-1024', name: 'Ana Ruiz', email: '[email protected]' };
const orderRequest = {
  customerId: 'c-1024',
  lines: [{ productId: 'p-501', quantity: 1 }, { productId: 'p-777', quantity: 2 }],
  shippingAddress: { street: 'Gran Vía 12', postalCode: '28013', city: 'Madrid', country: 'ES' }
};
module.exports = { products, customer, orderRequest };
// tests/unit/domain.order.test.js
const Order = require('../../src/domain/order');
const { transition } = require('../../src/domain/orderStateMachine');
const { products, customer, orderRequest } = require('../fixtures/products');

describe('Order.createOrder', () => {
  test('freezes name and price and computes the total without floating-point errors', () => {
    const order = Order.createOrder(orderRequest, { customer, products });
    expect(order.status).toBe('PENDING');
    expect(order.orderId).toMatch(/^ord-[0-9a-f]{8}$/);
    expect(order.lines[0]).toMatchObject({ line: 1, productId: 'p-501', productName: 'BT X200 Headphones', unitPrice: 59.90, quantity: 1 });
    expect(order.total).toBe(79.70);               // 59.90 + 2 × 9.90; with floats it would come out as 79.69999999999999
  });
});

describe('state machine', () => {
  test.each([
    ['PENDING', 'stock.reserved', 'STOCK_RESERVED'],
    ['STOCK_RESERVED', 'payment.confirmed', 'PAID'],
    ['PAID', 'confirm', 'CONFIRMED'],
    ['STOCK_RESERVED', 'payment.rejected', 'CANCELLED'],
    ['CONFIRMED', 'stock.reserved', null],          // late event: ignored
    ['CANCELLED', 'payment.confirmed', null]        // payment after cancellation: ignored (compensation in Payments)
  ])('%s + %s → %s', (status, event, expected) => {
    expect(transition(status, event)).toBe(expected);
  });

  test('applySagaEvent sets the reason when cancelling', () => {
    const order = Order.createOrder(orderRequest, { customer, products });
    Order.applySagaEvent(order, 'stock.rejected');
    expect(order).toMatchObject({ status: 'CANCELLED', cancellationReason: 'OUT_OF_STOCK' });
  });
});

test.each turns the TRANSITIONS table from 02-05 into a table of cases: every row of the design has its test, including the forbidden transitions, which are the ones that protect the saga from late events.

  1. Component tests with Supertest and doubles

This is where the createApp/server.js separation and the dependency injection from 04-02 pay off: the Orders app is built with an in-memory repository and HTTP client doubles, and Supertest sends it requests without opening any port.

// tests/doubles/catalogClientDouble.js — same contract as createCatalogClient (04-04)
const { BusinessError } = require('@techcorp/common-http');
function createCatalogClientDouble(knownProducts) {
  return {
    calls: [],
    async getProducts(ids) {
      this.calls.push(ids);
      const missing = ids.filter((id) => !knownProducts.has(id));
      if (missing.length) throw new BusinessError('PRODUCT_UNAVAILABLE', `Unavailable products: ${missing.join(', ')}`, 422);
      return new Map(ids.map((id) => [id, knownProducts.get(id)]));
    }
  };
}
module.exports = { createCatalogClientDouble };

inMemoryOrderRepository.js implements the same interface as createOrderRepository (saveWithEvents, get, findKey, saveKey, processOnce, transaction) on top of Maps, and also exposes outbox (an array) so tests can check which events would have been published. It is the equivalent of the in-memory repository from exercise 2 of 04-02.

// tests/component/orders.api.test.js
const request = require('supertest');
const pino = require('pino');
const { createApp } = require('../../src/app');
const { createInMemoryOrderRepository } = require('../doubles/inMemoryOrderRepository');
const { createCatalogClientDouble } = require('../doubles/catalogClientDouble');
const { createCustomersClientDouble } = require('../doubles/customersClientDouble');
const { products, customer, orderRequest } = require('../fixtures/products');

function setup() {
  const repository = createInMemoryOrderRepository();
  const app = createApp({
    repository,
    catalogClient: createCatalogClientDouble(products),
    customersClient: createCustomersClientDouble([customer]),
    logger: pino({ level: 'silent' })
  });
  return { app, repository };
}

describe('POST /v1/orders', () => {
  test('creates the order, responds 202 + Location and leaves order.created in the outbox', async () => {
    const { app, repository } = setup();
    const res = await request(app).post('/v1/orders').set('Idempotency-Key', 'key-1').send(orderRequest);

    expect(res.status).toBe(202);
    expect(res.headers.location).toMatch(/^\/v1\/orders\/ord-/);
    expect(res.body).toMatchObject({ status: 'PENDING', total: 79.70 });
    expect(repository.outbox).toHaveLength(1);
    expect(repository.outbox[0]).toMatchObject({ type: 'order.created', payload: { customerId: 'c-1024', total: 79.70 } });
  });

  test('is idempotent: same key and same body → same order, no second event', async () => {
    const { app, repository } = setup();
    const first = await request(app).post('/v1/orders').set('Idempotency-Key', 'key-2').send(orderRequest);
    const second = await request(app).post('/v1/orders').set('Idempotency-Key', 'key-2').send(orderRequest);
    expect(second.status).toBe(202);
    expect(second.body.id).toBe(first.body.id);
    expect(repository.outbox).toHaveLength(1);
  });

  test('same key with a different body → 422 IDEMPOTENCY_KEY_REUSED', async () => {
    const { app } = setup();
    await request(app).post('/v1/orders').set('Idempotency-Key', 'key-3').send(orderRequest);
    const other = { ...orderRequest, lines: [{ productId: 'p-501', quantity: 5 }] };
    const res = await request(app).post('/v1/orders').set('Idempotency-Key', 'key-3').send(other);
    expect(res.status).toBe(422);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body.code).toBe('IDEMPOTENCY_KEY_REUSED');
  });

  test('without Idempotency-Key → 400; unknown product → 422 PRODUCT_UNAVAILABLE', async () => {
    const { app } = setup();
    expect((await request(app).post('/v1/orders').send(orderRequest)).status).toBe(400);
    const res = await request(app).post('/v1/orders').set('Idempotency-Key', 'key-4')
      .send({ ...orderRequest, lines: [{ productId: 'p-999', quantity: 1 }] });
    expect(res.status).toBe(422);
    expect(res.body.code).toBe('PRODUCT_UNAVAILABLE');
  });
});

These tests cover the route, the validation, the use case, the domain and the error format together, in milliseconds and without infrastructure. What they do not cover (and should not try to) is whether the SQL in saveWithEvents is correct or whether the fetch to Catalog works: that belongs to the next two levels. For the real HTTP client (catalogClient.js) there is an alternative to injected doubles: intercepting the network with nock (nock('http://catalog.test').get('/v1/products').query({ ids: 'p-501,p-777' }).reply(200, {...})), useful for testing the mapping of timeouts and 5xx to DEPENDENCY_UNAVAILABLE. TechCorp uses injected doubles for component tests and reserves nock for testing the clients themselves.

  1. Integration tests with Testcontainers: outbox and saga consumer

Testcontainers starts a real PostgreSQL and RabbitMQ in Docker from the test itself, on random ports, and destroys them when finished. That way we test the real SQL, the real FOR UPDATE SKIP LOCKED and the real AMQP topology, without depending on anything preinstalled.

// tests/integration/outbox.test.js
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { RabbitMQContainer } = require('@testcontainers/rabbitmq');
const pino = require('pino');
const { createPostgresPool } = require('../../src/infra/postgres');
const { createOrderRepository } = require('../../src/repositories/orderRepository');
const { createOutboxRelay } = require('../../src/messaging/outboxRelay');
const { applyMigrations } = require('../../scripts/migrate');
const { connect } = require('@techcorp/common-http/messaging/topology');
const Order = require('../../src/domain/order');
const { products, customer, orderRequest } = require('../fixtures/products');

jest.setTimeout(120000);                    // the first run downloads images
let pg, mq, db, repository, amqp;

beforeAll(async () => {
  [pg, mq] = await Promise.all([new PostgreSqlContainer('postgres:16').start(), new RabbitMQContainer('rabbitmq:3-management').start()]);
  db = createPostgresPool({ url: pg.getConnectionUri(), logger: pino({ level: 'silent' }) });
  await applyMigrations(db);                                          // the same migrations as in production
  repository = createOrderRepository(db);
  amqp = await connect(mq.getAmqpUrl());                              // declares techcorp.events (03-02)
});
afterAll(async () => { await amqp.connection.close(); await db.close(); await Promise.all([pg.stop(), mq.stop()]); });

test('the relay publishes order.created exactly once and sets published_at', async () => {
  // Test queue bound to order.created: we play "Inventory"
  await amqp.channel.assertQueue('test.orders', { exclusive: true });
  await amqp.channel.bindQueue('test.orders', 'techcorp.events', 'order.created');

  const order = Order.createOrder(orderRequest, { customer, products });
  await repository.saveWithEvents(order, [{ type: 'order.created', payload: Order.dataForConsumers(order) }]);

  const confirmChannel = await amqp.connection.createConfirmChannel();
  const relay = createOutboxRelay({ db, confirmChannel, intervalMs: 100, logger: pino({ level: 'silent' }) });
  expect(await relay.publishPending()).toBe(1);
  expect(await relay.publishPending()).toBe(0);                      // second pass: nothing pending

  const msg = await amqp.channel.get('test.orders', { noAck: true });
  const envelope = JSON.parse(msg.content.toString());
  expect(envelope).toMatchObject({ type: 'order.created', version: 1, payload: { orderId: order.orderId, total: 79.70 } });
  expect(envelope.eventId).toMatch(/^evt-/);
  const { rows } = await db.query('SELECT published_at FROM outbox WHERE aggregate_id = $1', [order.orderId]);
  expect(rows[0].published_at).not.toBeNull();
});

To make this possible, outboxRelay.js exposes publishPending in addition to start/stop (a one-line change from 04-04) and scripts/migrate.js exports applyMigrations(db). The saga consumer test (sagaConsumer.test.js) follows the same scheme: it saves a PENDING order, starts createSagaConsumer({ channel, repository }), publishes a stock.reserved with publishEvent, waits with a small poll (up to 2 s) for repository.get() to return STOCK_RESERVED, and publishes the same envelope again (same eventId) to check that processed_events has one row and the status does not change. They are the most expensive tests in the repository (10-20 s for the suite), and the ones that catch the most SQL and AMQP bugs.

  1. Consumer-driven contract tests with Pact

The problem they solve: Orders depends on Catalog's GET /v1/products?ids=. The doubles from section 4 test that Orders does the right thing if Catalog responds the way Orders believes; nothing guarantees Catalog responds that way, neither today nor after its team changes something. With Pact, the consumer writes its expectations, a file (the pact) is generated, and the provider verifies it against its real code in its own CI. If Catalog breaks the contract, its build fails before deploying; and if Orders starts depending on a new field, the pact makes it explicit.

Consumer side (Orders repository): Pact spins up a fake server that responds according to the declared interactions, and we run our real catalogClient against it:

// tests/contract/catalog.consumer.pact.test.js (orders-service)
const path = require('node:path');
const { PactV3, MatchersV3: M } = require('@pact-foundation/pact');
const { createCatalogClient } = require('../../src/clients/catalogClient');

const provider = new PactV3({ consumer: 'orders-service', provider: 'catalog-service', dir: path.resolve('pacts') });

test('GET /v1/products?ids= returns data and notFound', async () => {
  provider
    .given('products p-501 and p-777 exist')                    // provider state: Catalog will know how to set it up
    .uponReceiving('a batch of ids with one that does not exist')
    .withRequest({ method: 'GET', path: '/v1/products', query: { ids: 'p-501,p-777,p-999' }, headers: { Accept: 'application/json' } })
    .willRespondWith({
      status: 200,
      headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: {
        // Matchers: Orders demands TYPE and shape, not exact values (except the ids it asked for). That is what "tolerant reader" means in a pact.
        data: M.eachLike({ id: M.string('p-501'), name: M.string('BT X200 Headphones'), price: M.decimal(59.90), available: M.boolean(true) }),
        notFound: M.eachLike('p-999')
      }
    });

  await provider.executeTest(async (fakeServer) => {
    const client = createCatalogClient({ baseUrl: fakeServer.url, timeoutMs: 1000 });
    await expect(client.getProducts(['p-501', 'p-777', 'p-999'], { requestId: 'req-test' }))
      .rejects.toMatchObject({ code: 'PRODUCT_UNAVAILABLE' });    // p-999 in notFound → business error (04-04)
  });
});

When it passes, pacts/orders-service-catalog-service.json is written: the list of interactions Orders needs. Notice that only the fields productTranslator uses appear (id, name, price, available); currency is not there, so Catalog could change it without breaking Orders.

Provider side (Catalog repository): the Verifier starts Catalog's real app (with the in-memory repository from exercise 2 of 04-02, seeded according to the provider state) and replays each interaction from the pact:

// tests/contract/catalog.provider.pact.test.js (catalog-service)
const { Verifier } = require('@pact-foundation/pact');
const pino = require('pino');
const { createApp } = require('../../src/app');
const { createInMemoryProductsRepository } = require('../doubles/inMemoryProductsRepository');

test('catalog-service honors its consumers\' pacts', async () => {
  const repository = createInMemoryProductsRepository();
  const server = createApp({ repository, logger: pino({ level: 'silent' }) }).listen(0);   // free port
  const url = `http://localhost:${server.address().port}`;
  try {
    await new Verifier({
      provider: 'catalog-service',
      providerBaseUrl: url,
      pactUrls: ['../orders-service/pacts/orders-service-catalog-service.json'],   // in CI: from the Pact Broker
      stateHandlers: {
        'products p-501 and p-777 exist': async () => repository.replace([        // replace(): method added to the fake to seed states
          { _id: 'p-501', name: 'BT X200 Headphones', price: 59.90, published: true, category: 'audio' },
          { _id: 'p-777', name: 'USB-C Cable 2 m', price: 9.90, published: true, category: 'accessories' }
        ])
      }
    }).verifyProvider();
  } finally { server.close(); }
});

If tomorrow Catalog renames price to amount, this test fails in Catalog's CI with a message that says exactly which consumer and which interaction break. In 03-06 we saw the theory (price as an object requires /v2/); Pact is what enforces it. How pacts get from one repository to another (the Pact Broker, and its can-i-deploy check that answers "can I deploy this version of Catalog without breaking any consumer?") is part of the 05-03 pipeline; here it is enough to know it exists.

  1. Event tests: the envelope and payload contract

Events are contracts too (the AsyncAPI from 03-06), and they also break silently. Two cheap tests:

// tests/events/orderCreated.schema.test.js — the producer checks that what it publishes matches the published schema
const Ajv = require('ajv');
const schema = require('../../contracts/schemas/order.created.v1.json');   // extracted from asyncapi.yaml
const Order = require('../../src/domain/order');
const { products, customer, orderRequest } = require('../fixtures/products');

test('the order.created payload matches its JSON Schema v1', () => {
  const validate = new Ajv({ allErrors: true }).compile(schema);
  const order = Order.createOrder(orderRequest, { customer, products });
  expect(validate(Order.dataForConsumers(order))).toBe(true);
  expect(validate.errors).toBeNull();
});

And on the consumer side (for example, the saga consumer), a tolerance test: processing a stock.reserved whose payload includes unknown fields ({ orderId, reservationId, warehouse: 'MAD-1', priority: 2 }) must take the order to STOCK_RESERVED just as if it did not have them. It is the guarantee that the consumer follows the tolerant reader rule from 03-06 and that Inventory can evolve its event without coordinating deployments.

  1. End-to-end tests: few and purposeful

TechCorp has one E2E test for the order flow: against the full system brought up with Docker Compose (05-01), it does a POST /api/v1/orders through the gateway (8080), and polls GET /api/v1/orders/{id} until it sees CONFIRMED (with a 15 s limit), checking along the way that notifications-service recorded a delivery. Nothing else: it does not test validations, errors or idempotency (all of that is already covered lower in the pyramid). Its value is detecting wiring problems that no other level sees: a misbound queue, a missing environment variable, an incompatible @techcorp/common-http version. It runs before promoting to staging and to production, not on every commit.

  1. Test doubles, deterministic data and TechCorp's strategy

Double What it is When to use it Example in this module
Stub Returns fixed responses; checks nothing When you only need the dependency to "respond" customersClientDouble that always returns Ana
Mock Records calls and lets you assert on them (toHaveBeenCalledWith) When what you test is that something was called and how Checking that getProducts received ['p-501','p-777'] without duplicates
Fake Simplified working implementation When the dependency has behavior (state) the test needs inMemoryOrderRepository with its outbox
Real container The actual dependency, ephemeral When what you test is the integration with it PostgreSQL and RabbitMQ with Testcontainers

We prefer fakes and injection over library mocks (jest.mock('pg')): fakes test behavior, not calls, and survive internal refactorings. Deterministic data is just as important: p-501, p-777, c-1024 and Ana's request live in tests/fixtures/ and are the same in unit, component, integration, pacts and the seed from 04-02; no test generates random data except ids, and those that depend on time receive an injectable clock.

TechCorp's strategy per service:

Service Unit Component Integration (Testcontainers) Contract When they run
Catalog toDto, cursor 3 endpoints, errors, Cache-Control MongoDB: queries and indexes Provider for Orders and the BFF npm test on every commit; integration and contract on every PR
Orders Order, transition, productTranslator POST/GET /v1/orders, idempotency PostgreSQL + RabbitMQ: outbox, saga consumer, customers_ref Consumer of Catalog and Customers; producer of order.* (schema) Same
Inventory / Payments / Notifications Reservation, charge, template rules Their internal endpoints PostgreSQL + RabbitMQ: idempotent consumers Consumers of order.* (tolerance) Same
All One E2E before promoting to staging/production

Common Mistakes and Tips

  • Testing everything with E2E "because it is the only real thing." Slow, brittle, and when they fail nobody knows why. One or two, for wiring purposes.
  • Component tests that start server.js. They open ports, read process.env, step on each other in parallel. Always createApp with injected dependencies.
  • Mocking the DB library (jest.mock('pg')). They pass even when the SQL is wrong. For SQL, Testcontainers.
  • A Pact contract with exact values (price: 59.90 without a matcher). It breaks with every price change in the provider's data. Types and shape with MatchersV3.
  • Pacts that demand more than what is used. If the pact requires currency and Orders does not use it, it ties itself to Catalog for no reason. Only what productTranslator reads.
  • Random data in tests (faker for prices). One flaky failure costs more than ten tests. Fixed fixtures.
  • Sharing a container across suites without cleaning up. One test leaves an order and another counts rows. One container per suite or TRUNCATE in beforeEach.
  • Skipping the duplicate test. Publishing the same eventId twice is the cheapest and most valuable test of a consumer.

Exercises

Exercise 1. Write the component test for GET /v1/orders/{id} that checks: 404 ORDER_NOT_FOUND in problem+json for an unknown id; 200 with an ETag header for an order created earlier in the same test; and 304 when repeating with If-None-Match equal to the received ETag.

Exercise 2. Write the consumer Pact interaction for Customers' GET /v1/customers/{id} with the state 'customer c-1024 exists' and check that customersClient.getCustomer('c-1024') returns { customerId, name, email }. Add a second interaction for the 404 with state 'customer c-0000 does not exist' and check that CUSTOMER_NOT_FOUND is thrown.

Exercise 3. The Inventory team asks whether they need their own E2E test for "reserve stock when order.created arrives." Classify what they want to test into the levels of the table in section 1 and say which test you would write at each level (no code).

Solutions

Solution 1.

test('GET /v1/orders/:id — 404, 200 with ETag and 304', async () => {
  const { app } = setup();
  const notFound = await request(app).get('/v1/orders/ord-00000000');
  expect(notFound.status).toBe(404);
  expect(notFound.body.code).toBe('ORDER_NOT_FOUND');
  expect(notFound.headers['content-type']).toMatch(/problem\+json/);

  const created = await request(app).post('/v1/orders').set('Idempotency-Key', 'key-get').send(orderRequest);
  const first = await request(app).get(`/v1/orders/${created.body.id}`);
  expect(first.status).toBe(200);
  expect(first.headers.etag).toMatch(new RegExp(`^"${created.body.id}:\\d+"$`));
  expect(first.body).toMatchObject({ id: created.body.id, status: 'PENDING' });

  const repeated = await request(app).get(`/v1/orders/${created.body.id}`).set('If-None-Match', first.headers.etag);
  expect(repeated.status).toBe(304);
  expect(repeated.text).toBe('');
});

Solution 2.

const provider = new PactV3({ consumer: 'orders-service', provider: 'customers-service', dir: path.resolve('pacts') });

test('GET /v1/customers/{id}: existing and nonexistent', async () => {
  provider.given('customer c-1024 exists').uponReceiving('the lookup of c-1024')
    .withRequest({ method: 'GET', path: '/v1/customers/c-1024', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: { id: 'c-1024', name: M.string('Ana Ruiz'), email: M.email('[email protected]'), addresses: M.eachLike({ id: M.string('addr-1'), street: M.string('Gran Vía 12') }) } });
  provider.given('customer c-0000 does not exist').uponReceiving('the lookup of c-0000')
    .withRequest({ method: 'GET', path: '/v1/customers/c-0000', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 404, headers: { 'Content-Type': 'application/problem+json' }, body: { status: 404, code: 'CUSTOMER_NOT_FOUND', detail: M.string() } });

  await provider.executeTest(async (server) => {
    const client = createCustomersClient({ baseUrl: server.url, timeoutMs: 1000 });
    await expect(client.getCustomer('c-1024', { requestId: 'req-test' })).resolves.toMatchObject({ customerId: 'c-1024', name: 'Ana Ruiz', email: '[email protected]' });
    await expect(client.getCustomer('c-0000', { requestId: 'req-test' })).rejects.toMatchObject({ code: 'CUSTOMER_NOT_FOUND' });
  });
});

The pact also documents the error format Orders expects (code: 'CUSTOMER_NOT_FOUND' in problem+json), so Customers cannot change the code without its CI catching it.

Solution 3. They do not need their own E2E. Breakdown: (1) unit: the rule "reserve only if quantity - reserved >= requested" and the computation of expires_at, without a DB; (2) component: the order.created handler with an in-memory reservations repository, checking that it produces stock.reserved (or stock.rejected) in its outbox; (3) integration: with Testcontainers, that the reserved <= quantity constraint from 02-04 really rejects concurrent over-reservation and that the same eventId twice does not reserve twice; (4) event contract: that their consumer tolerates new fields in order.created and that their stock.reserved matches the schema Orders consumes. The single existing E2E (create order → CONFIRMED) already goes through Inventory and would detect a wiring failure; a second E2E just for reservations would add no coverage, only time.

Conclusion

We have spread confidence in orders-service and catalog-service across levels, each with its cost and its purpose: unit tests for the domain (Order, transition with test.each over the transitions table), component tests with Supertest against createApp and injected doubles (POST /v1/orders → 202, idempotency, problem+json errors), integration tests with real PostgreSQL and RabbitMQ through Testcontainers (the outbox relay publishes once and sets published_at; the saga consumer is idempotent), contract tests with Pact (Orders declares what it needs from GET /v1/products?ids=, Catalog verifies it in its CI, with the Pact Broker and can-i-deploy as the hook for 05-03), event tests (payload JSON Schema and tolerance to new fields) and a single E2E for wiring. And we have fixed the rules: fakes and injection over library mocks, deterministic fixtures (p-501, p-777, c-1024) shared across every level, and a strategy table per service.

This closes the implementation module: we have chosen the tools, built catalog-service and orders-service on the same template, disciplined the configuration, connected the services over HTTP and events following the contracts from modules 2 and 3, and protected everything with tests at different levels. What we have are Node.js processes that run with npm run dev on a laptop, with their dependencies in loose containers. Module 5 takes them to production: packaging each service in a Docker image and bringing up the full system with Docker Compose (05-01), deploying and scaling it on Kubernetes with the ConfigMaps and Secrets that 04-03 left ready (05-02), automating tests, pacts and deployment in a CI/CD pipeline (05-03), deploying without downtime with rolling, blue-green and canary strategies (05-04) and, finally, delegating part of the inter-service communication to a service mesh (05-05). It starts with containers.

Microservices Course

Module 1: Introduction to Microservices

Module 2: Microservice Design

Module 3: Communication between Microservices

Module 4: Implementing Microservices

Module 5: Deployment and Orchestration

Module 6: Monitoring and Maintenance

Module 7: Security in Microservices

Module 8: Case Studies and Practical Examples

© Copyright 2026. All rights reserved