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
- La piràmide de proves adaptada a microserveis
- Eines i organització de la suite
- Proves unitàries del domini
- Proves de component amb Supertest i dobles
- Proves d'integració amb Testcontainers: outbox i consumidor de la saga
- Proves de contracte dirigides pel consumidor amb Pact
- Proves d'esdeveniments: el contracte del sobre i del payload
- Proves de cap a cap: poques i amb propòsit
- Dobles de prova, dades deterministes i l'estratègia de TechCorp
- 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.
- 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 ajvOrganització 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).
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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, llegeixenprocess.env, es trepitgen en paral·lel. SemprecrearAppamb 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.90sense matcher). Es trenca amb cada canvi de preu a les dades del proveïdor. Tipus i forma ambMatchersV3. - Pactes que demanen més del que es fa servir. Si el pacte exigeix
monedai Comandes no el fa servir, es lliga a Catàleg sense motiu. Només el que eltraductorProductellegeix. - Dades aleatòries a les proves (
fakerper 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
TRUNCATEabeforeEach. - Ignorar la prova del duplicat. Publicar el mateix
esdevenimentIddues 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
- Conceptes Bàsics de Microserveis
- Avantatges i Desavantatges dels Microserveis
- Comparació amb l'Arquitectura Monolítica
- Quan Adoptar Microserveis: Criteris de Decisió
- El Cas Pràctic del Curs: la Botiga Online de TechCorp
Mòdul 2: Disseny de Microserveis
- Principis de Disseny de Microserveis
- Descomposició d'Aplicacions Monolítiques
- Definició de Bounded Contexts
- Gestió de Dades: una Base de Dades per Servei
- Consistència Distribuïda: Sagues, CQRS i Event Sourcing
Mòdul 3: Comunicació entre Microserveis
- APIs RESTful
- Missatgeria Asíncrona
- Protocols de Comunicació: gRPC, GraphQL
- API Gateway i Backend for Frontend
- Descobriment de Serveis i Balanceig de Càrrega
- Contractes i Versionat d'APIs
Mòdul 4: Implementació de Microserveis
- Elecció de Tecnologies i Eines
- Desenvolupament d'un Microservei Simple
- Gestió de Configuració
- Integració Pràctica: Consumir APIs i Publicar Esdeveniments
- Proves en Microserveis: Unitàries, d'Integració i de Contracte
Mòdul 5: Desplegament i Orquestració
- Contenidors i Docker
- Orquestració amb Kubernetes
- CI/CD per a Microserveis
- Estratègies de Desplegament: Rolling, Blue-Green i Canary
- Service Mesh: Istio i Linkerd
Mòdul 6: Monitoratge i Manteniment
- Monitoratge i Logging
- Traçabilitat Distribuïda amb OpenTelemetry
- Gestió d'Errors i Recuperació
- Escalabilitat i Rendiment
- SLOs, Alertes i Gestió d'Incidents
Mòdul 7: Seguretat en Microserveis
- Autenticació i Autorització
- Seguretat en la Comunicació
- Pràctiques de Seguretat
- Seguretat en Contenidors i Kubernetes
