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
- The test pyramid adapted to microservices
- Tools and suite organization
- Unit tests for the domain
- Component tests with Supertest and doubles
- Integration tests with Testcontainers: outbox and saga consumer
- Consumer-driven contract tests with Pact
- Event tests: the envelope and payload contract
- End-to-end tests: few and purposeful
- Test doubles, deterministic data and TechCorp's strategy
- 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.
- 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 ajvLayout 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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, readprocess.env, step on each other in parallel. AlwayscreateAppwith 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.90without a matcher). It breaks with every price change in the provider's data. Types and shape withMatchersV3. - Pacts that demand more than what is used. If the pact requires
currencyand Orders does not use it, it ties itself to Catalog for no reason. Only whatproductTranslatorreads. - Random data in tests (
fakerfor 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
TRUNCATEinbeforeEach. - Skipping the duplicate test. Publishing the same
eventIdtwice 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
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
