Durante cuatro módulos hemos escrito la API de Tienda Aroma: la diseñamos en el módulo 2, la construimos en el 3 y la endurecimos en el 4. Y en todo ese tiempo la hemos probado de dos maneras: con curl a mano, escribiendo cabeceras larguísimas en la terminal, y con las pruebas automáticas de Supertest de 03-08, que son excelentes para el código pero no sirven para explorar.

Falta una tercera forma de trabajar, la que ocupa el día a día real de quien desarrolla o consume una API: abrir un cliente, lanzar una petición, mirar la respuesta, cambiar un parámetro, volver a lanzarla. Y, cuando algo funciona, guardarlo para no volver a escribirlo nunca más ni depender de que alguien recuerde la sintaxis exacta.

Esta lección abre el módulo 5 con esa herramienta. Vamos a construir la colección "Tienda Aroma v1": un fichero versionable, con carpetas por recurso, entornos para local, pruebas y producción, autenticación que se renueva sola, aserciones que comprueban el contrato y peticiones encadenadas. Y terminaremos ejecutándola entera desde la terminal con Newman, que es exactamente lo que 05-05 conectará a la integración continua.

Todos los datos, dominios y credenciales de esta lección son ficticios. Ninguna cadena que parezca un token es un secreto real, y ningún ejemplo debe copiarse con valores reales dentro.

Contenido

  1. Por qué hace falta un cliente HTTP además de las pruebas automáticas
  2. Qué es Postman y qué alternativas hay
  3. Instalación y primer contacto: GET /v1/cafes
  4. Leer la respuesta: cuerpo, cabeceras y tiempos
  5. Colecciones y carpetas: la estructura de "Tienda Aroma v1"
  6. Las peticiones reales del contrato
  7. Variables: de colección, de entorno y globales
  8. Entornos: local, pruebas y producción
  9. Secretos: qué nunca se exporta
  10. Autenticación: Bearer Token y herencia de carpeta
  11. Scripts pre-request: lo que pasa antes de enviar
  12. Scripts post-response: las aserciones
  13. Encadenar peticiones: guardar el id y usarlo después
  14. Generar una Idempotency-Key distinta en cada envío
  15. Aserciones sobre el esquema JSON
  16. Ejecutar la colección entera con el Collection Runner
  17. Ficheros de datos CSV y JSON
  18. Newman: la colección desde la terminal
  19. Importar openapi.yaml y exportar la colección
  20. Documentación y compartición con el equipo
  21. El servidor mock de Postman
  22. Buenas prácticas y qué va al repositorio

  1. Por qué hace falta un cliente HTTP además de las pruebas automáticas

Las pruebas de 03-08 y un cliente como Postman responden a preguntas distintas, y confundirlas lleva a equipos que tienen una cosa y echan de menos la otra.

Pruebas Supertest (03-08) Cliente HTTP (Postman)
Pregunta que responde ¿Sigue funcionando lo que ya funcionaba? ¿Qué pasa si hago esto?
Cuándo se usa En cada git push, sin humanos Mientras desarrollas, depuras o exploras
Contra qué corre El objeto app en memoria, sin red Un servidor real, con red, TLS y proxies
Quién la escribe Quien desarrolla la API También quien la consume
Qué detecta bien Regresiones lógicas Problemas de red, CORS, cabeceras, despliegue
Qué no detecta Que el despliegue esté mal configurado Regresiones, porque nadie lo ejecuta a mano

La frase clave es la última fila. Supertest no vería jamás que el balanceador de producción está eliminando la cabecera Aroma-Traza-Id, porque nunca hay balanceador: llama a app directamente. Y Postman no detectaría una regresión si nadie pulsa el botón. Por eso la meta de esta lección no es "aprender a pulsar Send", sino convertir la exploración manual en un artefacto repetible y ejecutable que, en 05-05, se pulsará solo.

  1. Qué es Postman y qué alternativas hay

Postman es un cliente HTTP con interfaz gráfica que guarda las peticiones en colecciones: ficheros JSON con las URLs, cabeceras, cuerpos, variables y scripts. Ese detalle —que una colección es un fichero— es el que la convierte en algo más que una herramienta personal: se versiona en Git, se revisa en un pull request y se ejecuta en la integración continua.

Estas son las alternativas serias que te vas a encontrar en equipos reales:

Herramienta Modelo Punto fuerte Punto débil Formato de la colección
Postman App de escritorio, cuenta en la nube Ecosistema completo: runner, mocks, monitores, documentación Empuja hacia la nube; pesada; funciones clave en el plan de pago JSON propio (v2.1)
Insomnia App de escritorio Ligera, buena para GraphQL y gRPC Ecosistema menor JSON/YAML propio
Bruno App de escritorio, offline first Guarda cada petición como fichero de texto plano (.bru) en tu repositorio; diffs legibles Joven, menos integraciones Ficheros .bru en carpetas
Hoppscotch Web (y autoalojable) Cero instalación, se abre en el navegador Depende del navegador para CORS y certificados JSON propio
REST Client (VS Code) Extensión del editor Peticiones en un .http junto al código; sin salir del editor Sin runner ni informes Fichero .http
curl Terminal Está en todas partes; es la lengua franca para compartir un fallo Verboso; sin estado entre llamadas Un comando
HTTPie Terminal Sintaxis mucho más legible que curl; colorea JSON Hay que instalarlo Un comando

Comparación práctica de la misma petición en las tres formas de la terminal:

# curl: universal, verboso. Así se pega un fallo en un ticket.
curl -i -X POST http://localhost:3000/v1/pedidos \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
  -d '{"clienteId":"cli_842","lineas":[{"cafeId":"caf_001","cantidad":2}]}'

# HTTPie: lo mismo, mucho más corto de leer
http POST localhost:3000/v1/pedidos \
  "Authorization:Bearer $TOKEN" \
  "Idempotency-Key:7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55" \
  clienteId=cli_842 \
  lineas:='[{"cafeId":"caf_001","cantidad":2}]'

Y el mismo caso como fichero .http de la extensión REST Client, que tiene la ventaja de vivir dentro del repositorio junto al código que prueba:

### Login del cliente Marta
# @name login
POST http://localhost:3000/v1/sesiones
Content-Type: application/json

{ "email": "[email protected]", "password": "{{claveDePrueba}}" }

### Crear pedido reutilizando el token de la respuesta anterior
POST http://localhost:3000/v1/pedidos
Authorization: Bearer {{login.response.body.token}}
Content-Type: application/json
Idempotency-Key: 7f3c1a90-2d64-4e11-9c88-1b2f4a6d0e55

{ "clienteId": "cli_842", "lineas": [{ "cafeId": "caf_001", "cantidad": 2 }] }

Criterio de elección. Si tu equipo ya vive en Postman, quédate en Postman: la ganancia de cambiar rara vez compensa. Si te importa por encima de todo que las peticiones se revisen como código en los pull requests, Bruno o REST Client son mejores porque guardan texto plano legible. Todo lo conceptual de esta lección —variables, entornos, encadenado, aserciones, ejecución en CI— existe en las cuatro herramientas gráficas con otro nombre; lo que cambia es la sintaxis.

Usaremos Postman porque es el estándar de facto y porque Newman nos da la ejecución en CI que necesita 05-05.

  1. Instalación y primer contacto: GET /v1/cafes

Postman se descarga de su sitio oficial para Windows, macOS y Linux; también existe una versión web, aunque para llamar a localhost necesita el Postman Agent instalado, así que para nuestro caso conviene la aplicación de escritorio.

Antes de nada, levanta el proyecto de los módulos 3 y 4:

cd tienda-aroma-api
npm run bd:reiniciar   # migra y siembra: caf_001, caf_002, cli_842, ped_5001...
npm run dev            # node --watch src/servidor.js → http://localhost:3000

En Postman, Ctrl/Cmd + NHTTP Request. Escribe el método GET y la URL:

http://localhost:3000/v1/cafes?tueste=claro&limite=2&ordenar=-precioEuros

Pulsa Send. Fíjate en que Postman ha entendido la cadena de consulta y ha rellenado sola la pestaña Params con una fila por parámetro: puedes activarlos y desactivarlos con la casilla, que es la forma cómoda de probar combinaciones de filtros sin editar texto.

La respuesta que devuelve nuestra API:

{
  "datos": [
    {
      "id": "caf_001",
      "nombre": "Etiopía Yirgacheffe",
      "origen": "Etiopía",
      "tueste": "claro",
      "precioEuros": 14.50,
      "stock": 120,
      "notasCata": ["cítrico", "floral", "té negro"],
      "fechaCreacion": "2026-01-15T08:30:00Z",
      "_links": {
        "self": { "href": "/v1/cafes/caf_001" },
        "resenas": { "href": "/v1/cafes/caf_001/resenas" }
      }
    }
  ],
  "total": 1
}

  1. Leer la respuesta: cuerpo, cabeceras y tiempos

El cuerpo es lo primero que se mira y lo menos interesante para lo que hemos construido. En la parte inferior de Postman hay tres datos que resumen medio módulo 4:

  • Status: 200 OK.
  • Time: el tiempo total de la petición. Cuidado: incluye la resolución DNS, la conexión y el TLS, así que siempre será mayor que la latencia que mide prom-client en /metricas (04-07). Pasa el ratón por encima para ver el desglose por fases, que es la forma más rápida de descubrir que "la API va lenta" es en realidad "el handshake TLS tarda 300 ms".
  • Size: el tamaño. Si activaste compression en 04-06, verás el tamaño comprimido.

La pestaña Headers de la respuesta es donde se comprueba el trabajo del módulo 4:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: W/"a1b2c3d4e5f6"
Cache-Control: public, max-age=60
Vary: Accept-Encoding, Origin
Link: </v1/cafes?limite=2&desplazamiento=2&tueste=claro>; rel="next",
      </v1/cafes?limite=2&desplazamiento=0&tueste=claro>; rel="first"
Aroma-Traza-Id: 3f9a2c1e-8b47-4d2a-9e01-77c6b5d3a812
Aroma-RateLimit-Limite: 600
Aroma-RateLimit-Restantes: 597
Aroma-RateLimit-Reinicio: 1771065600
X-Content-Type-Options: nosniff

Cuatro comprobaciones que conviene hacer a mano la primera vez:

  1. ETag y 304. Copia el valor del ETag, crea una petición idéntica con la cabecera If-None-Match puesta a ese valor y envíala. Debe responder 304 Not Modified sin cuerpo. Postman muestra Size: 0 B en el cuerpo: ahí ves de verdad el ahorro de 04-06.
  2. Aroma-RateLimit-Restantes. Pulsa Send diez veces seguidas y observa cómo baja. Es la comprobación más simple de que el limitador de 04-04 está activo en este entorno.
  3. Link. Copia la URL de rel="next" en una petición nueva: debe traerte la página siguiente sin que tengas que construir tú el desplazamiento. Si tienes que calcularlo a mano, HATEOAS no está funcionando.
  4. Aroma-Traza-Id. Cópialo y búscalo en la salida de pino de tu terminal. Debe aparecer en todas las líneas de esa petición. Es la correlación de 04-07 vista desde fuera.

Consejo. Prueba también un error a propósito: GET /v1/cafes?tueste=morado. Debe responder 400 con {"error":{"codigo":"parametro_invalido",...}} y sin trazaId en el cuerpo, porque solo los 5xx lo llevan. Ver el contrato de errores cumpliéndose es tan importante como ver el camino feliz.

  1. Colecciones y carpetas: la estructura de "Tienda Aroma v1"

Una petición suelta se pierde. El paso siguiente es crear la colección. En el panel izquierdo: Collections → + y nómbrala Tienda Aroma v1.

La estructura que vamos a construir refleja los recursos del contrato de 02-02, no la implementación:

Tienda Aroma v1/
├── 00 Sesiones/
│   ├── POST Iniciar sesión (cliente)
│   ├── POST Iniciar sesión (administrador)
│   └── DELETE Cerrar sesión
├── 01 Cafés/
│   ├── GET Listar cafés
│   ├── GET Listar cafés filtrados
│   ├── GET Obtener café por id
│   ├── GET Obtener café (If-None-Match → 304)
│   ├── POST Crear café
│   ├── PATCH Actualizar café (merge-patch)
│   ├── DELETE Borrar café
│   └── GET Reseñas del café
├── 02 Pedidos/
│   ├── POST Crear pedido (con Idempotency-Key)
│   ├── GET Listar mis pedidos
│   ├── GET Obtener pedido
│   ├── POST Pagar pedido
│   └── POST Anular pedido
├── 03 Clientes/
├── 04 Reseñas/
└── 99 Errores esperados/
    ├── GET Café inexistente → 404
    ├── GET Parámetro inválido → 400
    ├── POST Crear café sin token → 401
    ├── POST Crear café como cliente → 403
    └── PUT /v1/cafes → 405 con Allow

Tres decisiones de esa estructura merecen explicación:

  • El prefijo numérico (00, 01, …) no es decorativo: el Collection Runner ejecuta las peticiones en el orden en que aparecen, y la carpeta 00 Sesiones debe correr primero porque es la que obtiene el token que usan todas las demás.
  • Una carpeta por recurso, no por caso de uso. Los casos de uso cambian cada trimestre; los recursos son la parte estable del contrato.
  • La carpeta 99 Errores esperados es la que distingue una colección profesional de una lista de peticiones. Documenta el comportamiento del catálogo de errores de 02-04 y detecta la regresión más habitual: alguien toca la autorización y un 403 se convierte en 500.

  1. Las peticiones reales del contrato

Estas son las peticiones centrales tal como quedan configuradas. Empezamos por el login, del que depende todo lo demás:

POST {{urlBase}}/sesiones
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "{{claveCliente}}"
}

Respuesta esperada, 200, con el JWT que emitimos en 03-06:

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.EJEMPLO.FICTICIO",
  "expiraEn": 3600,
  "cliente": { "id": "cli_842", "nombre": "Marta García", "rol": "cliente" }
}

Listado con filtros, usando la pestaña Params para poder desactivarlos uno a uno:

GET {{urlBase}}/cafes?origen=Colombia&tueste=medio&precioMax=15&ordenar=-precioEuros&limite=20
Authorization: Bearer {{token}}

Creación de un café, que exige rol administrador según la matriz de permisos de 03-06:

POST {{urlBase}}/cafes
Authorization: Bearer {{tokenAdmin}}
Content-Type: application/json

{
  "nombre": "Kenia Nyeri AA",
  "origen": "Kenia",
  "tueste": "claro",
  "precioEuros": 16.90,
  "stock": 40,
  "notasCata": ["grosella", "tomate", "cítrico"]
}

Actualización parcial con merge-patch, el formato que fijamos en 02-05. Aquí es fácil equivocarse: el Content-Type no es application/json, y nuestra API responde 415 con Accept-Patch si lo pones mal.

PATCH {{urlBase}}/cafes/{{cafeId}}
Authorization: Bearer {{tokenAdmin}}
Content-Type: application/merge-patch+json
If-Match: {{cafeEtag}}

{ "precioEuros": 15.90, "stock": 55 }

Y la creación de pedido, la única —junto al pago— que exige Idempotency-Key:

POST {{urlBase}}/pedidos
Authorization: Bearer {{token}}
Content-Type: application/json
Idempotency-Key: {{claveIdempotencia}}

{
  "clienteId": "cli_842",
  "lineas": [
    { "cafeId": "caf_001", "cantidad": 2 },
    { "cafeId": "caf_002", "cantidad": 1 }
  ]
}

Observa que ni un solo valor está escrito a fuego: {{urlBase}}, {{token}}, {{cafeId}}, {{claveIdempotencia}}. De eso trata el apartado siguiente.

  1. Variables: de colección, de entorno y globales

Postman resuelve {{nombre}} buscando en varios ámbitos, del más específico al más general. Entender esta jerarquía evita el 80 % de los "pero si yo puse la URL bien".

Ámbito Dónde vive Alcance Uso correcto en Tienda Aroma
Local (de ejecución) Solo durante un Runner/Newman La ejecución en curso Datos del fichero CSV
De datos Fichero CSV/JSON del runner La iteración en curso nombreCafe, precio de cada fila
De entorno Fichero de entorno seleccionado Todo lo que corra con ese entorno urlBase, token, claveCliente
De colección Dentro del .json de la colección Toda la colección, en cualquier entorno version: "v1", moneda: "EUR"
Global La instalación de Postman Todo, todas las colecciones Casi nada: evítalas

Regla práctica: si el valor cambia según dónde apuntes, es de entorno; si es igual siempre, es de colección; si crees que es global, casi siempre estás equivocado. Las variables globales son la causa habitual de "en mi máquina funciona": alguien tiene un valor en su instalación que no está en el fichero que compartió.

Variables de colección de "Tienda Aroma v1":

{
  "variable": [
    { "key": "version", "value": "v1" },
    { "key": "clienteEmail", "value": "[email protected]" },
    { "key": "adminEmail", "value": "[email protected]" },
    { "key": "cafeIdSemilla", "value": "caf_001" },
    { "key": "pedidoIdSemilla", "value": "ped_5001" }
  ]
}

Los identificadores sembrados (caf_001, ped_5001) van en la colección porque los garantiza el npm run sembrar de 03-05 en todos los entornos.

  1. Entornos: local, pruebas y producción

Un entorno es un conjunto de valores para las mismas claves. Se cambia con el desplegable de la esquina superior derecha, y toda la colección apunta a otro sitio sin tocar una sola petición.

Variable local pruebas producción
urlBase http://localhost:3000/v1 https://api.pruebas.tiendaaroma.example/v1 https://api.tiendaaroma.example/v1
claveCliente clave-de-prueba-local (secret) (no se define)
token (vacía, la rellena el script) (vacía) (vacía)
permiteEscritura true true false

Fichero de entorno local, exportable y versionable porque no contiene nada sensible:

{
  "name": "Tienda Aroma — local",
  "values": [
    { "key": "urlBase", "value": "http://localhost:3000/v1", "type": "default", "enabled": true },
    { "key": "claveCliente", "value": "clave-de-prueba-local", "type": "default", "enabled": true },
    { "key": "claveAdmin", "value": "clave-admin-local", "type": "default", "enabled": true },
    { "key": "token", "value": "", "type": "secret", "enabled": true },
    { "key": "tokenAdmin", "value": "", "type": "secret", "enabled": true },
    { "key": "permiteEscritura", "value": "true", "type": "default", "enabled": true }
  ]
}

permiteEscritura no es un capricho. Es el freno de mano que evita la peor historia posible con un cliente HTTP: ejecutar la colección entera contra producción y crear cuarenta cafés de prueba en el catálogo real. En el script pre-request de la carpeta de escritura:

// Pre-request de las carpetas "01 Cafés" y "02 Pedidos"
// Aborta cualquier método de escritura si el entorno no lo permite.
const metodo = pm.request.method;
const escribe = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(metodo);
const permitido = pm.environment.get('permiteEscritura') === 'true';

if (escribe && !permitido) {
  throw new Error(
    `Bloqueado: ${metodo} no está permitido en el entorno "${pm.environment.name}".`
  );
}

  1. Secretos: qué nunca se exporta

Postman distingue el tipo de una variable: default (texto plano) o secret (se muestra enmascarada y no se incluye al exportar el entorno ni al compartirlo).

Reglas para Tienda Aroma:

  • La contraseña real de cualquier cuenta, el client_secret de OAuth de 04-03 y cualquier token: siempre secret.
  • Ningún token se escribe a mano. El token es un valor derivado: lo produce el login y lo guarda un script. Si lo pegas a mano, en una hora caduca y vuelves a pegarlo, y así hasta que alguien lo commitea.
  • Las claves del entorno "producción" no se guardan en el fichero: se rellenan en el momento, o mejor aún, no existe un entorno de producción con permisos de escritura en la colección compartida.
  • El fichero exportado se revisa antes de subirlo al repositorio. Un grep rápido evita disgustos:
# Antes de commitear cualquier fichero de Postman
grep -iE '"value": "(eyJ|sk_|ghp_|AKIA)' postman/*.json && echo "¡ALTO! hay un secreto" || echo "limpio"

  1. Autenticación: Bearer Token y herencia de carpeta

Poner Authorization: Bearer {{token}} a mano en treinta peticiones es garantía de que tres se quedarán sin él. La pestaña Authorization existe para eso y funciona por herencia:

  1. En la colección: Auth Type → Bearer Token, Token → {{token}}.
  2. En cada petición: Auth Type → Inherit auth from parent (el valor por defecto).
  3. En la carpeta 00 Sesiones y en el POST /v1/clientes de registro: Auth Type → No Auth, porque son públicas y enviar un token caducado a POST /v1/sesiones es un ruido innecesario.
  4. En las peticiones de administración: Bearer Token → {{tokenAdmin}}, sobrescribiendo la herencia.

La carpeta 99 Errores esperados merece atención: la petición "Crear café sin token → 401" debe estar en No Auth explícito, y la de "Crear café como cliente → 403" en Bearer con {{token}} (el de Marta, rol cliente). Si ambas heredan lo mismo, una de las dos no prueba lo que dice probar.

Postman también soporta el flujo OAuth 2.0 completo de 04-03: en Auth Type → OAuth 2.0 puedes configurar Authorization Code con PKCE, y Postman abre el navegador, hace el intercambio y guarda el token. Es la forma correcta de probar la integración de CataBox, y de comprobar de verdad que un token con solo el ámbito cafes.leer recibe 403 permisos_insuficientes al intentar POST /v1/pedidos.

  1. Scripts pre-request: lo que pasa antes de enviar

Cada petición, carpeta y colección tiene dos scripts en JavaScript: Pre-request (antes de enviar) y Post-response (al recibir; en versiones anteriores de Postman se llamaba "Tests"). Se ejecutan en cascada: primero el de la colección, luego el de la carpeta y por último el de la petición.

El uso más útil del pre-request en nuestra API es renovar el token si ha caducado, para que la colección funcione aunque lleves dos horas sin tocarla. En el script pre-request de la colección:

// Pre-request de la colección "Tienda Aroma v1"
// Si no hay token o está a punto de caducar, hace login antes de continuar.

const ahora = Date.now();
const caduca = Number(pm.environment.get('tokenCaducaEn') || 0);
const margenMs = 60 * 1000; // renovamos un minuto antes: evita el 401 por carrera

if (caduca - margenMs > ahora) {
  return; // el token sigue siendo válido, no hacemos nada
}

pm.sendRequest({
  url: `${pm.environment.get('urlBase')}/sesiones`,
  method: 'POST',
  header: { 'Content-Type': 'application/json' },
  body: {
    mode: 'raw',
    raw: JSON.stringify({
      email: pm.collectionVariables.get('clienteEmail'),
      password: pm.environment.get('claveCliente'),
    }),
  },
}, (error, respuesta) => {
  if (error) {
    throw new Error(`No se pudo renovar el token: ${error}`);
  }
  if (respuesta.code !== 200) {
    throw new Error(`Login fallido (${respuesta.code}): ${respuesta.text()}`);
  }
  const cuerpo = respuesta.json();
  pm.environment.set('token', cuerpo.token);
  // expiraEn viene en segundos (03-06); lo convertimos a marca de tiempo absoluta
  pm.environment.set('tokenCaducaEn', Date.now() + cuerpo.expiraEn * 1000);
  console.log('Token renovado automáticamente.');
});

Puntos que conviene entender de este script:

  • pm.sendRequest es asíncrono con callback. Postman espera a que termine antes de enviar la petición principal, pero cualquier pm.environment.set que hagas fuera del callback se ejecutará antes de tiempo.
  • El margen de un minuto evita la carrera clásica: el token es válido cuando el script comprueba y ha caducado cuando llega al servidor.
  • Lanzar un Error detiene la ejecución con un mensaje claro en lugar de dejarte investigando por qué todo devuelve 401.
  • console.log escribe en la Postman Console (Ctrl/Cmd + Alt + C), que además muestra la petición HTTP exacta que se envió, cabeceras incluidas. Es la herramienta de depuración número uno y casi nadie la abre.

  1. Scripts post-response: las aserciones

Aquí es donde la colección deja de ser documentación y se convierte en una prueba. La API es pm.test(nombre, funcion), y dentro se usa pm.expect, que es Chai.

Post-response de POST /v1/cafes:

// --- Estado y cabeceras --------------------------------------------------
pm.test('Responde 201 Created', () => {
  pm.response.to.have.status(201);
});

pm.test('Devuelve Location apuntando al recurso creado', () => {
  const location = pm.response.headers.get('Location');
  pm.expect(location, 'falta la cabecera Location').to.be.a('string');
  // El contrato de 02-04: /v1/cafes/{id} con id opaco con prefijo caf_
  pm.expect(location).to.match(/^\/v1\/cafes\/caf_[A-Za-z0-9]+$/);
});

pm.test('Devuelve ETag para la concurrencia optimista', () => {
  pm.expect(pm.response.headers.get('ETag')).to.be.a('string');
});

pm.test('El Content-Type es JSON', () => {
  pm.expect(pm.response.headers.get('Content-Type')).to.include('application/json');
});

// --- Cuerpo --------------------------------------------------------------
const cuerpo = pm.response.json();

pm.test('El cuerpo devuelve el recurso creado, no un envoltorio', () => {
  pm.expect(cuerpo).to.have.property('id');
  pm.expect(cuerpo).to.not.have.property('datos'); // eso es para colecciones
});

pm.test('El precio se serializa en euros con dos decimales', () => {
  pm.expect(cuerpo.precioEuros).to.be.a('number');
  // La regla de 02-05: fuera euros, dentro céntimos. Nunca debe salir 1690.
  pm.expect(cuerpo.precioEuros).to.equal(16.90);
});

pm.test('La fecha es ISO-8601 en UTC con Z', () => {
  pm.expect(cuerpo.fechaCreacion).to.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/);
});

pm.test('Incluye _links con self', () => {
  pm.expect(cuerpo._links.self.href).to.equal(`/v1/cafes/${cuerpo.id}`);
});

pm.test('Responde en menos de 500 ms', () => {
  pm.expect(pm.response.responseTime).to.be.below(500);
});

Post-response de GET /v1/cafes, donde lo que se comprueba es la forma de la colección y la paginación de 02-06:

const cuerpo = pm.response.json();

pm.test('Responde 200', () => pm.response.to.have.status(200));

pm.test('La colección tiene la forma {datos, total}', () => {
  pm.expect(cuerpo).to.have.all.keys('datos', 'total');
  pm.expect(cuerpo.datos).to.be.an('array');
  pm.expect(cuerpo.total).to.be.a('number');
});

pm.test('Respeta el límite solicitado', () => {
  const limite = Number(pm.request.url.query.get('limite') || 20);
  pm.expect(cuerpo.datos.length).to.be.at.most(limite);
});

pm.test('El filtro tueste=claro se aplica de verdad', () => {
  // Una aserción de estado sin comprobar el filtro deja pasar el peor bug:
  // que el filtro se ignore silenciosamente y se devuelva todo el catálogo.
  cuerpo.datos.forEach((cafe) => pm.expect(cafe.tueste).to.equal('claro'));
});

pm.test('El orden descendente por precio se respeta', () => {
  const precios = cuerpo.datos.map((c) => c.precioEuros);
  const ordenados = [...precios].sort((a, b) => b - a);
  pm.expect(precios).to.eql(ordenados);
});

pm.test('Expone las cabeceras de límite de peticiones', () => {
  pm.expect(pm.response.headers.has('Aroma-RateLimit-Restantes')).to.be.true;
});

pm.test('Emite Link con rel=next cuando hay más páginas', () => {
  if (cuerpo.total > cuerpo.datos.length) {
    pm.expect(pm.response.headers.get('Link')).to.include('rel="next"');
  }
});

Y el error esperado de la carpeta 99, tan importante como el camino feliz:

// Post-response de "GET Café inexistente → 404"
pm.test('Responde 404', () => pm.response.to.have.status(404));

pm.test('Cumple el formato de error del catálogo', () => {
  const { error } = pm.response.json();
  pm.expect(error.codigo).to.equal('cafe_no_encontrado');
  pm.expect(error.mensaje).to.be.a('string').and.not.empty;
  pm.expect(error.detalles).to.be.an('array');
});

pm.test('No filtra trazaId en un 4xx', () => {
  // Solo los 5xx llevan trazaId (03-07). Si aparece aquí, hay una fuga de convenio.
  pm.expect(pm.response.json().error).to.not.have.property('trazaId');
});

  1. Encadenar peticiones: guardar el id y usarlo después

El encadenado es lo que convierte una lista de peticiones en un recorrido. La técnica es siempre la misma: una petición guarda un valor en una variable, la siguiente lo consume.

En el post-response de POST /v1/cafes:

// Guardamos el id y el ETag para las peticiones siguientes de la carpeta.
if (pm.response.code === 201) {
  const cuerpo = pm.response.json();
  pm.collectionVariables.set('cafeId', cuerpo.id);
  pm.collectionVariables.set('cafeEtag', pm.response.headers.get('ETag'));
  pm.collectionVariables.set('cafeVersion', cuerpo.version);
}

Ahora PATCH {{urlBase}}/cafes/{{cafeId}} con If-Match: {{cafeEtag}} funciona sin tocar nada, y el DELETE posterior también. Detalle importante: tras el PATCH, el ETag cambia (la versión ha subido), así que el post-response del PATCH debe volver a guardarlo, o el DELETE recibirá el 412 precondicion_fallida de 04-06. Es exactamente el mismo error que cometería un cliente real, y descubrirlo aquí es barato.

Este es el recorrido completo que ejecuta la colección:

sequenceDiagram
    participant P as Postman
    participant A as API Tienda Aroma
    P->>A: POST /v1/sesiones con email y clave
    A-->>P: 200 con el token
    Note over P: guarda las variables token y tokenCaducaEn
    P->>A: POST /v1/cafes como administrador
    A-->>P: 201 con Location y ETag
    Note over P: guarda las variables cafeId y cafeEtag
    P->>A: PATCH /v1/cafes/cafeId con If-Match
    A-->>P: 200 con un ETag nuevo
    Note over P: actualiza la variable cafeEtag
    P->>A: POST /v1/pedidos con Idempotency-Key
    A-->>P: 201 con el id del pedido
    Note over P: guarda la variable pedidoId
    P->>A: POST /v1/pedidos/pedidoId/pago
    A-->>P: 200 con estado pagado
    P->>A: DELETE /v1/cafes/cafeId de limpieza
    A-->>P: 204

La última petición es la que casi todo el mundo olvida: limpiar lo creado. Sin ella, cada ejecución deja un café nuevo en la base de datos y la colección deja de ser repetible.

Hay una alternativa al orden fijo que conviene conocer: postman.setNextRequest('nombre de la petición') permite saltar en la ejecución del runner, por ejemplo para omitir el resto de una carpeta si el login falló. Úsalo con moderación: colecciones con saltos por todas partes son ilegibles.

  1. Generar una Idempotency-Key distinta en cada envío

POST /v1/pedidos exige Idempotency-Key (02-03 y 04-01). Si la escribes fija, el segundo envío devolverá la misma respuesta que el primero en lugar de crear un pedido nuevo, que es precisamente lo que la cabecera promete. Y si cambias el cuerpo manteniendo la clave, recibirás 409 clave_idempotencia_reutilizada.

Postman tiene variables dinámicas incorporadas: basta con poner {{$guid}} como valor de la cabecera. Pero conviene generarla en el pre-request para poder reutilizar el mismo valor en la prueba de reintento:

// Pre-request de "POST Crear pedido"
const clave = pm.variables.replaceIn('{{$guid}}'); // UUID v4 generado por Postman
pm.collectionVariables.set('claveIdempotencia', clave);
console.log('Idempotency-Key de esta ejecución:', clave);

Y una segunda petición idéntica, "Crear pedido (reintento)", que no regenera la clave y comprueba el contrato de idempotencia:

// Post-response de "POST Crear pedido (reintento)"
pm.test('El reintento con la misma clave no crea un pedido nuevo', () => {
  pm.response.to.have.status(201); // se repite la respuesta original
  pm.expect(pm.response.json().id).to.equal(pm.collectionVariables.get('pedidoId'));
});

Otras variables dinámicas útiles: {{$timestamp}} (segundos Unix), {{$randomInt}}, {{$isoTimestamp}}, {{$randomEmail}}, {{$randomFullName}}. Sirven para crear clientes de prueba sin colisiones de email.

  1. Aserciones sobre el esquema JSON

Comprobar propiedad a propiedad se vuelve insostenible. Postman incluye AJV, así que puedes validar la respuesta completa contra un JSON Schema:

const esquemaCafe = {
  type: 'object',
  required: ['id', 'nombre', 'origen', 'tueste', 'precioEuros', 'stock', 'fechaCreacion'],
  additionalProperties: true, // toleramos campos nuevos: 02-07, compatibilidad hacia delante
  properties: {
    id: { type: 'string', pattern: '^caf_[A-Za-z0-9]+$' },
    nombre: { type: 'string', minLength: 1, maxLength: 120 },
    origen: { type: 'string' },
    tueste: { type: 'string', enum: ['claro', 'medio', 'oscuro'] },
    precioEuros: { type: 'number', minimum: 0 },
    stock: { type: 'integer', minimum: 0 },
    notasCata: { type: 'array', items: { type: 'string' } },
    fechaCreacion: { type: 'string', format: 'date-time' },
    version: { type: 'integer', minimum: 1 },
  },
};

const esquemaColeccion = {
  type: 'object',
  required: ['datos', 'total'],
  properties: {
    datos: { type: 'array', items: esquemaCafe },
    total: { type: 'integer', minimum: 0 },
  },
};

pm.test('La respuesta cumple el esquema de colección de cafés', () => {
  pm.response.to.have.jsonSchema(esquemaColeccion);
});

additionalProperties: true es deliberado: si mañana añadimos paisTostado al recurso, la colección no debe romperse. Esa es la regla de compatibilidad hacia delante de 02-07 aplicada a las pruebas.

Duplicar estos esquemas a mano es tedioso y se desincroniza. Lo correcto es que salgan del openapi.yaml, y eso es exactamente lo que hará 05-04 con las pruebas de contrato; aquí queda como la solución rápida y suficiente para la exploración.

  1. Ejecutar la colección entera con el Collection Runner

Botón derecho sobre la colección → Run collection. El runner ejecuta todas las peticiones en orden y muestra un informe con las aserciones que pasan y las que no.

Opciones que importan:

  • Iterations: cuántas veces se repite la colección entera (con un fichero de datos, una por fila).
  • Delay: milisegundos entre peticiones. Con el rate limiting de 04-04 activo, un runner sin delay puede comerse la cuota y provocar 429 a mitad de la ejecución. 50-100 ms suele bastar.
  • Keep variable values: si se persisten los valores que escribieron los scripts. Actívalo mientras depuras, desactívalo para comprobar que la colección funciona desde cero.
  • Run manually / Automatically: la ejecución paso a paso es utilísima para depurar un encadenado roto.

  1. Ficheros de datos CSV y JSON

Para probar varios cafés sin duplicar peticiones, el runner acepta un fichero de datos; cada fila es una iteración y sus columnas se convierten en variables.

postman/datos-cafes.csv:

nombre,origen,tueste,precioEuros,stock,esperado
Kenia Nyeri AA,Kenia,claro,16.90,40,201
Brasil Cerrado,Brasil,oscuro,9.50,200,201
Café sin origen,,medio,11.00,10,400
Precio negativo,Perú,medio,-3.00,10,400
Tueste inválido,Perú,morado,11.00,10,400

El cuerpo de la petición usa las columnas como variables, y observa el detalle de las comillas: {{precioEuros}} va sin comillas porque es un número, y {{nombre}} con ellas porque es texto.

{
  "nombre": "{{nombre}}",
  "origen": "{{origen}}",
  "tueste": "{{tueste}}",
  "precioEuros": {{precioEuros}},
  "stock": {{stock}}
}

La columna esperado permite que una sola petición valide casos válidos e inválidos:

const esperado = Number(pm.iterationData.get('esperado'));

pm.test(`Responde ${esperado} para "${pm.iterationData.get('nombre')}"`, () => {
  pm.response.to.have.status(esperado);
});

if (esperado === 400) {
  pm.test('El 400 usa el código datos_invalidos con detalles', () => {
    const { error } = pm.response.json();
    pm.expect(error.codigo).to.equal('datos_invalidos');
    pm.expect(error.detalles).to.be.an('array').that.is.not.empty;
    pm.expect(error.detalles[0]).to.have.property('campo');
  });
}

Esto prueba de golpe la validación con Zod de 03-04 y el formato de error de 03-07, con cinco líneas de CSV en lugar de cinco peticiones.

  1. Newman: la colección desde la terminal

Newman es el ejecutor de colecciones por línea de comandos. Es la pieza que hace que todo lo anterior sirva para algo más que para ti.

Exporta primero los ficheros (botón derecho → Export, formato Collection v2.1) a la carpeta postman/ del repositorio:

tienda-aroma-api/
└── postman/
    ├── tienda-aroma-v1.postman_collection.json
    ├── local.postman_environment.json
    ├── pruebas.postman_environment.json
    └── datos-cafes.csv

Y ejecuta:

# Ejecución básica con el entorno local
npx newman run postman/tienda-aroma-v1.postman_collection.json \
  -e postman/local.postman_environment.json

# Con fichero de datos, delay para no chocar con el rate limiting,
# y un informe HTML además del resumen en pantalla
npx newman run postman/tienda-aroma-v1.postman_collection.json \
  -e postman/pruebas.postman_environment.json \
  -d postman/datos-cafes.csv \
  --delay-request 100 \
  --reporters cli,junit,htmlextra \
  --reporter-junit-export informes/newman.xml \
  --reporter-htmlextra-export informes/newman.html \
  --bail

# Solo una carpeta: útil para un smoke test rápido tras desplegar
npx newman run postman/tienda-aroma-v1.postman_collection.json \
  -e postman/pruebas.postman_environment.json \
  --folder "99 Errores esperados"
Opción Para qué sirve
-e Fichero de entorno
-d Fichero de datos CSV/JSON
--folder Ejecuta solo una carpeta
--env-var clave=valor Inyecta una variable sin escribirla en el fichero: así entran los secretos en CI
--delay-request Pausa entre peticiones
--bail Se detiene en el primer fallo
--reporters Formatos de informe; junit es el que entienden los sistemas de CI
--insecure Acepta certificados autofirmados (solo para preproducción interna)

El punto clave para 05-05: Newman devuelve un código de salida distinto de cero si alguna aserción falla. Eso es todo lo que necesita una tubería de CI para bloquear un despliegue.

Añádelo como script en package.json:

{
  "scripts": {
    "pruebas:api": "newman run postman/tienda-aroma-v1.postman_collection.json -e postman/local.postman_environment.json --delay-request 50",
    "pruebas:humo": "newman run postman/tienda-aroma-v1.postman_collection.json -e postman/pruebas.postman_environment.json --folder \"00 Sesiones\" --bail"
  }
}

Y newman con newman-reporter-htmlextra van a devDependencies.

  1. Importar openapi.yaml y exportar la colección

No hace falta crear las peticiones a mano si ya tienes el contrato. Import → File → openapi.yaml genera una colección completa con todas las rutas, parámetros, cuerpos de ejemplo y carpetas por tag.

Ventajas e inconvenientes, porque no es magia:

  • A favor: cobertura instantánea de todos los endpoints, cuerpos prerrellenados con los example de la especificación, y una comprobación indirecta de que el openapi.yaml describe lo que crees.
  • En contra: no trae scripts, ni encadenado, ni aserciones —lo que da valor a la colección—, y reimportar sobrevive mal: Postman crea una colección nueva en lugar de fusionar, y pierdes tus scripts.

Estrategia práctica: importa una vez para tener el esqueleto, añade encima scripts y encadenado, y a partir de ahí mantén la colección a mano. Cuando el contrato añada un endpoint nuevo, se importa a una colección temporal y se copia la petición que falte. La sincronización de verdad entre contrato e implementación no se resuelve aquí: se resuelve con las pruebas de contrato de 05-04.

  1. Documentación y compartición con el equipo

Postman genera documentación navegable a partir de la colección: descripciones en Markdown de cada petición y carpeta, parámetros, y ejemplos de código en curl, JavaScript, Python o Go generados automáticamente.

Dos hábitos que multiplican su valor:

  1. Guardar ejemplos de respuesta. En cada petición, botón "Save as Example" tras una respuesta buena. Guarda al menos el caso de éxito y uno de error por endpoint. Los ejemplos son lo que se ve en la documentación y, además, lo que sirve al servidor mock del apartado siguiente.
  2. Escribir la descripción de cada carpeta explicando qué rol hace falta, qué precondiciones tiene y qué errores esperar. Es documentación que vive donde se usa.

Formas de compartir, de menos a más comprometida: exportar el JSON y meterlo en el repositorio (la que recomendamos, porque se revisa en pull requests y no depende de cuentas); publicar la documentación como enlace público; o usar los workspaces de equipo con sincronización en la nube, que son cómodos pero implican que el contenido de tus colecciones vive en un servidor de terceros —revísalo con seguridad antes de meter ahí una API interna—.

Para consumidores externos como CataBox, la documentación de Postman es una opción, pero el destino natural es el portal de desarrollador de 05-06, alimentado por el openapi.yaml de 05-02.

  1. El servidor mock de Postman

Postman puede levantar una URL pública que responde con los ejemplos guardados de tu colección. Sirve para que el equipo de la SPA empiece a maquetar la pantalla del catálogo antes de que exista el endpoint.

https://a1b2c3d4-1111-2222-3333-444455556666.mock.pstmn.io/v1/cafes

Se crea en tres clics y tiene dos límites importantes: responde ejemplos fijos, sin lógica —el filtro ?tueste=claro no filtra nada—, y depende de la nube de Postman.

Es una de varias opciones, y no la mejor si ya tienes contrato: Prism genera el mock directamente desde openapi.yaml, sin ejemplos que mantener aparte. Lo veremos a fondo en 05-04, junto con msw y nock.

Errores Comunes y Consejos

  • Escribir la URL completa en cada petición. El día que aparece un entorno de preproducción hay que editar cuarenta peticiones. {{urlBase}} desde la primera, siempre.
  • Pegar el token a mano. Caduca en una hora y acabas con tokens en la colección exportada, es decir, en el repositorio. El token se obtiene con el login y se guarda con un script.
  • Exportar el entorno con secretos dentro. Marca las variables sensibles como secret, revisa el fichero antes de commitear y añade postman/*.local.json al .gitignore.
  • Aserciones que solo comprueban el estado. pm.response.to.have.status(200) pasa aunque la API devuelva un array vacío porque el filtro se ignoró. Comprueba también la forma y el contenido.
  • Olvidar la limpieza. Cada ejecución deja datos. Termina cada carpeta con el DELETE de lo que creó, o siembra la base de datos antes con npm run bd:reiniciar.
  • Confundir variables de entorno con variables de colección. Si urlBase está en la colección, el desplegable de entornos no hace nada y acabas apuntando siempre al mismo sitio.
  • Ejecutar el runner sin delay contra un entorno con rate limiting. Fallos aleatorios 429 que parecen bugs de la API y no lo son. --delay-request 100, o una cuota específica para el cliente de CI (04-04).
  • Content-Type: application/json en el PATCH. Nuestra API responde 415 formato_no_soportado con Accept-Patch. El valor correcto es application/merge-patch+json.
  • No abrir la Postman Console. Ahí se ve la petición literal enviada, con las variables ya sustituidas. La mayoría de los "no entiendo qué pasa" se resuelven en diez segundos mirándola.
  • Consejo: nombra las peticiones por lo que prueban, no por el método. "Crear café como cliente → 403" dice mucho más que "POST cafes 2".
  • Consejo: mete la colección en el mismo repositorio que la API. Así el pull request que cambia un endpoint cambia también su petición, y la revisión detecta las incoherencias.

Ejercicios

Ejercicio 1: la carpeta de errores esperados

Construye la carpeta 99 Errores esperados de la colección "Tienda Aroma v1" con cinco peticiones que verifiquen el catálogo de 02-04, y escribe sus aserciones. Los casos: café inexistente (404 cafe_no_encontrado), parámetro de consulta inválido (400 parametro_invalido), creación sin token (401 no_autenticado con WWW-Authenticate), creación con rol cliente (403 permisos_insuficientes) y PUT /v1/cafes (405 metodo_no_permitido con Allow).

Indica para cada una qué configuración de Authorization necesita y escribe el script post-response de al menos tres de ellas.

Ejercicio 2: recorrido de compra encadenado

Crea una carpeta 10 Recorrido de compra que ejecute, en orden y sin intervención manual: login como Marta → listar cafés (guardando el id del primero con stock disponible) → crear pedido con Idempotency-Key generada → pagar el pedido → consultar el pedido y comprobar que su estado es pagado.

Escribe los scripts necesarios y explica qué variables se pasan entre peticiones y en qué ámbito las guardarías.

Ejercicio 3: Newman con datos y puerta de calidad

Prepara la ejecución de la colección en la terminal para que sirva como puerta de calidad antes de un despliegue: un fichero de datos con al menos dos casos válidos y dos inválidos de creación de café, el comando de Newman que la ejecuta contra el entorno de pruebas inyectando la contraseña sin escribirla en ningún fichero, y el script de package.json. Explica cómo sabrá el sistema de CI si debe bloquear el despliegue.

Soluciones

Solución 1

Configuración de autenticación por petición:

Petición Authorization Motivo
Café inexistente → 404 Inherit ({{token}}) Hace falta estar autenticado para llegar al 404 y no quedarse en el 401
Parámetro inválido → 400 Inherit ({{token}}) Igual: la validación ocurre después de autenticar
Sin token → 401 No Auth explícito Si hereda el Bearer, la prueba no prueba nada
Como cliente → 403 Bearer {{token}} (rol cliente) Debe estar autenticado pero sin permiso
PUT /v1/cafes → 405 Inherit El router.all responde antes de la lógica

Petición 3 — POST {{urlBase}}/cafes sin token:

pm.test('Responde 401', () => pm.response.to.have.status(401));

pm.test('Incluye WWW-Authenticate', () => {
  const cabecera = pm.response.headers.get('WWW-Authenticate');
  pm.expect(cabecera).to.include('Bearer');
});

pm.test('El código es no_autenticado', () => {
  pm.expect(pm.response.json().error.codigo).to.equal('no_autenticado');
});

pm.test('El mensaje no revela si el recurso existe', () => {
  // 04-02: un 401 no debe filtrar información sobre el estado del sistema
  pm.expect(pm.response.json().error.mensaje.toLowerCase()).to.not.include('café');
});

Petición 4 — POST {{urlBase}}/cafes con el token de Marta (rol cliente):

pm.test('Responde 403, no 401', () => {
  // Distinción clave de 02-04: 401 es "no sé quién eres", 403 es "sé quién eres y no puedes"
  pm.response.to.have.status(403);
});

pm.test('El código es permisos_insuficientes', () => {
  pm.expect(pm.response.json().error.codigo).to.equal('permisos_insuficientes');
});

pm.test('No se ha creado nada', () => {
  pm.expect(pm.response.headers.has('Location')).to.be.false;
});

Petición 5 — PUT {{urlBase}}/cafes:

pm.test('Responde 405', () => pm.response.to.have.status(405));

pm.test('Declara los métodos permitidos en Allow', () => {
  const allow = pm.response.headers.get('Allow');
  pm.expect(allow, 'falta la cabecera Allow, obligatoria en un 405').to.be.a('string');
  ['GET', 'POST', 'HEAD', 'OPTIONS'].forEach((metodo) => {
    pm.expect(allow).to.include(metodo);
  });
  pm.expect(allow).to.not.include('PUT');
});

pm.test('El código es metodo_no_permitido', () => {
  pm.expect(pm.response.json().error.codigo).to.equal('metodo_no_permitido');
});

Solución 2

Ámbitos elegidos: todas las variables del recorrido van a pm.collectionVariables, no a pm.environment. Motivo: son valores efímeros de una ejecución concreta y no deben ensuciar el fichero de entorno que se comparte. El token es la excepción: va al entorno porque lo comparten todas las carpetas y lo gestiona el pre-request de la colección.

Petición 1 — POST {{urlBase}}/sesiones (No Auth). Post-response:

pm.test('Login correcto', () => pm.response.to.have.status(200));
const cuerpo = pm.response.json();
pm.environment.set('token', cuerpo.token);
pm.environment.set('tokenCaducaEn', Date.now() + cuerpo.expiraEn * 1000);
pm.collectionVariables.set('clienteId', cuerpo.cliente.id);

pm.test('El rol es cliente', () => pm.expect(cuerpo.cliente.rol).to.equal('cliente'));

Petición 2 — GET {{urlBase}}/cafes?disponible=true&limite=5:

const cuerpo = pm.response.json();
pm.test('Hay al menos un café disponible', () => {
  pm.expect(cuerpo.datos.length).to.be.above(0);
});

const cafe = cuerpo.datos.find((c) => c.stock >= 2);
if (!cafe) {
  throw new Error('No hay ningún café con stock suficiente: siembra la base de datos.');
}
pm.collectionVariables.set('cafeId', cafe.id);
pm.collectionVariables.set('cafePrecio', cafe.precioEuros);

Petición 3 — POST {{urlBase}}/pedidos. Pre-request:

pm.collectionVariables.set('claveIdempotencia', pm.variables.replaceIn('{{$guid}}'));

Cuerpo y post-response:

{ "clienteId": "{{clienteId}}", "lineas": [{ "cafeId": "{{cafeId}}", "cantidad": 2 }] }
pm.test('Pedido creado', () => pm.response.to.have.status(201));
const pedido = pm.response.json();
pm.collectionVariables.set('pedidoId', pedido.id);

pm.test('El estado inicial es pendiente_pago', () => {
  pm.expect(pedido.estado).to.equal('pendiente_pago');
});

pm.test('El total coincide con precio × cantidad', () => {
  // Comprueba de paso la conversión céntimos→euros del mapeador de 03-03
  const esperado = Number((pm.collectionVariables.get('cafePrecio') * 2).toFixed(2));
  pm.expect(pedido.totalEuros).to.equal(esperado);
});

pm.test('Location apunta al pedido creado', () => {
  pm.expect(pm.response.headers.get('Location')).to.equal(`/v1/pedidos/${pedido.id}`);
});

Petición 4 — POST {{urlBase}}/pedidos/{{pedidoId}}/pago con Idempotency-Key: {{$guid}}:

pm.test('Pago aceptado', () => pm.response.to.have.status(200));
pm.test('El pedido pasa a pagado', () => {
  pm.expect(pm.response.json().estado).to.equal('pagado');
});

Petición 5 — GET {{urlBase}}/pedidos/{{pedidoId}}:

pm.test('El estado persistido es pagado', () => {
  pm.expect(pm.response.json().estado).to.equal('pagado');
});

pm.test('El stock del café ha bajado', () => {
  pm.sendRequest({
    url: `${pm.environment.get('urlBase')}/cafes/${pm.collectionVariables.get('cafeId')}`,
    method: 'GET',
    header: { Authorization: `Bearer ${pm.environment.get('token')}` },
  }, (err, res) => {
    pm.expect(res.json().stock).to.be.at.most(120 - 2);
  });
});

Variables que viajan: token (entorno) → todas; clienteId y cafeId (colección) → petición 3; pedidoId (colección) → peticiones 4 y 5; claveIdempotencia (colección) → petición 3.

Solución 3

Fichero postman/datos-cafes.csv:

nombre,origen,tueste,precioEuros,stock,esperado,codigoError
Kenia Nyeri AA,Kenia,claro,16.90,40,201,
Brasil Cerrado,Brasil,oscuro,9.50,200,201,
Sin nombre,,medio,11.00,10,400,datos_invalidos
Tueste inválido,Perú,morado,11.00,10,400,datos_invalidos

Post-response que cubre ambos casos:

const esperado = Number(pm.iterationData.get('esperado'));
const codigoError = pm.iterationData.get('codigoError');

pm.test(`"${pm.iterationData.get('nombre')}" responde ${esperado}`, () => {
  pm.response.to.have.status(esperado);
});

if (esperado === 201) {
  // Guardamos el id para poder limpiar al final del recorrido
  const creados = JSON.parse(pm.collectionVariables.get('cafesCreados') || '[]');
  creados.push(pm.response.json().id);
  pm.collectionVariables.set('cafesCreados', JSON.stringify(creados));
} else {
  pm.test(`El código de error es ${codigoError}`, () => {
    pm.expect(pm.response.json().error.codigo).to.equal(codigoError);
  });
}

Comando con el secreto inyectado desde fuera:

npx newman run postman/tienda-aroma-v1.postman_collection.json \
  -e postman/pruebas.postman_environment.json \
  -d postman/datos-cafes.csv \
  --env-var "claveCliente=$CLAVE_CLIENTE_PRUEBAS" \
  --env-var "claveAdmin=$CLAVE_ADMIN_PRUEBAS" \
  --delay-request 100 \
  --reporters cli,junit \
  --reporter-junit-export informes/newman.xml

--env-var sobrescribe el valor del fichero de entorno. Así el fichero versionado puede tener claveCliente vacía y el valor real llega de una variable de entorno que en CI proviene del gestor de secretos (05-05), nunca del repositorio.

Script en package.json:

{
  "scripts": {
    "pruebas:api:pruebas": "newman run postman/tienda-aroma-v1.postman_collection.json -e postman/pruebas.postman_environment.json -d postman/datos-cafes.csv --delay-request 100 --reporters cli,junit --reporter-junit-export informes/newman.xml"
  }
}

Cómo bloquea el despliegue. Newman sale con código 0 si todas las aserciones pasan y con un código distinto de cero si alguna falla o hay un error de red. Cualquier sistema de CI interpreta un código de salida distinto de cero como paso fallido y detiene la tubería. Además, el informe junit permite que la interfaz muestre exactamente qué aserción falló, sin tener que leer el log. Si se quiere que el fallo sea inmediato en lugar de ejecutar toda la colección, se añade --bail.

Conclusión

Has convertido la exploración manual en un artefacto. La colección "Tienda Aroma v1" ya no es una lista de URLs: tiene carpetas por recurso que reflejan el contrato de 02-02, entornos que permiten apuntar a local, pruebas o producción sin editar una sola petición, un freno de mano que impide escribir en producción por accidente, y un pre-request que renueva el token del login de 03-06 antes de que caduque, sin que nadie pegue credenciales a mano.

Sobre esa base has puesto lo que la convierte en una prueba: aserciones que verifican el 201 y su Location, la forma {datos, total} de las colecciones, que los filtros de 02-06 filtren de verdad, que el precio salga en euros y no en céntimos, que los errores del catálogo de 02-04 lleguen con su código exacto y sin trazaId en los 4xx, y esquemas JSON validados con AJV que toleran campos nuevos. Has encadenado un recorrido completo de compra pasando el id y el ETag de una petición a la siguiente, has generado una Idempotency-Key por ejecución para probar el reintento de 02-03, y has multiplicado los casos con un fichero CSV en el que cada fila declara el código que espera. Y con Newman todo eso se ejecuta desde la terminal, con un código de salida que basta para bloquear un despliegue: los ficheros nuevos del proyecto son postman/tienda-aroma-v1.postman_collection.json, postman/local.postman_environment.json, postman/pruebas.postman_environment.json y postman/datos-cafes.csv, con newman en devDependencies y los scripts pruebas:api y pruebas:humo en package.json.

Queda un cabo suelto y es el importante: la colección es una segunda descripción de la API, escrita a mano, que puede desviarse de la real sin que nadie se entere. Ya tenemos una descripción mejor —el openapi.yaml que arrastramos desde 02-08— pero está a medias: un solo endpoint, sin esquemas completos, sin seguridad declarada y sin publicar. En 05-02, Swagger y OpenAPI para documentación, lo terminamos: la anatomía completa del documento sección a sección, components con esquemas, parámetros y respuestas reutilizables, los securitySchemes con los ámbitos OAuth de 04-03, la diferencia entre escribirlo a mano y generarlo desde el código, Swagger UI servido en /docs dentro del propio proyecto, la validación con swagger-cli y las reglas de Spectral de 04-01, y la generación de clientes TypeScript para la SPA y para Aroma Móvil a partir del contrato.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados