servei-comandes funciona, però només ho sabem perquè hem seguit sis passos amb curl. En un monòlit, una suite de proves que arrenca l'aplicació sencera contra una base de dades ho cobreix gairebé tot; en microserveis això deixa de ser possible: el comportament de "crear una comanda" depèn de tres serveis, un broker i dues bases de dades, i no podem aixecar tot això a cada execució de cada repositori. La resposta és repartir la confiança en nivells: proves unitàries del domini, proves de component del servei aïllat amb dobles, proves d'integració amb les dependències reals d'aquell servei (PostgreSQL, RabbitMQ) en contenidors efímers, proves de contracte que garanteixen que Comandes i Catàleg es continuen entenent sense desplegar junts, i molt poques proves de cap a cap. En aquesta lliçó muntem cada nivell amb codi real sobre el que hem construït a 04-02 i 04-04, i fixem l'estratègia de TechCorp.

Contingut

  1. La piràmide de proves adaptada a microserveis
  2. Eines i organització de la suite
  3. Proves unitàries del domini
  4. Proves de component amb Supertest i dobles
  5. Proves d'integració amb Testcontainers: outbox i consumidor de la saga
  6. Proves de contracte dirigides pel consumidor amb Pact
  7. Proves d'esdeveniments: el contracte del sobre i del payload
  8. Proves de cap a cap: poques i amb propòsit
  9. Dobles de prova, dades deterministes i l'estratègia de TechCorp

  1. La piràmide de proves adaptada a microserveis

Nivell Què prova Què NO necessita Velocitat Cost de mantenir On corre
Unitària Una funció o mòdul del domini (Comanda, transicionar, traductorProducte) Ni BD, ni xarxa, ni Express ms Baix Portàtil i CI, a cada commit
De component (o de servei) El servei sencer en procés (crearApp) amb les seves dependències externes substituïdes per dobles Ni BD real, ni altres serveis, ni port desenes de ms Baix-mitjà Portàtil i CI, a cada commit
D'integració El servei contra les seves dependències reals d'infraestructura (PostgreSQL, RabbitMQ) en contenidors efímers Altres serveis segons Mitjà CI a cada PR; portàtil sota demanda
De contracte Que les expectatives del consumidor sobre un proveïdor (Comandes → Catàleg) es compleixen, sense desplegar-los tots dos L'altre servei en execució segons Mitjà CI de tots dos repositoris
De cap a cap (E2E) Un flux de negoci complet amb tots els serveis desplegats minuts Alt: fràgils, lentes, difícils de diagnosticar Un entorn de proves, abans de promocionar a producció

La forma continua sent una piràmide: moltes unitàries i de component, algunes d'integració i de contracte, molt poques E2E. Les E2E són les úniques que proven el sistema real, però cadascuna depèn de tot (set serveis, dues BD, el broker, dades coherents) i falla per motius aliens al que vol provar; amb vint serveis, una suite E2E àmplia es converteix en el coll d'ampolla de tots els desplegaments. Les proves de contracte són la peça que fa possible reduir-les: donen la garantia d'integració entre parells de serveis a cost de prova unitària.

  1. Eines i organització de la suite

Eina Ús Nivell
Jest (o Vitest, equivalent) Runner, assercions, mocks Tots
Supertest Peticions HTTP a una app Express sense obrir port Component
Testcontainers (@testcontainers/postgresql, @testcontainers/rabbitmq) Arrenca contenidors reals des de la prova i els destrueix en acabar Integració
Pact (@pact-foundation/pact) Contractes consumidor-proveïdor Contracte
Ajv Validar payloads d'esdeveniments contra JSON Schema Esdeveniments
Docker Compose Aixecar el sistema complet (05-01) E2E
npm install -D jest supertest @testcontainers/postgresql @testcontainers/rabbitmq @pact-foundation/pact ajv

Organització a servei-comandes i scripts que substitueixen el "test": "jest" de 04-02:

proves/
├── unitaries/        domini.comanda.test.js, traductorProducte.test.js
├── component/        comandes.api.test.js
├── integracio/       outbox.test.js, consumidorSaga.test.js
├── contracte/        cataleg.consumidor.pact.test.js
├── esdeveniments/    comandaCreada.esquema.test.js
├── dobles/           comandaRepositoriEnMemoria.js, catalegClientDoble.js, clientsClientDoble.js
└── fixtures/         productes.js (p-501, p-777), clients.js (c-1024), peticioComanda.js
"scripts": {
  "test": "jest proves/unitaries proves/component proves/esdeveniments",
  "test:integracio": "jest proves/integracio --runInBand",
  "test:contracte": "jest proves/contracte --runInBand",
  "test:tot": "npm test && npm run test:integracio && npm run test:contracte"
}

npm test és el que s'executa a cada desada i a cada commit: sense Docker, en pocs segons. Les d'integració i contracte necessiten Docker i corren a cada PR (05-03).

  1. Proves unitàries del domini

El domini de 04-04 (domini/comanda.js, domini/maquinaEstatsComanda.js) no importa res d'infraestructura, així que es prova amb funcions pures. Els fixtures deterministes es reutilitzen a tots els nivells:

// proves/fixtures/productes.js — el Map que retorna catalegClient.obtenirProductes (04-04)
const productes = new Map([
  ['p-501', { producteId: 'p-501', nom: 'Auriculars BT X200', preuUnitari: 59.90, disponible: true }],
  ['p-777', { producteId: 'p-777', nom: 'Cable USB-C 2 m', preuUnitari: 9.90, disponible: true }]
]);
const client = { clientId: 'c-1024', nom: 'Ana Ruiz', email: '[email protected]' };
const peticioComanda = {
  clientId: 'c-1024',
  linies: [{ producteId: 'p-501', quantitat: 1 }, { producteId: 'p-777', quantitat: 2 }],
  adrecaEnviament: { carrer: 'Gran Vía 12', codiPostal: '28013', ciutat: 'Madrid', pais: 'ES' }
};
module.exports = { productes, client, peticioComanda };
// proves/unitaries/domini.comanda.test.js
const Comanda = require('../../src/domini/comanda');
const { transicionar } = require('../../src/domini/maquinaEstatsComanda');
const { productes, client, peticioComanda } = require('../fixtures/productes');

describe('Comanda.crearComanda', () => {
  test('congela nom i preu i calcula el total sense errors de coma flotant', () => {
    const comanda = Comanda.crearComanda(peticioComanda, { client, productes });
    expect(comanda.estat).toBe('PENDENT');
    expect(comanda.comandaId).toMatch(/^com-[0-9a-f]{8}$/);
    expect(comanda.linies[0]).toMatchObject({ linia: 1, producteId: 'p-501', nomProducte: 'Auriculars BT X200', preuUnitari: 59.90, quantitat: 1 });
    expect(comanda.total).toBe(79.70);             // 59.90 + 2 × 9.90; amb flotants sortiria 79.69999999999999
  });
});

describe('màquina d\'estats', () => {
  test.each([
    ['PENDENT', 'estoc.reservat', 'ESTOC_RESERVAT'],
    ['ESTOC_RESERVAT', 'pagament.confirmat', 'PAGADA'],
    ['PAGADA', 'confirmar', 'CONFIRMADA'],
    ['ESTOC_RESERVAT', 'pagament.rebutjat', 'CANCELLADA'],
    ['CONFIRMADA', 'estoc.reservat', null],        // esdeveniment tardà: ignorat
    ['CANCELLADA', 'pagament.confirmat', null]     // pagament després de cancel·lació: ignorat (compensació a Pagaments)
  ])('%s + %s → %s', (estat, esdeveniment, esperat) => {
    expect(transicionar(estat, esdeveniment)).toBe(esperat);
  });

  test('aplicarEsdevenimentSaga fixa el motiu en cancel·lar', () => {
    const comanda = Comanda.crearComanda(peticioComanda, { client, productes });
    Comanda.aplicarEsdevenimentSaga(comanda, 'estoc.rebutjat');
    expect(comanda).toMatchObject({ estat: 'CANCELLADA', motiuCancellacio: 'SENSE_ESTOC' });
  });
});

test.each converteix la taula TRANSICIONS de 02-05 en una taula de casos: cada fila del disseny té la seva prova, incloses les transicions prohibides, que són les que protegeixen la saga d'esdeveniments tardans.

  1. Proves de component amb Supertest i dobles

Aquí és on es cobra la separació crearApp/servidor.js i la injecció de dependències de 04-02: l'app de Comandes es construeix amb un repositori en memòria i clients HTTP dobles, i Supertest li envia peticions sense obrir cap port.

// proves/dobles/catalegClientDoble.js — mateix contracte que crearCatalegClient (04-04)
const { ErrorNegoci } = require('@techcorp/comu-http');
function crearCatalegClientDoble(productesConeguts) {
  return {
    crides: [],
    async obtenirProductes(ids) {
      this.crides.push(ids);
      const falten = ids.filter((id) => !productesConeguts.has(id));
      if (falten.length) throw new ErrorNegoci('PRODUCTE_NO_DISPONIBLE', `Productes no disponibles: ${falten.join(', ')}`, 422);
      return new Map(ids.map((id) => [id, productesConeguts.get(id)]));
    }
  };
}
module.exports = { crearCatalegClientDoble };

comandaRepositoriEnMemoria.js implementa la mateixa interfície que crearComandaRepositori (desarAmbEsdeveniments, obtenir, cercarClau, desarClau, processarUnCop, transaccio) sobre Maps, i a més exposa outbox (array) perquè les proves comprovin quins esdeveniments s'haurien publicat. És l'equivalent del repositori en memòria de l'exercici 2 de 04-02.

// proves/component/comandes.api.test.js
const request = require('supertest');
const pino = require('pino');
const { crearApp } = require('../../src/app');
const { crearComandaRepositoriEnMemoria } = require('../dobles/comandaRepositoriEnMemoria');
const { crearCatalegClientDoble } = require('../dobles/catalegClientDoble');
const { crearClientsClientDoble } = require('../dobles/clientsClientDoble');
const { productes, client, peticioComanda } = require('../fixtures/productes');

function muntar() {
  const repositori = crearComandaRepositoriEnMemoria();
  const app = crearApp({
    repositori,
    catalegClient: crearCatalegClientDoble(productes),
    clientsClient: crearClientsClientDoble([client]),
    logger: pino({ level: 'silent' })
  });
  return { app, repositori };
}

describe('POST /v1/comandes', () => {
  test('crea la comanda, respon 202 + Location i deixa comanda.creada a l\'outbox', async () => {
    const { app, repositori } = muntar();
    const res = await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-1').send(peticioComanda);

    expect(res.status).toBe(202);
    expect(res.headers.location).toMatch(/^\/v1\/comandes\/com-/);
    expect(res.body).toMatchObject({ estat: 'PENDENT', total: 79.70 });
    expect(repositori.outbox).toHaveLength(1);
    expect(repositori.outbox[0]).toMatchObject({ tipus: 'comanda.creada', carrega: { clientId: 'c-1024', total: 79.70 } });
  });

  test('és idempotent: mateixa clau i mateix cos → mateixa comanda, sense segon esdeveniment', async () => {
    const { app, repositori } = muntar();
    const primera = await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-2').send(peticioComanda);
    const segona = await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-2').send(peticioComanda);
    expect(segona.status).toBe(202);
    expect(segona.body.id).toBe(primera.body.id);
    expect(repositori.outbox).toHaveLength(1);
  });

  test('mateixa clau amb un altre cos → 422 CLAU_IDEMPOTENCIA_REUTILITZADA', async () => {
    const { app } = muntar();
    await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-3').send(peticioComanda);
    const altra = { ...peticioComanda, linies: [{ producteId: 'p-501', quantitat: 5 }] };
    const res = await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-3').send(altra);
    expect(res.status).toBe(422);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body.codi).toBe('CLAU_IDEMPOTENCIA_REUTILITZADA');
  });

  test('sense Idempotency-Key → 400; producte desconegut → 422 PRODUCTE_NO_DISPONIBLE', async () => {
    const { app } = muntar();
    expect((await request(app).post('/v1/comandes').send(peticioComanda)).status).toBe(400);
    const res = await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-4')
      .send({ ...peticioComanda, linies: [{ producteId: 'p-999', quantitat: 1 }] });
    expect(res.status).toBe(422);
    expect(res.body.codi).toBe('PRODUCTE_NO_DISPONIBLE');
  });
});

Aquestes proves cobreixen la ruta, la validació, el cas d'ús, el domini i el format d'errors junts, en mil·lisegons i sense infraestructura. El que no cobreixen (i no han d'intentar cobrir) és si l'SQL de desarAmbEsdeveniments és correcte o si el fetch a Catàleg funciona: això és dels dos nivells següents. Per al client HTTP real (catalegClient.js) hi ha una alternativa als dobles injectats: interceptar la xarxa amb nock (nock('http://cataleg.test').get('/v1/productes').query({ ids: 'p-501,p-777' }).reply(200, {...})), útil per provar el mapatge de timeouts i 5xx a DEPENDENCIA_NO_DISPONIBLE. TechCorp fa servir dobles injectats per a les proves de component i reserva nock per provar els clients en si.

  1. Proves d'integració amb Testcontainers: outbox i consumidor de la saga

Testcontainers arrenca un PostgreSQL i un RabbitMQ reals a Docker des de la mateixa prova, amb ports aleatoris, i els destrueix en acabar. Així provem l'SQL de debò, FOR UPDATE SKIP LOCKED de debò i la topologia AMQP de debò, sense dependre de res preinstal·lat.

// proves/integracio/outbox.test.js
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const { RabbitMQContainer } = require('@testcontainers/rabbitmq');
const pino = require('pino');
const { crearPoolPostgres } = require('../../src/infra/postgres');
const { crearComandaRepositori } = require('../../src/repositoris/comandaRepositori');
const { crearRelayOutbox } = require('../../src/missatgeria/relayOutbox');
const { aplicarMigracions } = require('../../scripts/migrar');
const { connectar } = require('@techcorp/comu-http/missatgeria/topologia');
const Comanda = require('../../src/domini/comanda');
const { productes, client, peticioComanda } = require('../fixtures/productes');

jest.setTimeout(120000);                    // la primera vegada descarrega imatges
let pg, mq, bd, repositori, amqp;

beforeAll(async () => {
  [pg, mq] = await Promise.all([new PostgreSqlContainer('postgres:16').start(), new RabbitMQContainer('rabbitmq:3-management').start()]);
  bd = crearPoolPostgres({ url: pg.getConnectionUri(), logger: pino({ level: 'silent' }) });
  await aplicarMigracions(bd);                                        // les mateixes migracions que en producció
  repositori = crearComandaRepositori(bd);
  amqp = await connectar(mq.getAmqpUrl());                            // declara techcorp.esdeveniments (03-02)
});
afterAll(async () => { await amqp.connexio.close(); await bd.tancar(); await Promise.all([pg.stop(), mq.stop()]); });

test('el relay publica comanda.creada exactament un cop i marca publicat_en', async () => {
  // Cua de prova enllaçada a comanda.creada: fem d'"Inventari"
  await amqp.canal.assertQueue('prova.comandes', { exclusive: true });
  await amqp.canal.bindQueue('prova.comandes', 'techcorp.esdeveniments', 'comanda.creada');

  const comanda = Comanda.crearComanda(peticioComanda, { client, productes });
  await repositori.desarAmbEsdeveniments(comanda, [{ tipus: 'comanda.creada', carrega: Comanda.dadesPerAConsumidors(comanda) }]);

  const canalConfirm = await amqp.connexio.createConfirmChannel();
  const relay = crearRelayOutbox({ bd, canalConfirm, intervalMs: 100, logger: pino({ level: 'silent' }) });
  expect(await relay.publicarPendents()).toBe(1);
  expect(await relay.publicarPendents()).toBe(0);                    // segona passada: res pendent

  const msg = await amqp.canal.get('prova.comandes', { noAck: true });
  const sobre = JSON.parse(msg.content.toString());
  expect(sobre).toMatchObject({ tipus: 'comanda.creada', versio: 1, carrega: { comandaId: comanda.comandaId, total: 79.70 } });
  expect(sobre.esdevenimentId).toMatch(/^evt-/);
  const { rows } = await bd.consultar('SELECT publicat_en FROM outbox WHERE agregat_id = $1', [comanda.comandaId]);
  expect(rows[0].publicat_en).not.toBeNull();
});

Perquè sigui possible, relayOutbox.js exposa publicarPendents a més d'iniciar/aturar (un canvi d'una línia respecte a 04-04) i scripts/migrar.js exporta aplicarMigracions(bd). La prova del consumidor de la saga (consumidorSaga.test.js) segueix el mateix esquema: desa una comanda PENDENT, arrenca crearConsumidorSaga({ canal, repositori }), publica un estoc.reservat amb publicarEsdeveniment, espera amb un petit polling (fins a 2 s) que repositori.obtenir() retorni ESTOC_RESERVAT, i publica el mateix sobre una altra vegada (mateix esdevenimentId) per comprovar que esdeveniments_processats té una fila i l'estat no canvia. Són les proves més cares del repositori (10-20 s la suite), i les que més bugs d'SQL i d'AMQP atrapen.

  1. Proves de contracte dirigides pel consumidor amb Pact

El problema que resolen: Comandes depèn de GET /v1/productes?ids= de Catàleg. Els dobles de l'apartat 4 proven que Comandes fa el correcte si Catàleg respon com Comandes creu; res no garanteix que Catàleg respongui així, ni avui ni després que el seu equip canviï alguna cosa. Amb Pact, el consumidor escriu les seves expectatives, es genera un fitxer (el pacte) i el proveïdor el verifica contra el seu codi real al seu propi CI. Si Catàleg trenca el contracte, la seva build falla abans de desplegar; i si Comandes comença a dependre d'un camp nou, el pacte ho fa explícit.

Costat consumidor (repositori de Comandes): Pact aixeca un servidor fals que respon segons les interaccions declarades, i executem el nostre catalegClient real contra ell:

// proves/contracte/cataleg.consumidor.pact.test.js (servei-comandes)
const path = require('node:path');
const { PactV3, MatchersV3: M } = require('@pact-foundation/pact');
const { crearCatalegClient } = require('../../src/clients/catalegClient');

const proveidor = new PactV3({ consumer: 'servei-comandes', provider: 'servei-cataleg', dir: path.resolve('pactes') });

test('GET /v1/productes?ids= retorna dades i noTrobats', async () => {
  proveidor
    .given('existeixen els productes p-501 i p-777')          // estat del proveïdor: Catàleg sabrà preparar-lo
    .uponReceiving('un lot d\'ids amb un d\'inexistent')
    .withRequest({ method: 'GET', path: '/v1/productes', 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: Comandes exigeix TIPUS i forma, no valors exactes (excepte els ids que va demanar). És el que "tolerant reader" vol dir en un pacte.
        dades: M.eachLike({ id: M.string('p-501'), nom: M.string('Auriculars BT X200'), preu: M.decimal(59.90), disponible: M.boolean(true) }),
        noTrobats: M.eachLike('p-999')
      }
    });

  await proveidor.executeTest(async (servidorFals) => {
    const client = crearCatalegClient({ urlBase: servidorFals.url, timeoutMs: 1000 });
    await expect(client.obtenirProductes(['p-501', 'p-777', 'p-999'], { requestId: 'req-test' }))
      .rejects.toMatchObject({ codi: 'PRODUCTE_NO_DISPONIBLE' });      // p-999 a noTrobats → error de negoci (04-04)
  });
});

En passar, s'escriu pactes/servei-comandes-servei-cataleg.json: la llista d'interaccions que Comandes necessita. Fixa't que només hi apareixen els camps que el traductorProducte fa servir (id, nom, preu, disponible); moneda no hi és, així que Catàleg podria canviar-lo sense trencar Comandes.

Costat proveïdor (repositori de Catàleg): el Verifier arrenca l'app real de Catàleg (amb el repositori en memòria de l'exercici 2 de 04-02, sembrat segons el provider state) i reprodueix cada interacció del pacte:

// proves/contracte/cataleg.proveidor.pact.test.js (servei-cataleg)
const { Verifier } = require('@pact-foundation/pact');
const pino = require('pino');
const { crearApp } = require('../../src/app');
const { crearProductesRepositoriEnMemoria } = require('../dobles/productesRepositoriEnMemoria');

test('servei-cataleg compleix els pactes dels seus consumidors', async () => {
  const repositori = crearProductesRepositoriEnMemoria();
  const servidor = crearApp({ repositori, logger: pino({ level: 'silent' }) }).listen(0);   // port lliure
  const url = `http://localhost:${servidor.address().port}`;
  try {
    await new Verifier({
      provider: 'servei-cataleg',
      providerBaseUrl: url,
      pactUrls: ['../servei-comandes/pactes/servei-comandes-servei-cataleg.json'],   // a CI: des del Pact Broker
      stateHandlers: {
        'existeixen els productes p-501 i p-777': async () => repositori.reemplacar([   // reemplacar(): mètode afegit al fake per sembrar estats
          { _id: 'p-501', nom: 'Auriculars BT X200', preu: 59.90, publicat: true, categoria: 'audio' },
          { _id: 'p-777', nom: 'Cable USB-C 2 m', preu: 9.90, publicat: true, categoria: 'accessoris' }
        ])
      }
    }).verifyProvider();
  } finally { servidor.close(); }
});

Si demà Catàleg reanomena preu a import, aquesta prova falla al CI de Catàleg amb un missatge que diu exactament quin consumidor i quina interacció es trenquen. A 03-06 vam veure la teoria (preu com a objecte exigeix /v2/); Pact és qui la fa complir. Com arriben els pactes d'un repositori a l'altre (el Pact Broker, i la seva comprovació can-i-deploy que respon "puc desplegar aquesta versió de Catàleg sense trencar cap consumidor?") forma part del pipeline de 05-03; aquí n'hi ha prou de saber que existeix.

  1. Proves d'esdeveniments: el contracte del sobre i del payload

Els esdeveniments també són contractes (AsyncAPI de 03-06), i també es trenquen en silenci. Dues proves barates:

// proves/esdeveniments/comandaCreada.esquema.test.js — el productor comprova que el que publica compleix l'esquema publicat
const Ajv = require('ajv');
const esquema = require('../../contractes/esquemes/comanda.creada.v1.json');   // extret de l'asyncapi.yaml
const Comanda = require('../../src/domini/comanda');
const { productes, client, peticioComanda } = require('../fixtures/productes');

test('la càrrega de comanda.creada compleix el seu JSON Schema v1', () => {
  const validar = new Ajv({ allErrors: true }).compile(esquema);
  const comanda = Comanda.crearComanda(peticioComanda, { client, productes });
  expect(validar(Comanda.dadesPerAConsumidors(comanda))).toBe(true);
  expect(validar.errors).toBeNull();
});

I al costat consumidor (per exemple, el consumidor de la saga), una prova de tolerància: processar un estoc.reservat la càrrega del qual inclou camps desconeguts ({ comandaId, reservaId, magatzem: 'MAD-1', prioritat: 2 }) ha de portar la comanda a ESTOC_RESERVAT igual que si no els tingués. És la garantia que el consumidor compleix la regla del tolerant reader de 03-06 i que Inventari pot fer evolucionar el seu esdeveniment sense coordinar desplegaments.

  1. Proves de cap a cap: poques i amb propòsit

TechCorp té una prova E2E per al flux de comanda: contra el sistema complet aixecat amb Docker Compose (05-01), fa POST /api/v1/comandes a través del gateway (8080), i espera amb polling sobre GET /api/v1/comandes/{id} fins a veure CONFIRMADA (amb un límit de 15 s), comprovant de passada que servei-notificacions va registrar un enviament. Res més: no prova validacions, ni errors, ni idempotència (tot això ja està cobert més avall a la piràmide). El seu valor és detectar problemes de cablejat que cap altre nivell no veu: una cua mal enllaçada, una variable d'entorn que falta, una versió de @techcorp/comu-http incompatible. S'executa abans de promocionar a staging i a producció, no a cada commit.

  1. Dobles de prova, dades deterministes i l'estratègia de TechCorp

Doble Què és Quan fer-lo servir Exemple en aquest mòdul
Stub Retorna respostes fixes; no comprova res Quan només necessites que la dependència "respongui" clientsClientDoble que sempre retorna l'Ana
Mock Registra crides i permet afirmar sobre elles (toHaveBeenCalledWith) Quan el que proves és que s'ha cridat alguna cosa i com Comprovar que obtenirProductes va rebre ['p-501','p-777'] sense duplicats
Fake Implementació funcional simplificada Quan la dependència té comportament (estat) que la prova necessita comandaRepositoriEnMemoria amb el seu outbox
Contenidor real La dependència de debò, efímera Quan el que proves és la integració amb ella PostgreSQL i RabbitMQ amb Testcontainers

Preferim fakes i injecció als mocks de llibreries (jest.mock('pg')): els fakes proven comportament, no crides, i sobreviuen a refactoritzacions internes. Les dades deterministes són igual d'importants: p-501, p-777, c-1024 i la petició de l'Ana viuen a proves/fixtures/ i són les mateixes a unitàries, component, integració, pactes i la llavor de 04-02; cap prova no genera dades aleatòries llevat dels ids, i les que depenen del temps reben un rellotge injectable.

Estratègia de TechCorp per servei:

Servei Unitàries Component Integració (Testcontainers) Contracte Quan corren
Catàleg aDto, cursor 3 endpoints, errors, Cache-Control MongoDB: consultes i índexs Proveïdor de Comandes i del BFF npm test a cada commit; integració i contracte a cada PR
Comandes Comanda, transicionar, traductorProducte POST/GET /v1/comandes, idempotència PostgreSQL + RabbitMQ: outbox, consumidor saga, clients_ref Consumidor de Catàleg i Clients; productor de comanda.* (esquema) Igual
Inventari / Pagaments / Notificacions Regles de reserva, cobrament, plantilles Els seus endpoints interns PostgreSQL + RabbitMQ: consumidors idempotents Consumidors de comanda.* (tolerància) Igual
Tots Una E2E abans de promocionar a staging/producció

Errors Comuns i Consells

  • Provar-ho tot amb E2E "perquè és l'únic real". Lentes, fràgils, i quan fallen ningú no sap per què. Una o dues, amb propòsit de cablejat.
  • Proves de component que arrenquen servidor.js. Obren ports, llegeixen process.env, es trepitgen en paral·lel. Sempre crearApp amb dependències injectades.
  • Mocks de la llibreria de BD (jest.mock('pg')). Passen encara que l'SQL estigui malament. Per a l'SQL, Testcontainers.
  • Un contracte Pact amb valors exactes (preu: 59.90 sense matcher). Es trenca amb cada canvi de preu a les dades del proveïdor. Tipus i forma amb MatchersV3.
  • Pactes que demanen més del que es fa servir. Si el pacte exigeix moneda i Comandes no el fa servir, es lliga a Catàleg sense motiu. Només el que el traductorProducte llegeix.
  • Dades aleatòries a les proves (faker per a preus). Una fallada intermitent costa més que deu proves. Fixtures fixos.
  • Compartir un contenidor entre suites sense netejar. Una prova deixa una comanda i una altra compta files. Contenidor per suite o TRUNCATE a beforeEach.
  • Ignorar la prova del duplicat. Publicar el mateix esdevenimentId dues vegades és la prova més barata i més valuosa d'un consumidor.

Exercicis

Exercici 1. Escriu la prova de component de GET /v1/comandes/{id} que comprova: 404 COMANDA_NO_EXISTEIX en problem+json per a un id desconegut; 200 amb capçalera ETag per a una comanda creada abans a la mateixa prova; i 304 en repetir amb If-None-Match igual a l'ETag rebut.

Exercici 2. Escriu la interacció Pact del consumidor per a GET /v1/clients/{id} de Clients amb l'estat 'existeix el client c-1024' i comprova que clientsClient.obtenirClient('c-1024') retorna { clientId, nom, email }. Afegeix una segona interacció per al 404 amb estat 'no existeix el client c-0000' i comprova que es llança CLIENT_NO_EXISTEIX.

Exercici 3. L'equip d'Inventari pregunta si necessiten una prova E2E pròpia per a "reservar estoc quan arriba comanda.creada". Classifica el que volen provar en els nivells de la taula de l'apartat 1 i digues quina prova de cada nivell escriuries (sense codi).

Solucions

Solució 1.

test('GET /v1/comandes/:id — 404, 200 amb ETag i 304', async () => {
  const { app } = muntar();
  const noExisteix = await request(app).get('/v1/comandes/com-00000000');
  expect(noExisteix.status).toBe(404);
  expect(noExisteix.body.codi).toBe('COMANDA_NO_EXISTEIX');
  expect(noExisteix.headers['content-type']).toMatch(/problem\+json/);

  const creada = await request(app).post('/v1/comandes').set('Idempotency-Key', 'clau-get').send(peticioComanda);
  const primera = await request(app).get(`/v1/comandes/${creada.body.id}`);
  expect(primera.status).toBe(200);
  expect(primera.headers.etag).toMatch(new RegExp(`^"${creada.body.id}:\\d+"$`));
  expect(primera.body).toMatchObject({ id: creada.body.id, estat: 'PENDENT' });

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

Solució 2.

const proveidor = new PactV3({ consumer: 'servei-comandes', provider: 'servei-clients', dir: path.resolve('pactes') });

test('GET /v1/clients/{id}: existent i no existent', async () => {
  proveidor.given('existeix el client c-1024').uponReceiving('la consulta de c-1024')
    .withRequest({ method: 'GET', path: '/v1/clients/c-1024', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 200, headers: { 'Content-Type': 'application/json; charset=utf-8' },
      body: { id: 'c-1024', nom: M.string('Ana Ruiz'), email: M.email('[email protected]'), adreces: M.eachLike({ id: M.string('adr-1'), carrer: M.string('Gran Vía 12') }) } });
  proveidor.given('no existeix el client c-0000').uponReceiving('la consulta de c-0000')
    .withRequest({ method: 'GET', path: '/v1/clients/c-0000', headers: { Accept: 'application/json' } })
    .willRespondWith({ status: 404, headers: { 'Content-Type': 'application/problem+json' }, body: { status: 404, codi: 'CLIENT_NO_EXISTEIX', detail: M.string() } });

  await proveidor.executeTest(async (servidor) => {
    const client = crearClientsClient({ urlBase: servidor.url, timeoutMs: 1000 });
    await expect(client.obtenirClient('c-1024', { requestId: 'req-test' })).resolves.toMatchObject({ clientId: 'c-1024', nom: 'Ana Ruiz', email: '[email protected]' });
    await expect(client.obtenirClient('c-0000', { requestId: 'req-test' })).rejects.toMatchObject({ codi: 'CLIENT_NO_EXISTEIX' });
  });
});

El pacte documenta també el format d'error que Comandes espera (codi: 'CLIENT_NO_EXISTEIX' en problem+json), de manera que Clients no pot canviar el codi sense que el seu CI ho detecti.

Solució 3. No necessiten una E2E pròpia. Descomposició: (1) unitària: la regla "reservar només si quantitat - reservat >= demanada" i el càlcul d'expira_en, sense BD; (2) component: el gestor de comanda.creada amb un repositori de reserves en memòria, comprovant que produeix estoc.reservat (o estoc.rebutjat) al seu outbox; (3) integració: amb Testcontainers, que la restricció reservat <= quantitat de 02-04 rebutja de debò la sobrereserva concurrent i que el mateix esdevenimentId dues vegades no reserva dues vegades; (4) contracte d'esdeveniments: que el seu consumidor tolera camps nous a comanda.creada i que el seu estoc.reservat compleix l'esquema que Comandes consumeix. L'única E2E existent (crear comanda → CONFIRMADA) ja passa per Inventari i detectaria una fallada de cablejat; una segona E2E només per a reserves no afegiria cobertura, només temps.

Conclusió

Hem repartit la confiança en servei-comandes i servei-cataleg en nivells, cadascun amb el seu cost i el seu propòsit: proves unitàries del domini (Comanda, transicionar amb test.each sobre la taula de transicions), de component amb Supertest contra crearApp i dobles injectats (POST /v1/comandes → 202, idempotència, errors problem+json), d'integració amb PostgreSQL i RabbitMQ reals mitjançant Testcontainers (el relay de l'outbox publica un cop i marca publicat_en; el consumidor de la saga és idempotent), de contracte amb Pact (Comandes declara el que necessita de GET /v1/productes?ids=, Catàleg ho verifica al seu CI, amb el Pact Broker i can-i-deploy com a enllaç per a 05-03), d'esdeveniments (JSON Schema del payload i tolerància a camps nous) i una única E2E de cablejat. I hem fixat les regles: fakes i injecció abans que mocks de llibreries, fixtures deterministes (p-501, p-777, c-1024) compartits per tots els nivells, i una taula d'estratègia per servei.

Amb això acaba el mòdul d'implementació: hem triat les eines, construït servei-cataleg i servei-comandes amb la mateixa plantilla, disciplinat la configuració, unit els serveis per HTTP i per esdeveniments seguint els contractes dels mòduls 2 i 3, i protegit tot plegat amb proves a diferents nivells. El que tenim són processos Node.js que s'executen amb npm run dev en un portàtil, amb les dependències en contenidors solts. El mòdul 5 els porta a producció: empaquetar cada servei en una imatge Docker i aixecar el sistema complet amb Docker Compose (05-01), desplegar-lo i escalar-lo a Kubernetes amb els ConfigMaps i Secrets que 04-03 va deixar preparats (05-02), automatitzar proves, pactes i desplegament en un pipeline de CI/CD (05-03), desplegar sense talls amb estratègies rolling, blue-green i canary (05-04) i, finalment, delegar part de la comunicació entre serveis en un service mesh (05-05). Comença pels contenidors.

Curs de Microserveis

Mòdul 1: Introducció als Microserveis

Mòdul 2: Disseny de Microserveis

Mòdul 3: Comunicació entre Microserveis

Mòdul 4: Implementació de Microserveis

Mòdul 5: Desplegament i Orquestració

Mòdul 6: Monitoratge i Manteniment

Mòdul 7: Seguretat en Microserveis

Mòdul 8: Casos d'Estudi i Exemples Pràctics

© Copyright 2026. Tots els drets reservats