A 05-02 vam acabar openapi.yaml: un contracte complet, validat, passat pel linter i publicat a /docs. És un document excel·lent. I no hi ha absolutament res que garanteixi que el servidor el compleixi.
Aquesta frase mereix aturar-s'hi. Ara mateix algú podria afegir un camp obligatori a POST /comandes, reanomenar preuEuros a preu, o fer que un 404 retorni un cos diferent, i tot passaria: les proves de 03-08 continuarien en verd perquè comproven el que el codi fa, no el que el contracte promet; Spectral continuaria en verd perquè el YAML és sintàcticament correcte; i la SPA, l'Aroma Mòbil i RàpidEnviaments se n'assabentarien en producció.
Aquest és el problema d'aquesta lliçó: tancar el cercle entre el contracte i la realitat, en totes dues direccions. Que les respostes reals compleixin l'esquema. Que un canvi trencador al contracte es detecti abans de fusionar-se. I, de passada, que la SPA es pugui desenvolupar contra un mock del contracte sense esperar que l'endpoint existeixi.
Afegirem al projecte un mock amb Prism, validació d'esquemes dins de les proves Supertest que ja tenim, una porta de canvis trencadors amb oasdiff, i un recorregut de compra d'extrem a extrem. I veurem quan Pact resol un problema real i quan és sobreenginyeria cara.
Contingut
- Cinc consumidors i una API que canvia
- El contracte com a artefacte executable
- Servidors mock: Prism sobre
openapi.yaml - Mocks estàtics davant de dinàmics, i els seus límits
- Dobles al consumidor:
mswa la SPA - Dobles al proveïdor:
nockper a RàpidEnviaments - Proves de contracte del proveïdor: validar les respostes
- Validar també les peticions
- Detectar canvis trencadors amb
oasdiff oasdiffcom a porta a la integració contínua- Contract testing dirigit pel consumidor: Pact
- Quan Pact compensa i quan és sobreenginyeria
- La taula de tipus de prova
- Proves d'extrem a extrem: el recorregut de compra
- Entorn efímer, dades sembrades i aïllament
- La col·lecció de Postman a CI amb Newman
- Càrrega i seguretat: on encaixen
- Què s'executa en cada moment
- Cinc consumidors i una API que canvia
L'inventari de qui depèn de l'API de la Botiga Aroma, i què passa si alguna cosa es trenca:
| Consumidor | Qui el desenvolupa | Com es desplega | Si trenques el contracte |
|---|---|---|---|
SPA botigaaroma.example |
Equip de front | Continu; es recarrega sol | S'arregla en hores |
| Aroma Mòbil | Equip mòbil | Botigues d'aplicacions, amb revisió | Dies o setmanes, i hi ha usuaris amb versions velles per sempre |
Panell panel.botigaaroma.example |
Equip intern | Continu | Hores, però bloqueja operacions |
| RàpidEnviaments | Empresa externa | El seu propi cicle | Reunió, correus, incidència comercial |
| CataBox | Tercer desconegut | No el controles | Te n'assabentes per una piulada |
La fila de l'Aroma Mòbil és la que fa inevitable tot el d'aquesta lliçó: no pots desplegar als clients mòbils. Una versió de l'app de fa vuit mesos continua cridant la teva API, i ho continuarà fent. Qualsevol canvi trencador és permanent per a algú.
I una precisió sobre «trencar el contracte»: no cal mala fe ni descuit. Les trencades típiques són involuntàries i subtils:
- Un
refactordels mapejadors fa quenotesTastdeixi d'aparèixer quan és buit, en lloc de retornar[]. - Una optimització canvia l'ordre dels resultats i un consumidor en depenia.
- Algú afegeix
.strict()a un esquema d'entrada i una app antiga que enviava un camp extra comença a rebre400. - Un
enumguanya un valor nou (torrefaccio: "molt_fosc") i el client TypeScript generat de 05-02 no el contempla.
Cap d'aquestes no es detecta llegint el diff. Es detecten amb eines.
- El contracte com a artefacte executable
La idea que organitza la lliçó: openapi.yaml no és documentació, és codi. Tot el que se'n pot derivar:
graph LR
O[openapi.yaml] --> M[Mock amb Prism<br/>la SPA avança sense backend]
O --> V[Validació de respostes<br/>a les proves Supertest]
O --> D[oasdiff<br/>porta de canvis trencadors]
O --> C[Clients generats<br/>SPA i Aroma Mòbil - 05-02]
O --> P[Col·lecció de Postman<br/>importada - 05-01]
O --> G[Configuració del gateway<br/>rutes i esquemes - 05-06]
O --> R[Portal de desenvolupador<br/>documentació pública - 05-06]
Set usos d'un mateix fitxer. Cadascun d'ells fa més car mantenir-lo malament i més rendible mantenir-lo bé, que és exactament l'incentiu que es busca.
- Servidors mock: Prism sobre
openapi.yaml
openapi.yamlSituació concreta: l'equip de la SPA ha de construir la pantalla «les meves comandes amb detall d'enviament», que necessita un GET /v1/comandes/{id}/enviament que encara no existeix. Sense mock, esperen dues setmanes o s'inventen dades que després no coincideixen.
Prism (de Stoplight) aixeca un servidor HTTP que implementa la teva especificació:
npm install --save-dev @stoplight/prism-cli
# Mock al port 4010, amb validació de peticions
npx prism mock openapi.yaml --port 4010 --errors# La SPA apunta a http://localhost:4010 i crida exactament igual
curl -s http://localhost:4010/cafes?torrefaccio=clar | jq{
"dades": [
{
"id": "caf_001",
"nom": "Etiòpia Yirgacheffe",
"origen": "Etiòpia",
"torrefaccio": "clar",
"preuEuros": 14.5,
"estoc": 120,
"notesTast": ["cítric", "floral", "te negre"],
"dataCreacio": "2026-01-15T08:30:00Z",
"versio": 3
}
],
"total": 137
}Aquell cos surt de l'exemple primeraPagina que vam escriure a 05-02. Aquí es veu per què vam insistir que els exemples fossin coherents: són el que consumeix l'equip de front durant dues setmanes, i uns exemples amb dades absurdes produeixen una interfície dissenyada per a dades absurdes.
Opcions importants de Prism:
| Opció | Efecte |
|---|---|
--errors |
Retorna 422 si la petició no compleix l'especificació: paràmetre invàlid, cos mal format, falta una capçalera obligatòria |
--dynamic |
Genera dades aleatòries que compleixen l'esquema, en lloc de repetir l'exemple |
-h 0.0.0.0 |
Escolta a totes les interfícies: necessari a Docker o per a l'equip mòbil |
Prefer: example=nom |
Capçalera que demana un exemple concret dels definits |
Prefer: code=404 |
Capçalera que demana una resposta concreta: així es proven els errors |
Aquesta última és la que converteix el mock en una cosa seriosa:
# Forçar el 409 d'estoc insuficient per maquetar el missatge d'error
curl -i -X POST http://localhost:4010/comandes \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
-H "Prefer: code=409, example=estocInsuficient" \
-d '{"clientId":"cli_842","linies":[{"cafeId":"caf_001","quantitat":200}]}'Sense això, el front maqueta el camí feliç i descobreix en producció que no havia pensat la pantalla d'estoc insuficient. Amb això, cada estat d'error es pot provar al navegador el primer dia.
I --errors dona un regal inesperat: valida el consumidor. Si la SPA oblida la Idempotency-Key, el mock respon 422 en desenvolupament, no en producció.
Afegim l'script al projecte:
{
"scripts": {
"mock": "prism mock openapi.yaml --port 4010 --errors",
"mock:dinamic": "prism mock openapi.yaml --port 4010 --errors --dynamic"
}
}Prism té un segon mode que mereix menció, prism proxy, que reenvia les peticions a l'API real i valida totes dues direccions contra l'especificació, avisant de cada desviació. És una manera barata d'auditar un entorn de preproducció sencer.
- Mocks estàtics davant de dinàmics, i els seus límits
| Estàtic (exemples) | Dinàmic (--dynamic) |
|
|---|---|---|
| D'on surten les dades | Els examples del contracte |
Generades a l'atzar complint l'esquema |
| Realisme | Alt: els va escriure una persona | Baix: "nom": "string", dates absurdes |
| Estabilitat | Total: la mateixa resposta sempre | Canvia a cada crida |
| Bo per a | Maquetar, captures, demos, proves del front | Descobrir supòsits ocults del client |
| Dolent per a | Detectar que el front assumeix un ordre fix | Qualsevol prova que compari valors |
Els dinàmics tenen un ús molt concret i valuós: trencar supòsits. Si la SPA falla amb --dynamic, és que assumia una cosa que el contracte no garanteix —que notesTast mai no és buit, que el total és menor que 1000, que els noms són curts—. Aquella fallada en desenvolupament és una fallada evitada en producció.
Els límits de qualsevol mock, i cal tenir-los molt presents:
- No hi ha lògica de negoci. El mock accepta una comanda de 500 unitats d'un cafè amb 120 d'estoc. Mai no retornarà
estoc_insuficienttret que l'hi demanis ambPrefer. - No hi ha estat. Crees una comanda amb
POSTiGET /comandescontinua retornant l'exemple de sempre. Els recorreguts complets no es poden provar així. - No hi ha autenticació real. El mock no valida tokens ni permisos.
- Un mock que passa no demostra res sobre l'API real. És el parany més perillós: el front té tot en verd contra el mock i falla contra el servidor de debò.
D'aquí la regla: el mock desbloqueja el desenvolupament en paral·lel; no substitueix cap prova d'integració. I el moment de connectar la SPA contra l'API real ha de ser tan aviat com sigui possible.
- Dobles al consumidor:
msw a la SPA
msw a la SPAPrism és un procés a part, útil mentre es desenvolupa. Per a les proves automàtiques del front cal alguna cosa que s'executi dins del procés de proves: Mock Service Worker (msw), que intercepta les peticions a nivell de xarxa sense que el codi de l'aplicació se n'assabenti.
// aroma-spa/proves/servidor-simulat.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const URL_BASE = 'https://api.botigaaroma.example/v1';
export const manejadors = [
// Catàleg amb dos cafès, filtrable de debò: el mock SÍ que implementa el filtre,
// perquè la prova vol comprovar que la SPA l'envia bé.
http.get(`${URL_BASE}/cafes`, ({ request }) => {
const url = new URL(request.url);
const torrefaccio = url.searchParams.get('torrefaccio');
const cataleg = [
{ id: 'caf_001', nom: 'Etiòpia Yirgacheffe', torrefaccio: 'clar', preuEuros: 14.5, estoc: 120 },
{ id: 'caf_002', nom: 'Colòmbia Huila', torrefaccio: 'mitja', preuEuros: 12.9, estoc: 80 },
];
const dades = torrefaccio ? cataleg.filter((c) => c.torrefaccio === torrefaccio) : cataleg;
return HttpResponse.json({ dades, total: dades.length });
}),
// Error de negoci: la SPA ha de mostrar un missatge concret, no un de genèric
http.post(`${URL_BASE}/comandes`, async ({ request }) => {
if (!request.headers.get('Idempotency-Key')) {
return HttpResponse.json({
error: { codi: 'clau_idempotencia_requerida', missatge: '...', detalls: [] },
}, { status: 428 });
}
const cos = await request.json();
if (cos.linies.some((l) => l.quantitat > 100)) {
return HttpResponse.json({
error: {
codi: 'estoc_insuficient',
missatge: 'No hi ha estoc suficient d\'"Etiòpia Yirgacheffe".',
detalls: [{ camp: 'linies[0].quantitat', sollicitat: 200, disponible: 120 }],
},
}, { status: 409 });
}
return HttpResponse.json(
{ id: 'com_5001', estat: 'pendent_pagament', totalEuros: 29.0 },
{ status: 201, headers: { Location: '/v1/comandes/com_5001' } },
);
}),
];
export const servidorSimulat = setupServer(...manejadors);// aroma-spa/proves/cataleg.prova.js
import { servidorSimulat } from './servidor-simulat.js';
import { http, HttpResponse } from 'msw';
before(() => servidorSimulat.listen({ onUnhandledRequest: 'error' }));
afterEach(() => servidorSimulat.resetHandlers());
after(() => servidorSimulat.close());
test('mostra l\'avís de límit quan l\'API respon 429', async () => {
// Sobreescriu el manejador NOMÉS per a aquesta prova
servidorSimulat.use(
http.get('*/cafes', () => HttpResponse.json(
{ error: { codi: 'limit_peticions', missatge: '...', detalls: [] } },
{ status: 429, headers: { 'Retry-After': '30' } },
)),
);
const pantalla = renderitzar(<Cataleg />);
await pantalla.trobarPerText(/massa peticions/i);
// I comprovem que respecta el Retry-After en lloc de reintentar en bucle (04-04)
});onUnhandledRequest: 'error' és l'opció clau: qualsevol petició que la SPA faci i que no estigui declarada fa fallar la prova. Així es descobreixen crides inesperades —analítica, un endpoint oblidat— en lloc que s'escapin silenciosament.
El risc estructural de msw, i cal dir-ho clar: aquests manejadors són una tercera descripció de l'API, escrita per l'equip de front, que es pot desviar de la real. Si el backend canvia preuEuros per preu, les proves del front continuen en verd. Dues mitigacions: generar els manejadors des d'openapi.yaml amb eines com msw-auto-mock, o —el que resol el problema d'arrel— el contract testing de l'apartat 11.
- Dobles al proveïdor:
nock per a RàpidEnviaments
nock per a RàpidEnviamentsEl problema simètric: la nostra API també és consumidora. Crida RàpidEnviaments per crear un enviament i li envia webhooks signats. Les proves de 03-08 no poden cridar de debò un servei extern: seria lent, fràgil i crearia enviaments reals.
nock intercepta les peticions HTTP sortints de Node:
// proves/integracio/enviaments.prova.js
import './../ajudes/entorn-prova.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import nock from 'nock';
import request from 'supertest';
import { migrar, sembrar } from '../ajudes/base-dades-prova.js';
import { tokenDe } from '../ajudes/token.js';
const API_RAPIDENVIAMENTS = 'https://api.rapidenviaments.example';
test.before(() => {
// Cap petició real no surt de les proves. Si algun codi intenta cridar
// un host no interceptat, nock llança i la prova falla sorollosament.
nock.disableNetConnect();
nock.enableNetConnect('127.0.0.1'); // tret de Supertest, que es crida a si mateix
});
test.after(() => {
nock.cleanAll();
nock.enableNetConnect();
});
test('en pagar una comanda es demana l\'enviament a RàpidEnviaments', async (t) => {
const { app } = await import('../../src/app.js');
migrar(); sembrar();
// Declarem què esperem que la nostra API enviï, i què respondrà el simulat
const abast = nock(API_RAPIDENVIAMENTS)
.post('/v2/enviaments', (cos) => {
// L'asserció sobre la petició SORTINT és el valuós d'aquesta prova:
assert.equal(cos.referencia, 'com_5001');
assert.equal(cos.pesGrams, 500);
assert.ok(cos.desti.codiPostal, 'cal enviar el codi postal');
return true;
})
.matchHeader('authorization', /^Bearer /)
.matchHeader('idempotency-key', /^[0-9a-f-]{36}$/) // també reintentem amb seguretat
.reply(201, { enviamentId: 'env_9001', seguiment: 'RE-4471-XA' });
const resposta = await request(app)
.post('/v1/comandes/com_5001/pagament')
.set('Authorization', `Bearer ${tokenDe('cli_842', 'client')}`)
.set('Idempotency-Key', crypto.randomUUID())
.send({ metode: 'targeta', tokenTargeta: 'tok_prova_fictici' })
.expect(200);
assert.equal(resposta.body.estat, 'pagat');
assert.equal(resposta.body.seguiment, 'RE-4471-XA');
assert.ok(abast.isDone(), 'no s\'ha cridat RàpidEnviaments');
});
test('si RàpidEnviaments falla, el pagament es completa igualment i l\'enviament queda pendent', async () => {
const { app } = await import('../../src/app.js');
migrar(); sembrar();
nock(API_RAPIDENVIAMENTS).post('/v2/enviaments').reply(503, { missatge: 'manteniment' });
const resposta = await request(app)
.post('/v1/comandes/com_5001/pagament')
.set('Authorization', `Bearer ${tokenDe('cli_842', 'client')}`)
.set('Idempotency-Key', crypto.randomUUID())
.send({ metode: 'targeta', tokenTargeta: 'tok_prova_fictici' })
.expect(200);
// Regla de negoci: el cobrament no es reverteix perquè el transportista estigui caigut.
// L'enviament s'encua i es reintenta. Aquesta prova documenta aquella decisió.
assert.equal(resposta.body.estat, 'pagat');
assert.equal(resposta.body.enviament.estat, 'pendent_sollicitud');
});nock.disableNetConnect() és una pràctica que mereix adoptar-se sempre: garanteix que cap prova no crida internet. Un conjunt de proves que depèn de la xarxa és un conjunt que falla els divendres a la tarda per motius aliens.
La segona prova il·lustra una cosa que només es pot provar amb dobles: el comportament davant de la fallada d'una dependència. Provocar un 503 real de RàpidEnviaments és impossible; simular-lo, trivial.
- Proves de contracte del proveïdor: validar les respostes
Arribem al nucli. La pregunta és: les respostes reals de la nostra API compleixen openapi.yaml?
La tècnica: extreure els esquemes del contracte, compilar-los amb AJV i validar-hi el cos de les respostes dins de les proves Supertest que ja existeixen.
Fitxer nou proves/ajudes/contracte.js:
// proves/ajudes/contracte.js
// Valida cossos de resposta contra els esquemes d'openapi.yaml.
// És la peça que impedeix que el contracte i la implementació se separin.
import { readFileSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js'; // OpenAPI 3.1 fa servir JSON Schema 2020-12
import addFormats from 'ajv-formats';
import YAML from 'yaml';
import assert from 'node:assert/strict';
const especificacio = YAML.parse(readFileSync('openapi.yaml', 'utf8'));
const ajv = new Ajv2020({
strict: false, // OpenAPI afegeix paraules que AJV no coneix (example, xml...)
allErrors: true, // volem TOTS els errors, no només el primer
validateFormats: true,
});
addFormats(ajv); // habilita date-time, uuid, email, uri-reference...
// Registrem tots els esquemes de components perquè els $ref interns resolguin.
for (const [nom, esquema] of Object.entries(especificacio.components.schemas)) {
ajv.addSchema(esquema, `#/components/schemas/${nom}`);
}
/**
* Comprova que un cos compleix un esquema de components.schemas.
* @param {string} nomEsquema p. ex. 'ColleccioCafes'
* @param {unknown} cos el cos de la resposta
*/
export function compleixEsquema(nomEsquema, cos) {
const esquema = especificacio.components.schemas[nomEsquema];
assert.ok(esquema, `L'esquema "${nomEsquema}" no existeix a openapi.yaml`);
const validar = ajv.compile(esquema);
const valid = validar(cos);
if (!valid) {
const problemes = validar.errors
.map((e) => ` · ${e.instancePath || '(arrel)'} ${e.message}`)
.join('\n');
assert.fail(
`La resposta no compleix l'esquema "${nomEsquema}":\n${problemes}\n` +
`Cos rebut:\n${JSON.stringify(cos, null, 2)}`,
);
}
}
/**
* Localitza a openapi.yaml l'esquema declarat per a una operació i un codi,
* i hi valida. Evita haver d'anomenar l'esquema a mà a cada prova.
*/
export function compleixContracte(ruta, metode, codi, cos) {
const operacio = especificacio.paths?.[ruta]?.[metode.toLowerCase()];
assert.ok(operacio, `openapi.yaml no descriu ${metode.toUpperCase()} ${ruta}`);
const resposta = operacio.responses?.[String(codi)]
?? operacio.responses?.[`${String(codi)[0]}XX`];
assert.ok(resposta, `openapi.yaml no documenta el ${codi} de ${metode} ${ruta}`);
// Resolem la $ref de components.responses si n'hi ha
const resolta = resposta.$ref
? especificacio.components.responses[resposta.$ref.split('/').pop()]
: resposta;
const esquema = resolta.content?.['application/json']?.schema;
if (!esquema) return; // respostes sense cos, com el 204 o el 304
const nom = esquema.$ref?.split('/').pop();
if (nom) return compleixEsquema(nom, cos);
const validar = ajv.compile(esquema);
assert.ok(validar(cos), JSON.stringify(validar.errors, null, 2));
}I el seu ús a les proves d'integració, que amb prou feines canvien:
// proves/integracio/cafes.prova.js — ampliació de les proves de 03-08
import './../ajudes/entorn-prova.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { migrar, sembrar } from '../ajudes/base-dades-prova.js';
import { tokenDe } from '../ajudes/token.js';
import { compleixContracte, compleixEsquema } from '../ajudes/contracte.js';
test('GET /v1/cafes compleix el contracte publicat', async () => {
const { app } = await import('../../src/app.js');
migrar(); sembrar();
const resposta = await request(app)
.get('/v1/cafes?torrefaccio=clar&limit=10')
.set('Authorization', `Bearer ${tokenDe('cli_842', 'client')}`)
.expect(200);
// Assercions de comportament (les de 03-08, continuen sent necessàries)
assert.ok(resposta.body.dades.every((c) => c.torrefaccio === 'clar'));
// Asserció de CONTRACTE: la forma exacta, contra openapi.yaml
compleixContracte('/cafes', 'get', 200, resposta.body);
});
test('els errors 404 compleixen l\'esquema Error del catàleg', async () => {
const { app } = await import('../../src/app.js');
migrar(); sembrar();
const resposta = await request(app)
.get('/v1/cafes/caf_inexistent')
.set('Authorization', `Bearer ${tokenDe('cli_842', 'client')}`)
.expect(404);
compleixEsquema('Error', resposta.body);
// L'enum de l'esquema Error garanteix que el codi és al catàleg:
// si algú inventa 'cafe_no_existeix', aquesta línia falla.
assert.equal(resposta.body.error.codi, 'cafe_no_trobat');
});
test('POST /v1/comandes retorna una Comanda conforme al contracte', async () => {
const { app } = await import('../../src/app.js');
migrar(); sembrar();
const resposta = await request(app)
.post('/v1/comandes')
.set('Authorization', `Bearer ${tokenDe('cli_842', 'client')}`)
.set('Idempotency-Key', crypto.randomUUID())
.send({ clientId: 'cli_842', linies: [{ cafeId: 'caf_001', quantitat: 2 }] })
.expect(201);
compleixContracte('/comandes', 'post', 201, resposta.body);
assert.match(resposta.headers.location, /^\/v1\/comandes\/com_/);
});Què detecta això que les proves de 03-08 no detectaven:
| Canvi | Ho veia 03-08? | Ho veu la prova de contracte? |
|---|---|---|
Reanomenar preuEuros a preu |
Sí, si hi havia una asserció sobre aquell camp | Sí, sempre: és required |
Deixar de retornar versio |
No, tret d'asserció explícita | Sí: és required a l'esquema |
Retornar preuEuros: 1450 (cèntims) |
Només si hi havia asserció del valor | Sí, si l'esquema té multipleOf: 0.01… i sobretot ho veuria el pattern/rang |
| Un codi d'error nou fora del catàleg | No | Sí: l'enum de l'esquema Error el rebutja |
dataCreacio sense la Z final |
No | Sí: format: date-time |
| Afegir un camp nou | No | No, i és correcte: és un canvi compatible (02-07) |
L'última fila és tan important com les altres. Recorda que a 05-02 vam deixar deliberadament els esquemes de sortida sense additionalProperties: false. Si els tanquéssim, cada camp nou trencaria aquestes proves i l'equip acabaria desactivant-les.
Un advertiment sobre la cobertura: aquestes proves només validen els endpoints i codis que hagis provat. Si mai no escrius una prova que provoqui el 429, ningú no comprova que aquell cos compleixi el contracte. Una manera barata d'apujar la cobertura és un embolcall que validi totes les respostes automàticament:
// proves/ajudes/peticio.js
// Embolcall de Supertest que valida el contracte a CADA resposta, sense recordar-ho.
import request from 'supertest';
import { compleixContracte } from './contracte.js';
export function peticio(app, plantillaRuta) {
const agent = request(app);
const original = agent.get.bind(agent);
return {
get(url) {
return original(url).expect((res) => {
compleixContracte(plantillaRuta, 'get', res.status, res.body);
});
},
// ... post, patch, delete equivalents
};
}
- Validar també les peticions
El contracte té dos costats. També convé comprovar que els cossos que documentem com a vàlids ho són de debò per al servidor, i que els invàlids es rebutgen:
// proves/integracio/contracte-entrades.prova.js
import { readFileSync } from 'node:fs';
import YAML from 'yaml';
import test from 'node:test';
import request from 'supertest';
import { tokenDe } from '../ajudes/token.js';
const especificacio = YAML.parse(readFileSync('openapi.yaml', 'utf8'));
test('tots els exemples de requestBody del contracte són acceptats per l\'API', async () => {
const { app } = await import('../../src/app.js');
migrar(); sembrar();
const exemples = especificacio.paths['/comandes'].post
.requestBody.content['application/json'].examples;
for (const [nom, exemple] of Object.entries(exemples)) {
const resposta = await request(app)
.post('/v1/comandes')
.set('Authorization', `Bearer ${tokenDe('cli_842', 'client')}`)
.set('Idempotency-Key', crypto.randomUUID())
.send(exemple.value);
// Un exemple del contracte que produeix 400 és un error DEL CONTRACTE:
// estàs publicant a la documentació un cos que la teva API rebutja.
assert.notEqual(resposta.status, 400,
`L'exemple "${nom}" del contracte és rebutjat per l'API: ` +
JSON.stringify(resposta.body));
}
});Aquesta prova sembla menor i atrapa una fallada molt comuna i molt danyina: l'exemple de la documentació que no funciona. És el primer que copia i enganxa qui s'integra amb tu, i si falla, la teva API perd credibilitat els primers cinc minuts.
- Detectar canvis trencadors amb
oasdiff
oasdiffLes proves anteriors comproven que el servidor compleix el contracte actual. Falta l'altra pregunta: el contracte nou trenca algú respecte de l'anterior?
oasdiff compara dues versions d'una especificació i classifica les diferències:
# Instal·lació (Go, o binari, o imatge Docker)
go install github.com/tufin/oasdiff@latest
# Resum de diferències entre la versió publicada i la d'aquesta branca
oasdiff diff openapi-produccio.yaml openapi.yaml --format text
# Només els canvis TRENCADORS: això és el que interessa a CI
oasdiff breaking openapi-produccio.yaml openapi.yamlSortida típica quan algú fica la pota:
2 breaking changes: 2 error, 0 warning
error, in components/schemas/Cafe property/notesTast request property became required
in API GET /cafes
error, in API POST /comandes request property 'linies/items/quantitat' max was decreased
from 99 to 20La classificació que fa oasdiff coincideix, no per casualitat, amb les regles de 02-07:
| Canvi | És trencador? | Motiu |
|---|---|---|
| Afegir un endpoint | No | Ningú no el cridava |
| Afegir un camp opcional a una petició | No | Els clients antics no l'envien |
| Afegir un camp a una resposta | No | Els clients han d'ignorar el que no coneixen |
| Fer obligatori un camp de petició | Sí | Els clients antics no l'envien → 400 |
| Eliminar un camp d'una resposta | Sí | Algú l'estava llegint |
Eliminar un valor d'un enum de resposta |
Sí | Un client el podia tenir mapejat |
Afegir un valor a un enum de resposta |
Trencador «suau» | El client generat pot no contemplar-lo |
Afegir un valor a un enum de petició |
No | És ampliar el que s'accepta |
Restringir un rang (maximum menor) |
Sí | Peticions abans vàlides ara fallen |
Ampliar un rang (maximum major) |
No | És acceptar més |
| Canviar el tipus d'un camp | Sí | Trencada clàssica |
| Eliminar un endpoint | Sí | Obvi |
Marcar com a deprecated |
No | Només avisa; la retirada és el canvi |
| Afegir un codi d'error nou | Depèn | Si el client fa switch exhaustiu, l'afecta |
Aquell quadre és la traducció operativa de la compatibilitat cap enrere. I l'important és que ja no depèn que el revisor del pull request ho recordi: ho comprova una eina.
Fixa't en el matís de les dues files de l'enum: la direcció importa. Ampliar el que acceptes és segur; ampliar el que retornes pot trencar un client que fes switch sobre els valors coneguts. És exactament el cas del torrefaccio: "molt_fosc" de l'apartat 1, i per això el contracte de 05-02 avisa a la descripció del camp que s'hi poden afegir valors nous.
oasdiff com a porta a la integració contínua
oasdiff com a porta a la integració contínuaPer comparar cal tenir una referència. Dues estratègies:
- Contra la branca principal, amb
git show main:openapi.yaml. Simple i suficient per a la majoria d'equips. - Contra l'especificació publicada en producció, descarregada de
https://api.botigaaroma.example/docs/openapi.json. Més correcte —el que importa és el que hi ha desplegat, no el que hi ha amain— i una mica més fràgil.
Un script que serveix en local i a CI:
#!/usr/bin/env bash
# eines/comprovar-contracte.sh
# Falla si aquesta branca introdueix canvis trencadors respecte de la branca principal.
set -euo pipefail
BASE="${1:-main}"
TEMPORAL="$(mktemp -d)"
git show "${BASE}:openapi.yaml" > "${TEMPORAL}/base.yaml" 2>/dev/null || {
echo "No hi ha openapi.yaml a ${BASE}: primera versió, res a comparar."
exit 0
}
echo "== Diferències respecte de ${BASE} =="
oasdiff diff "${TEMPORAL}/base.yaml" openapi.yaml --format text || true
echo "== Comprovació de canvis trencadors =="
if oasdiff breaking "${TEMPORAL}/base.yaml" openapi.yaml --fail-on ERR; then
echo "Sense canvis trencadors."
else
cat <<'AVIS'
CANVI TRENCADOR DETECTAT.
Segons la política de versionat (02-07), un canvi trencador exigeix una d'aquestes tres vies:
1. Reformular el canvi de manera compatible (camp opcional, valor per defecte,
camp nou en lloc de reanomenar l'existent).
2. Iniciar el cicle de deprecació: mantenir l'antic, marcar-lo `deprecated`,
emetre `Deprecation` i `Sunset`, i avisar els consumidors.
3. Obrir /v2, amb almenys 6 mesos de convivència.
Si el canvi és intencionat i està acordat, afegeix l'etiqueta "canvi-trencador"
al pull request i documenta la decisió en un ADR de docs/decisions/.
AVIS
exit 1
fiAmb oasdiff a la canalització, la conversa canvia de naturalesa: en lloc de discutir a la revisió si un canvi trenca alguna cosa, l'eina ho diu i la discussió passa a ser què fer-hi. Aquesta és la porta que 05-05 integrarà a ci.yml.
- Contract testing dirigit pel consumidor: Pact
Fins aquí, el contracte el defineix el proveïdor (nosaltres) i els consumidors s'hi adapten. És el model correcte per a una API pública. Però hi ha un altre model que resol un problema diferent.
Imagina't que la Botiga Aroma creix i apareixen inventari-service, pagaments-service i recomanacions-service, que es criden entre si. Preguntes incòmodes: quins camps de la resposta d'inventari-service fa servir realment cada consumidor? En puc eliminar un? La resposta honesta sol ser «no ho sé, per si de cas no toco res», i així els serveis es fossilitzen.
Pact inverteix la direcció: cada consumidor declara què necessita, i el proveïdor verifica que ho compleix.
sequenceDiagram
participant C as SPA consumidora
participant B as Pact Broker
participant P as API Botiga Aroma
C->>C: Prova del consumidor contra un mock local
Note over C: Es genera el PACTE<br/>crido GET /v1/cafes amb torrefaccio clar<br/>i necessito els camps id i preuEuros
C->>B: Publica el pacte amb la seva versió i branca
P->>B: Descarrega tots els pactes dels seus consumidors
P->>P: Reprodueix cada interacció contra l'API real
P->>B: Publica el resultat de la verificació
B-->>C: can-i-deploy: puc desplegar?
B-->>P: can-i-deploy: puc desplegar?
El costat del consumidor:
// aroma-spa/proves/contracte/cafes.pacte.js
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
const { like, eachLike, string, integer, decimal } = MatchersV3;
const proveidor = new PactV3({
consumer: 'aroma-spa',
provider: 'botiga-aroma-api',
});
test('la SPA obté el catàleg filtrat per torrefacció', async () => {
await proveidor
.given('existeixen cafès de torrefacció clara') // ESTAT que el proveïdor ha de muntar
.uponReceiving('una petició del catàleg de torrefacció clara')
.withRequest({
method: 'GET',
path: '/v1/cafes',
query: { torrefaccio: 'clar', limit: '20' },
headers: { Authorization: like('Bearer token-fictici') },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: {
// Els matchers descriuen la FORMA, no els valors concrets.
// La SPA declara així exactament quins camps consumeix.
dades: eachLike({
id: string('caf_001'),
nom: string('Etiòpia Yirgacheffe'),
torrefaccio: string('clar'),
preuEuros: decimal(14.5),
estoc: integer(120),
}),
total: integer(137),
},
})
.executeTest(async (mockServer) => {
const api = new ClientCafes(mockServer.url);
const resultat = await api.llistar({ torrefaccio: 'clar', limit: 20 });
assert.equal(resultat.dades[0].nom, 'Etiòpia Yirgacheffe');
});
});El costat del proveïdor:
// proves/contracte/verificar-pactes.js
import { Verifier } from '@pact-foundation/pact';
import { servidor } from '../../src/servidor.js';
import { migrar, sembrar, sembrarNomesClars } from '../ajudes/base-dades-prova.js';
await new Verifier({
provider: 'botiga-aroma-api',
providerBaseUrl: 'http://localhost:3001',
pactBrokerUrl: process.env.PACT_BROKER_URL,
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
providerVersion: process.env.GIT_COMMIT,
publishVerificationResult: true,
// Els "estats" del consumidor es tradueixen aquí en dades reals.
// Aquesta és la part que costa mantenir i on Pact es complica.
stateHandlers: {
'existeixen cafès de torrefacció clara': async () => {
migrar(); sembrarNomesClars();
},
'la comanda com_5001 està pendent de pagament': async () => {
migrar(); sembrar();
},
},
// Els tokens dels pactes són ficticis: aquí se substitueixen per vàlids.
requestFilter: (req, res, next) => {
req.headers.authorization = `Bearer ${tokenDe('cli_842', 'client')}`;
next();
},
}).verifyProvider();El que Pact dona i OpenAPI no pot donar:
- Saber quins camps es fan servir de debò. Si cap pacte no esmenta
notesTast, el pots eliminar amb confiança. És l'única manera fiable de saber-ho. can-i-deploy. Abans de desplegar, el broker respon si la versió que vols desplegar és compatible amb les versions desplegades de tots els seus consumidors. És una porta de desplegament amb informació real, no amb suposicions.- Interaccions concretes, no formes abstractes. El pacte diu «quan demano això, amb aquests paràmetres i en aquest estat, necessito aquesta resposta».
- Quan Pact compensa i quan és sobreenginyeria
Pact té un cost alt i convé ser honest sobre això: cal operar un broker, escriure i mantenir els stateHandlers —la part que més fa mal—, coordinar dos repositoris i formar dos equips.
| Situació | Pact? | Raó |
|---|---|---|
| Microserveis interns, diversos equips | Sí | És el seu cas d'ús exacte: consumidors coneguts i controlables |
| Dos equips de la mateixa empresa (front i back) | Potser | Compensa si el desplegament és independent i les trencades són freqüents |
| API pública amb consumidors desconeguts | No | No pots obligar CataBox a publicar un pacte. Mana el contracte OpenAPI del proveïdor |
| Aplicació mòbil amb versions antigues vives | No | El pacte reflecteix la versió actual de l'app, no la de fa vuit mesos |
| Un equip, un consumidor, monòlit | No | Les proves d'integració cobreixen el mateix per molt menys |
| Proveïdor extern (RàpidEnviaments) | No | No controles el seu cicle. Fes servir nock i proves d'integració contra el seu sandbox |
Per a la Botiga Aroma avui la resposta és que no, i convé raonar-ho: dos dels cinc consumidors són fora del nostre control (RàpidEnviaments i CataBox), un té versions antigues permanents (Aroma Mòbil), i l'API és essencialment pública. En aquell escenari, el contracte del proveïdor mana i openapi.yaml amb oasdiff cobreix el 90 % del valor pel 10 % del cost.
La resposta canviaria el dia que la Botiga Aroma es partís en microserveis interns amb equips separats. Aleshores Pact entre comandes-service i inventari-service seria exactament l'eina adequada.
I una idea que resumeix l'elecció: Pact respon «què necessiten els meus consumidors?»; OpenAPI respon «què prometo jo?». Amb consumidors coneguts, la primera pregunta és més útil. Amb consumidors desconeguts, és impossible de respondre, i només queda la segona.
- La taula de tipus de prova
| Unitària | Integració | Contracte (proveïdor) | Contracte (Pact) | Mock (consumidor) | Extrem a extrem | |
|---|---|---|---|---|---|---|
| Què prova | Una funció o servei | L'API completa en procés | Que les respostes compleixen OpenAPI | Que es compleix el que demana cada consumidor | Que el client gestiona bé les respostes | El sistema real desplegat |
| Què aixeca | Res | app + SQLite temporal |
Igual que integració | L'API + el broker | Res, s'intercepta | Tot: API, Redis, base de dades |
| Velocitat | Mil·lisegons | Dècimes de segon | Igual | Segons | Mil·lisegons | Minuts |
| Fragilitat | Molt baixa | Baixa | Baixa | Mitjana | Baixa | Alta |
| Quan s'executa | En desar | En desar / PR | PR | PR de tots dos costats | PR del front | Després de desplegar |
| Què NO detecta | Res d'integració | Deriva del contracte | Que el consumidor l'usi bé | Consumidors no participants | Que l'API real compleixi | Res, però falla per causes alienes |
| Quantes tenir-ne | Moltes | Bastants | Una per endpoint i codi | Una per interacció real | Les del front | Poques |
| A la Botiga Aroma | Sí (03-08) | Sí (03-08) | Sí, aquesta lliçó | No, de moment | Sí, a la SPA | Sí, un grapat |
La fila «quantes tenir-ne» és la piràmide de proves de 03-08 vista des del contracte. La regla no ha canviat: moltes de ràpides a baix, poques de lentes a dalt. El que aquesta lliçó hi afegeix és una capa nova —el contracte— que és gairebé tan barata com les d'integració perquè es munta al damunt d'elles: no són proves noves, són assercions extra a les que ja existeixen.
- Proves d'extrem a extrem: el recorregut de compra
Les proves d'extrem a extrem s'executen contra el sistema realment desplegat: procés de debò, base de dades de debò, Redis de debò, xarxa de debò. Són les úniques que detecten que la variable d'entorn està malament a preproducció, que el balancejador es menja una capçalera o que la migració no es va aplicar.
I són cares i fràgils. Per això: poques i ben triades. El criteri és cobrir els recorreguts que, si es trenquen, fan que el negoci s'aturi. A la Botiga Aroma, un: la compra.
// proves/e2e/recorregut-compra.prova.js
// S'executa contra un entorn DESPLEGAT, no contra `app` en procés.
// URL_API s'injecta des de CI: preproducció, o local amb docker-compose.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
const URL = process.env.URL_API ?? 'http://localhost:3000/v1';
async function cridar(ruta, opcions = {}) {
const resposta = await fetch(`${URL}${ruta}`, {
...opcions,
headers: { 'Content-Type': 'application/json', ...opcions.headers },
});
const cos = resposta.status === 204 ? null : await resposta.json();
return { estat: resposta.status, cos, capcaleres: resposta.headers };
}
test('recorregut complet de compra', async (t) => {
let token;
let cafeId;
let comandaId;
await t.test('1. el client inicia sessió', async () => {
const { estat, cos } = await cridar('/sessions', {
method: 'POST',
body: JSON.stringify({
email: process.env.EMAIL_PROVA, // compte fictici sembrat
contrasenya: process.env.CLAU_PROVA, // arriba del gestor de secrets
}),
});
assert.equal(estat, 200);
assert.ok(cos.token, 'no s\'ha retornat token');
token = cos.token;
});
await t.test('2. consulta el catàleg i tria un cafè disponible', async () => {
const { estat, cos, capcaleres } = await cridar('/cafes?disponible=true&limit=5', {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(estat, 200);
assert.ok(cos.dades.length > 0, 'el catàleg és buit: s\'ha sembrat la base?');
// Comprovacions que NOMÉS tenen sentit en un entorn real:
assert.ok(capcaleres.get('etag'), 'falta ETag: el middleware de memòria cau està actiu?');
assert.ok(capcaleres.get('aroma-ratelimit-restants'), 'falta el rate limiting');
assert.ok(capcaleres.get('aroma-traca-id'), 'falta la correlació de traces');
cafeId = cos.dades.find((c) => c.estoc >= 2).id;
});
await t.test('3. crea una comanda amb clau d\'idempotència', async () => {
const clau = randomUUID();
const cosPeticio = JSON.stringify({
clientId: process.env.CLIENT_PROVA,
linies: [{ cafeId, quantitat: 2 }],
});
const primera = await cridar('/comandes', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': clau },
body: cosPeticio,
});
assert.equal(primera.estat, 201);
assert.equal(primera.cos.estat, 'pendent_pagament');
comandaId = primera.cos.id;
// Reintent amb la MATEIXA clau: la idempotència de 02-03, comprovada de debò
// (en producció hi intervenen Redis i diverses instàncies, no una taula en memòria)
const segona = await cridar('/comandes', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': clau },
body: cosPeticio,
});
assert.equal(segona.estat, 201);
assert.equal(segona.cos.id, comandaId, 'la idempotència ha creat una comanda duplicada');
});
await t.test('4. paga la comanda', async () => {
const { estat, cos } = await cridar(`/comandes/${comandaId}/pagament`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': randomUUID() },
body: JSON.stringify({ metode: 'targeta', tokenTargeta: 'tok_sandbox_fictici' }),
});
assert.equal(estat, 200);
assert.equal(cos.estat, 'pagat');
});
await t.test('5. consulta la comanda i comprova el seu estat persistit', async () => {
const { estat, cos } = await cridar(`/comandes/${comandaId}`, {
headers: { Authorization: `Bearer ${token}` },
});
assert.equal(estat, 200);
assert.equal(cos.estat, 'pagat');
assert.equal(cos.totalEuros > 0, true);
assert.ok(cos._links.factura, 'una comanda pagada ha d\'enllaçar la seva factura');
});
t.after(async () => {
// Neteja: sense ella, cada execució deixa rastre i l'entorn es degrada.
if (comandaId) {
await cridar(`/comandes/${comandaId}/anullacio`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Idempotency-Key': randomUUID() },
body: JSON.stringify({ motiu: 'prova automatitzada' }),
});
}
});
});Fixa't en les tres assercions de capçaleres del pas 2. Són la raó de ser d'aquesta prova: cap prova en procés no les pot comprovar, perquè a Supertest no hi ha proxy invers, ni Redis, ni les variables d'entorn de producció. Si el balancejador elimina Aroma-Traca-Id o el rate limiting no està configurat a preproducció, això és l'única cosa que ho detecta.
- Entorn efímer, dades sembrades i aïllament
Les proves d'extrem a extrem tenen fama de fràgils, i gairebé sempre per la mateixa causa: l'estat compartit. Quatre regles que ho resolen.
1. Entorn efímer. L'entorn es crea en començar i es destrueix en acabar. Amb docker-compose (que veurem a 05-05) és directe:
# L'entorn complet, aixecat i destruït en la mateixa execució
docker compose -f docker-compose.proves.yml up -d --wait
npm run migrar && npm run sembrar
URL_API=http://localhost:3000/v1 node --test proves/e2e/
docker compose -f docker-compose.proves.yml down -v # -v esborra també els volums--wait espera que els healthcheck estiguin verds: sense això, les proves arrenquen abans que la base de dades i fallen per una cursa, no per una fallada real.
2. Dades sembrades i conegudes, sempre fictícies. El mateix npm run sembrar de 03-05: caf_001, cli_842, com_5001. Mai dades reals de clients en un entorn de proves, ni tan sols anonimitzades a mitges (04-02 i RGPD).
3. Aïllament entre execucions. Si l'entorn és compartit —el cas habitual de preproducció—, cada execució ha de fer servir dades pròpies:
// Prefix únic per execució: dues canalitzacions simultànies no xoquen
const marca = `e2e-${Date.now()}-${randomUUID().slice(0, 8)}`;
const email = `prova+${marca}@exemple.example`; // el "+" crea àlies únics4. Idempotència de la prova. Ha de poder executar-se dues vegades seguides amb el mateix resultat. Això implica no dependre d'un estat deixat per l'execució anterior i netejar al final, fins i tot si falla (t.after s'executa sempre).
Errors clàssics que trenquen aquestes quatre regles: proves que depenen de l'ordre en què s'executen, una que crea un client amb correu fix i falla la segona vegada per duplicat, i —la pitjor— la que consumeix l'estoc de caf_001 fins a esgotar-lo i fa fallar totes les altres des d'aquell dia.
- La col·lecció de Postman a CI amb Newman
La col·lecció de 05-01 encaixa aquí de manera natural: és una prova d'extrem a extrem amb interfície gràfica per escriure-la i depurar-la.
npx newman run postman/botiga-aroma-v1.postman_collection.json \
-e postman/proves.postman_environment.json \
--env-var "urlBase=$URL_PREPRODUCCIO" \
--env-var "clauClient=$CLAU_CLIENT_PROVES" \
--delay-request 100 \
--reporters cli,junit \
--reporter-junit-export informes/newman.xmlNewman o proves d'extrem a extrem en codi? No competeixen; cobreixen necessitats diferents:
| Newman | Proves e2e en codi | |
|---|---|---|
| Qui les escriu | També QA i persones no desenvolupadores | Desenvolupament |
| Depuració | Excel·lent: interfície gràfica, pas a pas | Amb el depurador |
| Lògica complexa | Limitada: scripts solts | Tota la del llenguatge |
| Revisió en pull request | Dolenta: JSON gegant il·legible al diff | Bona: és codi |
| Reutilització d'utilitats | Escassa | Total |
La recomanació pràctica per a la Botiga Aroma: Newman per a les proves de fum posteriors al desplegament —ràpides, àmplies, fàcils d'ampliar per qualsevol— i codi per al recorregut de compra, que té lògica, neteja i assercions fines.
- Càrrega i seguretat: on encaixen
Dues famílies més, que s'esmenten per situar-les al mapa i no es desenvolupen aquí.
Proves de càrrega. Ja les coneixes de 04-06 amb autocannon. S'executen contra preproducció, mai a cada pull request —triguen i necessiten un entorn estable— sinó de manera programada, setmanal o abans d'un llançament. L'important és comparar contra una referència i vigilar el p99, no la mitjana:
autocannon -c 50 -d 30 -H "Authorization: Bearer $TOKEN" \
"$URL_PREPRODUCCIO/cafes?torrefaccio=clar&limit=20"Proves de seguretat. Escàners tipus OWASP ZAP en mode baseline recorren l'API buscant capçaleres absents, configuracions insegures i vulnerabilitats conegudes; complementen però no substitueixen els controls de 04-02 ni una revisió manual de la lògica d'autorització, que és on són les fallades greus d'una API (el BOLA de l'OWASP API Top 10 no el detecta cap escàner). Al seu costat, npm audit cobreix les dependències, i s'integra a CI a 05-05.
- Què s'executa en cada moment
La taula que ordena tot l'anterior en el temps, i que és l'entrada directa a 05-05:
| Moment | Què s'executa | Durada objectiu | Si falla |
|---|---|---|---|
| En desar (local) | Lint, format, proves unitàries del fitxer tocat | < 5 s | Ho arregles a l'instant |
| Abans del commit (hook) | Lint, format, proves unitàries completes | < 30 s | El commit no es crea |
| Al pull request | Tot l'anterior + integració + contracte + spectral lint + swagger-cli validate + oasdiff breaking + npm audit + cobertura |
< 5 min | No es pot fusionar |
En fusionar a main |
Tot el del PR + construir imatge + publicar al registre | < 10 min | No es genera artefacte desplegable |
| En desplegar a preproducció | Migracions + e2e + Newman + comprovar /salut/preparat |
< 10 min | No es promociona a producció |
| En desplegar a producció | Migracions + proves de fum (subconjunt mínim) | < 2 min | Retorn enrere automàtic |
| En producció, contínuament | Monitoratge sintètic: un recorregut cada 5 minuts des de diverses regions | — | Alerta a l'equip de guàrdia |
| Setmanalment | Càrrega amb autocannon, escaneig ZAP, auditoria de dependències |
— | Tasca, no bloqueig |
Dos principis que la sostenen:
- Com més tard es detecta una fallada, més cara surt. Un
400mal format detectat en desar costa un minut; en producció costa una incidència, un retorn enrere i una trucada de RàpidEnviaments. - Com més tard a la canalització, menys proves i més lentes. Milers d'unitàries en desar; cinc proves de fum en producció. Invertir aquella proporció produeix canalitzacions de quaranta minuts que la gent aprèn a saltar-se.
Errors Comuns i Consells
- Confiar en el mock com si fos l'API. Un front amb tot verd contra Prism pot fallar sencer contra el servidor real. Connecta contra l'API de debò tan aviat com puguis.
- Exemples irreals al contracte. Alimenten el mock, la documentació i les proves d'entrada. Un exemple amb
"nom": "string"produeix una interfície dissenyada per a escombraries. - Tancar els esquemes de sortida amb
additionalProperties: false. Cada camp nou trenca les proves de contracte i l'equip acaba desactivant-les. Tanca'ls només a les entrades. - Escriure les proves de contracte a part. Dupliquen feina i s'abandonen. Munta-les com a assercions extra dins de les proves d'integració que ja tens.
- Moltes proves d'extrem a extrem. Mitja hora de canalització, fallades intermitents i, al cap de tres setmanes, algú les marca com a opcionals. Poques i crítiques.
- No netejar després de la prova d'extrem a extrem. L'entorn es degrada fins que les proves fallen per dades escombraria i ningú no sap per què.
- Adoptar Pact perquè sona bé. Sense consumidors controlables i sense disciplina als
stateHandlers, és un manteniment constant sense retorn. Comença per OpenAPI ioasdiff. oasdiffsense referència estable. Comparar contra una branca que es mou produeix soroll. Compara contramaino, millor, contra l'especificació publicada en producció.- Ignorar la direcció de l'
enum. Afegir un valor al que acceptes és segur; afegir-lo al que retornes pot trencar clients generats. Avisa-ho a la descripció del camp. - Consell: valida el contracte a la resposta d'error, no només a la d'èxit. Els errors són la part del contracte que més es trenca, perquè gairebé ningú no els prova.
- Consell:
nock.disableNetConnect()a tots els conjunts de proves. Una prova que crida internet és una prova que fallarà algun divendres per motius aliens. - Consell: si un canvi trencador és inevitable, que sigui conscient. Etiqueta el pull request, escriu l'ADR, avisa els consumidors i aplica el cicle de deprecació de 02-07. L'eina detecta; el procés decideix.
Exercicis
Exercici 1: classificar canvis del contracte
Per a cada canvi proposat sobre openapi.yaml, indica si oasdiff breaking el marcaria com a trencador, justifica-ho des del punt de vista d'un consumidor concret de la Botiga Aroma, i proposa una alternativa compatible quan ho sigui:
- Afegir el camp opcional
paisTorrefaccioa la resposta deGET /cafes. - Canviar
limitde màxim 100 a màxim 50. - Afegir el valor
descafeinata l'enumdetorrefaccioa la resposta. - Reanomenar
notesTastanotes. - Fer obligatori el camp
metodeal cos dePOST /comandes/{id}/pagament. - Afegir la resposta
429a una operació que no la documentava. - Canviar
totalEurosdenumberastringper evitar problemes de coma flotant.
Exercici 2: prova de contracte d'un endpoint amb error
Escriu la prova d'integració que verifica que POST /v1/comandes compleix el contracte al seu camí d'error: quan es demana més estoc del disponible ha de respondre 409 amb codi: estoc_insuficient, un cos que compleixi l'esquema Error i detalls amb el camp afectat. Fes servir les ajudes compleixEsquema i compleixContracte de l'apartat 7, i afegeix una segona asserció que comprovi que l'estoc no s'ha modificat.
Exercici 3: dissenyar l'estratègia de proves d'un endpoint nou
La Botiga Aroma afegeix POST /v1/comandes/{id}/devolucio: un client sol·licita la devolució d'una comanda enviat en els 14 dies següents; l'API valida el termini, crea la sol·licitud, notifica RàpidEnviaments per a la recollida i envia un correu al client.
Dissenya l'estratègia completa: quines proves escriuries de cada tipus (unitària, integració, contracte, e2e, mock del consumidor), què comprova cadascuna, què se simula a cada nivell, i en quin moment de la taula de l'apartat 18 s'executa cadascuna. Indica també què no provaries i per què.
Solucions
Solució 1
| # | Canvi | És trencador? | Anàlisi i alternativa |
|---|---|---|---|
| 1 | Afegir paisTorrefaccio a la resposta |
No | Els consumidors han d'ignorar camps desconeguts (02-07), i per això els esquemes de sortida no estan tancats. Un client TypeScript generat simplement no el coneix. Es pot desplegar sense cerimònia. |
| 2 | limit de 100 a 50 |
Sí | L'Aroma Mòbil demana limit=100 a la pantalla de catàleg; després del canvi rep 400 parametre_invalid i la pantalla queda buida per a tots els usuaris amb aquella versió instal·lada. Alternativa: acceptar fins a 100 però retornar com a màxim 50 elements, documentant-ho; o iniciar la deprecació avisant i canviar el màxim a /v2. |
| 3 | descafeinat a l'enum de resposta |
Sí (trencador «suau») | El client TypeScript de la SPA té TorrefaccioEnum amb tres valors; en rebre'n un quart pot fallar la deserialització o caure en un default inesperat. L'Aroma Mòbil, amb validació estricta, podria descartar l'element. Alternativa: anunciar-ho amb antelació, publicar primer l'enum ampliat al contracte perquè els clients es regenerin, i només després començar a retornar el valor. És un bon exemple que el contracte ha de canviar abans que el comportament. |
| 4 | Reanomenar notesTast a notes |
Sí | És la trencada més clàssica: eliminar un camp de la resposta. Tota la SPA que pinta les notes de tast deixa de mostrar-les. Alternativa: afegir notes mantenint notesTast amb el mateix valor, marcar notesTast com a deprecated: true amb Sunset, esperar sis mesos i eliminar-lo a /v2. Cost: duplicar un camp durant mig any. Benefici: no es trenca ningú. |
| 5 | metode obligatori al pagament |
Sí | Una versió antiga de l'Aroma Mòbil que enviava {} confiant en el mètode per defecte comença a rebre 400, i els usuaris no poden pagar. Alternativa: mantenir-lo opcional amb un valor per defecte documentat (targeta), i fer-lo obligatori a /v2. Si el valor per defecte és perillós, la via correcta és rebutjar explícitament els casos ambigus amb un codi d'error específic i un cicle de deprecació. |
| 6 | Documentar el 429 que ja existia |
No | No canvia el comportament: l'API ja podia retornar 429. És una millora del contracte, i de les més útils: oasdiff no la marca, però sí que evita que un consumidor s'emporti una sorpresa. És la prova que el contracte mentia per omissió. |
| 7 | totalEuros de number a string |
Sí, amb matís | Un canvi de tipus trenca qualsevol client tipat. La intenció és bona —evitar la coma flotant—, però l'execució és trencadora. Alternativa: afegir totalCentims (enter, exacte) al costat del totalEuros existent, documentar que és el camp preferit per a l'aritmètica, deprecar totalEuros i eliminar-lo a /v2. El principi general: afegeix el camp correcte, no transformis l'existent. |
Solució 2
// proves/integracio/comandes-contracte.prova.js
import './../ajudes/entorn-prova.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import request from 'supertest';
import { migrar, sembrar } from '../ajudes/base-dades-prova.js';
import { tokenDe } from '../ajudes/token.js';
import { compleixEsquema, compleixContracte } from '../ajudes/contracte.js';
test('POST /v1/comandes amb estoc insuficient compleix el contracte del 409', async () => {
const { app } = await import('../../src/app.js');
migrar();
sembrar(); // caf_001 queda amb estoc 120
const token = `Bearer ${tokenDe('cli_842', 'client')}`;
// 1. Estat inicial: anotem l'estoc abans d'intentar res
const abans = await request(app).get('/v1/cafes/caf_001').set('Authorization', token).expect(200);
const estocInicial = abans.body.estoc;
assert.equal(estocInicial, 120, 'la sembra no ha deixat l\'estoc esperat');
// 2. Demanem més del que hi ha
const resposta = await request(app)
.post('/v1/comandes')
.set('Authorization', token)
.set('Idempotency-Key', randomUUID())
.send({ clientId: 'cli_842', linies: [{ cafeId: 'caf_001', quantitat: 99 }, { cafeId: 'caf_001', quantitat: 99 }] })
.expect(409);
// 3. Assercions de CONTRACTE
compleixEsquema('Error', resposta.body);
compleixContracte('/comandes', 'post', 409, resposta.body);
// 4. Assercions de COMPORTAMENT
assert.equal(resposta.body.error.codi, 'estoc_insuficient');
assert.ok(Array.isArray(resposta.body.error.detalls));
assert.ok(resposta.body.error.detalls.length > 0,
'un 409 d\'estoc ha de dir QUINA línia falla: sense detalls no és accionable');
assert.match(resposta.body.error.detalls[0].camp, /^linies\[\d+\]/);
// 5. Un 4xx NO porta tracaId: només els 5xx (03-07)
assert.equal(resposta.body.error.tracaId, undefined);
// 6. No s'ha creat cap recurs
assert.equal(resposta.headers.location, undefined);
// 7. L'ASSERCIÓ CLAU: la transacció s'ha revertit del tot.
// Sense això, una fallada parcial podria haver reservat estoc de la primera línia
// abans de detectar que la segona no hi cabia. És la garantia de 03-05.
const despres = await request(app).get('/v1/cafes/caf_001').set('Authorization', token).expect(200);
assert.equal(despres.body.estoc, estocInicial,
'l\'estoc ha canviat tot i que la comanda ha fallat: la transacció no és atòmica');
assert.equal(despres.body.versio, abans.body.versio,
'la versió del recurs ha canviat: hi ha hagut una escriptura que no havia de passar');
});Notes sobre el disseny d'aquesta prova:
- Dues línies del mateix cafè amb 99 unitats cadascuna és un cas millor que una sola línia de 200: prova a més que el servei suma les quantitats per cafè en lloc de validar-les per separat, una fallada real i freqüent.
- La comprovació de la versió a més de l'estoc detecta escriptures que es compensen (baixar i tornar a pujar), que deixarien l'estoc igual però la versió diferent.
- La prova fa servir
compleixEsquema('Error', ...)icompleixContracte('/comandes', 'post', 409, ...): la primera valida contra l'esquema genèric, la segona comprova a més queopenapi.yamldocumenta aquell409. Si algú implementa l'error però oblida documentar-lo, la segona falla. Aquella és justament la deriva que volem caçar.
Solució 3
Estratègia de proves per a POST /v1/comandes/{id}/devolucio
Nivell 1 — Unitàries (servei, amb repositori en memòria; s'executen en desar):
| Prova | Què comprova | Què se simula |
|---|---|---|
| Termini vàlid | Una comanda enviada fa 3 dies admet devolució | Rellotge fixat a una data coneguda |
| Termini esgotat | Fa 15 dies → termini_devolucio_expirat |
Rellotge fixat |
| Frontera exacta | Dia 14 a les 23:59 sí, dia 15 a les 00:01 no | Rellotge fixat |
| Estat incorrecte | Una comanda pendent_pagament o pagat sense enviar → 409 |
Repositori en memòria |
| Devolució duplicada | Segona sol·licitud sobre la mateixa comanda → 409 |
Repositori en memòria |
| Càlcul de l'import | Amb devolució parcial de línies, l'import quadra al cèntim | Cap |
El rellotge és la decisió de disseny clau: la lògica de termini ha de rebre la data actual com a dependència injectada, no cridar Date.now() per dins. Sense això, les proves de frontera són impossibles o fràgils.
Nivell 2 — Integració (Supertest sobre app + SQLite temporal + nock; en desar i al PR):
| Prova | Què comprova | Què se simula |
|---|---|---|
| Camí feliç | 201 amb Location, la comanda passa a devolucio_sollicitada |
RàpidEnviaments amb nock; correu amb un doble |
| Autorització | Un client no pot retornar la comanda d'un altre → 403 |
— |
| Idempotència | Dues peticions amb la mateixa Idempotency-Key → una sola devolució |
— |
| RàpidEnviaments caigut | 503 del transportista: la devolució es crea igualment i queda recollida_pendent |
nock amb 503 |
| Correu caigut | La fallada del correu no reverteix la devolució; es reencua | Doble del servei de correu |
| Petició sortint correcta | El cos enviat a RàpidEnviaments porta referència, adreça i pes | Asserció dins de nock |
Les dues proves de dependència caiguda són les més valuoses i les que ningú no escriu: documenten que la fallada d'un tercer no ha de desfer una operació del client.
Nivell 3 — Contracte (dins de les d'integració; al PR):
compleixContracte('/comandes/{id}/devolucio', 'post', 201, cos)al camí feliç.compleixEsquema('Error', cos)al403, al409de termini i al409de duplicat.- Abans d'escriure el codi: afegir l'operació a
openapi.yaml, inclosos els codis d'error nous (termini_devolucio_expirat) a l'enumde l'esquemaError. Si no és al contracte,compleixContractefalla, i això és el que es busca: el contracte primer. oasdiff breaking: afegir un endpoint i un valor a l'enumd'error no és trencador, així que la porta passarà en verd. Convé verificar-ho igualment.
Nivell 4 — Mock del consumidor (msw a la SPA; al PR del front):
- La pantalla «sol·licitar devolució» amb
201, amb409de termini expirat (missatge específic, no genèric) i amb403. - Un manejador amb
Prefer: code=409a Prism per maquetar abans que existeixi l'endpoint.
Nivell 5 — Extrem a extrem (després de desplegar a preproducció):
Cap prova nova. I això és una decisió, no un oblit: el recorregut de compra ja cobreix inici de sessió, catàleg, comanda i pagament, que són les peces que trenquen el negoci si fallen. Una devolució és un flux secundari, requereix una comanda en estat enviat —cosa que exigeix simular el pas del transportista— i la seva prova seria lenta i fràgil. Es cobreix amb integració, que dona el 95 % de la confiança pel 5 % del cost.
Excepció: si la devolució mou diners de debò cap a la passarel·la, sí que mereix una prova de fum contra el sandbox de la passarel·la, executada setmanalment i no a cada desplegament, perquè les integracions de pagament són on més car surt una fallada.
Què NO provaria, i per què:
- El contingut del correu. És responsabilitat del servei de correu. N'hi ha prou de comprovar que se li demana enviar-lo amb les dades correctes; verificar la plantilla és provar una biblioteca aliena.
- Que RàpidEnviaments reculli el paquet. És fora del nostre sistema. Provem que l'hi demanem bé i que sabem gestionar la seva fallada; la resta és el seu contracte amb nosaltres.
- Cada combinació de línies retornades. El càlcul de l'import es prova unitàriament amb tres o quatre casos representatius i les fronteres. Provar totes les combinacions en integració és lent i no aporta res nou.
- La interfície de la SPA en e2e. Això és una API; les proves de la interfície són del repositori del front i es fan amb
mswo amb un navegador automatitzat allà.
Moment d'execució (segons la taula de l'apartat 18): unitàries en desar; integració i contracte en desar i al pull request; msw al PR del front; sense e2e noves; la prova de fum de la passarel·la, setmanal.
Conclusió
El cercle està tancat. openapi.yaml ha deixat de ser un document que descriu intencions per convertir-se en un artefacte del qual se'n deriven set coses diferents i —el més important— contra el qual es verifica la realitat. Has aixecat un mock amb Prism que permet a la SPA maquetar el catàleg, la pantalla d'estoc insuficient i cada estat d'error amb Prefer: code=409 setmanes abans que l'endpoint existeixi, sabent exactament on són els seus límits: sense lògica, sense estat i sense autenticació, un mock desbloqueja el desenvolupament en paral·lel però no demostra res sobre l'API real. Has vist els dobles als dos costats de la conversa: msw interceptant a les proves de la SPA, amb onUnhandledRequest: 'error' perquè cap crida no s'escapi, i nock amb disableNetConnect() per provar el que d'altra manera seria impossible —que un 503 de RàpidEnviaments no reverteixi un cobrament ja fet—.
El nucli de la lliçó són les proves de contracte del proveïdor: la nova ajuda proves/ajudes/contracte.js compila amb AJV els components.schemas d'OpenAPI 3.1 i valida els cossos reals dins de les proves Supertest de 03-08, sense escriure un conjunt de proves a part. Això detecta el que cap asserció manual no detectava: un camp required que desapareix, una data sense Z, un codi d'error que s'inventa fora del catàleg. I en l'altra direcció, oasdiff converteix les regles de compatibilitat de 02-07 en una porta automàtica amb eines/comprovar-contracte.sh, que distingeix el que trenca del que no i —detall que costa car aprendre— sap que ampliar un enum d'entrada és segur i ampliar-ne un de sortida no ho és. Sobre Pact t'emportes un criteri, no una implementació: respon «què necessiten els meus consumidors?», una pregunta magnífica quan els consumidors són serveis interns coneguts i impossible de respondre quan són l'Aroma Mòbil amb versions de fa vuit mesos i CataBox. Per a la Botiga Aroma avui, el contracte del proveïdor mana.
Els artefactes nous del projecte: proves/ajudes/contracte.js, proves/e2e/recorregut-compra.prova.js amb el recorregut complet inici de sessió → catàleg → comanda amb idempotència comprovada de debò → pagament → consulta, eines/comprovar-contracte.sh, els scripts mock i mock:dinamic, i @stoplight/prism-cli, ajv, ajv-formats, yaml i nock a devDependencies. I una taula que ordena tota la feina del curs en el temps: què s'executa en desar, què al pull request, què en desplegar i què en producció.
Aquella darrera taula és literalment el guió de la lliçó següent. A 05-05, Integració contínua i desplegament, la convertim en una canalització que s'executa sola: empaquetarem l'API en un Dockerfile multietapa amb usuari no root, HEALTHCHECK sobre /salut i l'aturada ordenada davant de SIGTERM que vam escriure a 03-07; aixecarem l'entorn complet amb docker-compose.yml incloent-hi Redis; escriurem .github/workflows/ci.yml amb les portes en ordre —npm ci, lint, Spectral, proves amb cobertura, npm audit, oasdiff, construcció i publicació de la imatge, i Newman contra preproducció—; veurem per què les migracions han de ser retrocompatibles i què passa quan un DROP COLUMN es troba amb la versió anterior encara viva; i compararem recreate, rolling, blue-green i canary, amb el paper exacte que hi juguen /salut i /salut/preparat perquè no entri trànsit abans d'hora.
Curs de REST API: Principis de Disseny i Desenvolupament d'APIs RESTful
Mòdul 1: Introducció a les APIs RESTful
- Què és una API?
- Història i evolució de les APIs
- Fonaments d'HTTP per a APIs
- Principis bàsics de REST
- Model de maduresa de Richardson i HATEOAS
- REST vs. SOAP
- REST davant de GraphQL, gRPC i webhooks
Mòdul 2: Disseny d'APIs RESTful
- Principis de disseny d'APIs RESTful
- Recursos i URIs
- Mètodes HTTP
- Codis d'estat HTTP
- Representacions, capçaleres i negociació de contingut
- Filtratge, ordenació, paginació i cerca
- Versionat d'APIs
- Documentació d'APIs
Mòdul 3: Desenvolupament d'APIs RESTful
- Configuració de l'entorn de desenvolupament
- Creació d'un servidor bàsic
- Gestió de peticions i respostes
- Validació de dades d'entrada
- Persistència i capa d'accés a dades
- Autenticació i autorització
- Gestió d'errors
- Proves i validació
Mòdul 4: Bones Pràctiques i Seguretat
- Bones pràctiques en el disseny d'APIs
- Seguretat en APIs RESTful
- OAuth 2.0 i OpenID Connect a la pràctica
- Rate limiting i throttling
- CORS i polítiques de seguretat
- Memòria cau HTTP i rendiment
- Observabilitat: logs, mètriques i traces
Mòdul 5: Eines i Frameworks
- Postman per a proves d'APIs
- Swagger i OpenAPI per a documentació
- Frameworks populars per a APIs RESTful
- Contractes, mocks i proves automatitzades d'API
- Integració contínua i desplegament
- API gateways i portals de desenvolupador
