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

  1. Cinc consumidors i una API que canvia
  2. El contracte com a artefacte executable
  3. Servidors mock: Prism sobre openapi.yaml
  4. Mocks estàtics davant de dinàmics, i els seus límits
  5. Dobles al consumidor: msw a la SPA
  6. Dobles al proveïdor: nock per a RàpidEnviaments
  7. Proves de contracte del proveïdor: validar les respostes
  8. Validar també les peticions
  9. Detectar canvis trencadors amb oasdiff
  10. oasdiff com a porta a la integració contínua
  11. Contract testing dirigit pel consumidor: Pact
  12. Quan Pact compensa i quan és sobreenginyeria
  13. La taula de tipus de prova
  14. Proves d'extrem a extrem: el recorregut de compra
  15. Entorn efímer, dades sembrades i aïllament
  16. La col·lecció de Postman a CI amb Newman
  17. Càrrega i seguretat: on encaixen
  18. Què s'executa en cada moment

  1. 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 refactor dels mapejadors fa que notesTast deixi 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 rebre 400.
  • Un enum guanya 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.

  1. 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.

  1. Servidors mock: Prism sobre openapi.yaml

Situació 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.

  1. 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:

  1. 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_insuficient tret que l'hi demanis amb Prefer.
  2. No hi ha estat. Crees una comanda amb POST i GET /comandes continua retornant l'exemple de sempre. Els recorreguts complets no es poden provar així.
  3. No hi ha autenticació real. El mock no valida tokens ni permisos.
  4. 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.

  1. Dobles al consumidor: msw a la SPA

Prism é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.

  1. Dobles al proveïdor: nock per a RàpidEnviaments

El 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.

  1. 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 : 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
  };
}

  1. 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.

  1. Detectar canvis trencadors amb oasdiff

Les 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.yaml

Sortida 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 20

La 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ó Els clients antics no l'envien → 400
Eliminar un camp d'una resposta Algú l'estava llegint
Eliminar un valor d'un enum de resposta 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) Peticions abans vàlides ara fallen
Ampliar un rang (maximum major) No És acceptar més
Canviar el tipus d'un camp Trencada clàssica
Eliminar un endpoint 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.

  1. oasdiff com a porta a la integració contínua

Per comparar cal tenir una referència. Dues estratègies:

  1. Contra la branca principal, amb git show main:openapi.yaml. Simple i suficient per a la majoria d'equips.
  2. 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 a main— 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
fi

Amb 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.

  1. 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».

  1. 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 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.

  1. 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.

  1. 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.

  1. 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 únics

4. 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.

  1. 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.xml

Newman 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.

  1. 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.

  1. 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 400 mal 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 i oasdiff.
  • oasdiff sense referència estable. Comparar contra una branca que es mou produeix soroll. Compara contra main o, 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:

  1. Afegir el camp opcional paisTorrefaccio a la resposta de GET /cafes.
  2. Canviar limit de màxim 100 a màxim 50.
  3. Afegir el valor descafeinat a l'enum de torrefaccio a la resposta.
  4. Reanomenar notesTast a notes.
  5. Fer obligatori el camp metode al cos de POST /comandes/{id}/pagament.
  6. Afegir la resposta 429 a una operació que no la documentava.
  7. Canviar totalEuros de number a string per 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 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 (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 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 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 , 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', ...) i compleixContracte('/comandes', 'post', 409, ...): la primera valida contra l'esquema genèric, la segona comprova a més que openapi.yaml documenta aquell 409. 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) al 403, al 409 de termini i al 409 de duplicat.
  • Abans d'escriure el codi: afegir l'operació a openapi.yaml, inclosos els codis d'error nous (termini_devolucio_expirat) a l'enum de l'esquema Error. Si no és al contracte, compleixContracte falla, i això és el que es busca: el contracte primer.
  • oasdiff breaking: afegir un endpoint i un valor a l'enum d'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, amb 409 de termini expirat (missatge específic, no genèric) i amb 403.
  • Un manejador amb Prefer: code=409 a 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 msw o 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

Mòdul 2: Disseny d'APIs RESTful

Mòdul 3: Desenvolupament d'APIs RESTful

Mòdul 4: Bones Pràctiques i Seguretat

Mòdul 5: Eines i Frameworks

Mòdul 6: Casos d'Estudi i Projectes

© Copyright 2026. Tots els drets reservats