Una petición guardada en el equipo de una persona sirve para depurar. Una colección compartida, revisable y ejecutable puede convertirse en parte del proyecto: explica cómo llamar a la API y comprueba que ciertos comportamientos siguen funcionando cuando cambia el código.
Bruno encaja especialmente bien en ese segundo escenario. Guarda las colecciones como archivos locales que puedes revisar con git diff, incluir en una pull request y ejecutar desde una CLI. La decisión interesante no es qué cliente tiene el botón de enviar más cómodo, sino cómo mantienes las peticiones y sus pruebas junto al software que estás construyendo.
Vamos a preparar una API REST de tareas en Node.js y TypeScript, probarla con una colección y ejecutar esa misma colección desde la terminal. El enfoque complementa el artículo sobre HTTPie para consultas desde la terminal: aquí nos concentraremos en archivos compartidos, entornos y comprobaciones repetibles.
Qué aporta Bruno al trabajo con APIs
Bruno es un cliente para construir y enviar peticiones, inspeccionar respuestas y automatizar comprobaciones. Su modelo de archivos permite trabajar con colecciones sin depender de sincronizarlas mediante una cuenta del fabricante. Esto no significa que las peticiones no utilicen la red: se comunican con el servidor que configures.
Hay dos partes que usaremos: la aplicación de escritorio para explorar y editar, y bru, su CLI, para ejecutar las peticiones guardadas. Compartir los archivos permite que ambas trabajen con la misma colección.
Al revisar este tutorial, la versión publicada es Bruno 4.3.0. Desde Bruno 4, el formato predeterminado para nuevas colecciones es OpenCollection YAML. El formato histórico .bru sigue disponible; no conviene mezclar ejemplos de ambos sin identificar cuál se está utilizando. La documentación de OpenCollection explica su organización y su relación con OpenAPI.
OpenAPI describe el contrato de una API. Una colección guarda llamadas concretas, variables y comprobaciones para ejercitarla. Pueden coexistir en el mismo repositorio.
Bruno, Postman, Insomnia y HTTPie: diferencias útiles
| Herramienta | Enfoque que conviene evaluar | Pregunta para tu equipo |
|---|---|---|
| Bruno | Colecciones locales en archivos y ejecución con su CLI | ¿Queremos revisar las peticiones junto al código en Git? |
| Postman | Plataforma de APIs con colecciones, pruebas y colaboración | ¿Necesitamos sus flujos de trabajo e integraciones compartidas? |
| Insomnia | Cliente de APIs con varias opciones de almacenamiento, incluidas locales y Git Sync | ¿Qué modo de almacenamiento y colaboración necesitamos? |
| HTTPie CLI | Peticiones expresivas desde la terminal y uso en scripts | ¿Basta una consulta rápida o necesitamos una colección mantenida por el equipo? |
Todas permiten hacer más que enviar un GET. Postman documenta pruebas y ejecución en CI; Insomnia ofrece Local Vault, Cloud Sync y Git Sync. Por eso, afirmar que las alternativas siempre necesitan la nube o que solo Bruno puede trabajar con Git sería incorrecto.
HTTPie CLI sigue siendo una opción cómoda para explorar una respuesta sin abrir un cliente gráfico. Elegir Bruno tiene más sentido cuando quieres conservar una secuencia de peticiones y sus expectativas como un artefacto compartido. Revisa también las funciones incluidas en cada edición: Bruno tiene una base abierta y planes comerciales; este tutorial utiliza Git desde la terminal.
Instalar la aplicación y preparar la CLI
En macOS, con Homebrew:
brew install brunoEn Windows, con winget:
winget install Bruno.BrunoEn Linux puedes instalarlo con Flatpak:
flatpak install flathub com.usebruno.BrunoTambién hay instaladores y paquetes para los tres sistemas. Selecciona el correspondiente a tu arquitectura en las instrucciones oficiales de instalación. Los pasos de instalación gráfica dependen del sistema; el laboratorio de este artículo se verificó mediante la CLI.
Para la API necesitas Git, pnpm y Node.js 22 actualizado o posterior con soporte de eliminación de tipos. Usaremos .mts y TypeScript nativo en Node.js; ejecutar esos archivos no realiza una comprobación estática de tipos.
Crea una carpeta independiente del resto de tus proyectos:
mkdir bruno-api-lab
cd bruno-api-lab
pnpm init
pnpm add -D -E @usebruno/cli@4.3.0
pnpm exec bru --version
pnpm exec bru run --helpInstalar una versión concreta y guardar el lockfile evita que dos personas ejecuten herramientas diferentes por accidente. La instalación de la CLI es independiente de la aplicación de escritorio.
Si tu versión de pnpm exige revisar scripts de instalación, inspecciona los paquetes indicados antes de aprobarlos. Este ejemplo funciona sin habilitar los scripts opcionales de protobufjs; con pnpm 11 se puede registrar esa decisión en pnpm-workspace.yaml:
ignoredBuiltDependencies:
- protobufjsSi la instalación se detuvo por esa comprobación, vuelve a ejecutar pnpm install después de guardar la configuración.
Una API local pequeña para practicar
La API mantiene tareas en memoria. Todas las operaciones sobre ellas requieren el mismo token ficticio; GET /health permite comprobar que el proceso arrancó. Usamos el servidor HTTP de Node.js para evitar dependencias de un framework.
Guarda este archivo como server.mts:
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
type Task = { id: string; title: string; done: boolean };
const tasks = new Map<string, Task>();
const token = process.env.LAB_TOKEN;
if (!token) throw new Error('Configura LAB_TOKEN antes de iniciar');
function send(res: ServerResponse, status: number, body?: unknown) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(body === undefined ? undefined : JSON.stringify(body));
}
async function input(req: IncomingMessage) {
if (!req.headers['content-type']?.startsWith('application/json')) {
throw new Error('INVALID_BODY');
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 4096) throw new Error('INVALID_BODY');
chunks.push(chunk);
}
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('INVALID_BODY');
}
const body = value as Record<string, unknown>;
if (Object.keys(body).sort().join(',') !== 'done,title'
|| typeof body.title !== 'string'
|| body.title.trim().length === 0
|| body.title.length > 120
|| typeof body.done !== 'boolean') {
throw new Error('INVALID_BODY');
}
return { title: body.title.trim(), done: body.done };
}
const server = createServer(async (req, res) => {
try {
if (req.url === '/health' && req.method === 'GET') {
return send(res, 200, { status: 'ok' });
}
if (req.headers.authorization !== `Bearer ${token}`) {
res.setHeader('WWW-Authenticate', 'Bearer');
return send(res, 401, { error: 'UNAUTHORIZED' });
}
const path = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
if (path === '/tasks' && req.method === 'GET') {
return send(res, 200, [...tasks.values()]);
}
if (path === '/tasks' && req.method === 'POST') {
const task = { id: randomUUID(), ...await input(req) };
tasks.set(task.id, task);
return send(res, 201, task);
}
const id = /^\/tasks\/([a-f0-9-]{36})$/.exec(path)?.[1];
if (!id || !tasks.has(id)) {
return send(res, 404, { error: 'NOT_FOUND' });
}
if (req.method === 'GET') return send(res, 200, tasks.get(id));
if (req.method === 'PUT') {
const task = { id, ...await input(req) };
tasks.set(id, task);
return send(res, 200, task);
}
if (req.method === 'DELETE') {
tasks.delete(id);
return send(res, 204);
}
send(res, 405, { error: 'METHOD_NOT_ALLOWED' });
} catch (error) {
const invalid = error instanceof SyntaxError
|| (error instanceof Error && error.message === 'INVALID_BODY');
send(res, invalid ? 400 : 500, { error: invalid ? 'INVALID_BODY' : 'INTERNAL_ERROR' });
}
});
server.listen(3037, '127.0.0.1', () => console.log('API local en http://127.0.0.1:3037'));El cuerpo de POST y PUT debe incluir exactamente title y done; el servidor comprueba sus valores en runtime y limita el tamaño leído. PUT reemplaza los campos modificables de una tarea existente. La API devuelve 401 si falta el token, 400 para un cuerpo inválido y 404 para recursos ausentes.
Este servidor es un soporte de aprendizaje: no tiene usuarios, autorización por objeto, persistencia ni controles completos de producción. Una credencial estática de laboratorio no sustituye un diseño de autenticación real. Para ese contexto, consulta los controles esenciales de una API REST.
Inícialo en una terminal:
export LAB_TOKEN=solo-laboratorio-local
node --experimental-strip-types server.mtsEn PowerShell, asigna primero $env:LAB_TOKEN = "solo-laboratorio-local" y ejecuta el mismo comando de Node. El valor es público y ficticio, válido únicamente para este ejercicio. El servidor escucha en 127.0.0.1:3037 y pierde sus datos al reiniciarse.
Crear y abrir una colección dentro del proyecto
En Bruno, utiliza Create Collection, asigna un nombre como «Tareas locales», selecciona YAML y elige bruno-api-lab/api como ubicación. La guía de creación de colecciones también explica cómo abrir una carpeta existente mediante Open Collection.
Puedes crear los archivos siguientes directamente y abrir después api. Obtendrás esta estructura:
bruno-api-lab/
├── server.mts
├── package.json
├── pnpm-lock.yaml
├── .gitignore
└── api/
├── opencollection.yml
├── .env.example
├── .env
├── environments/
│ └── local.yml
├── 01-listar.yml
├── 02-crear.yml
├── 03-reemplazar.yml
├── 04-eliminar.yml
└── 05-comprobar.ymlEn una API mayor puedes crear carpetas por recurso, como usuarios o pedidos, usando Create Folder. Bruno puede guardar su configuración en folder.yml. Para estas cinco peticiones, una sola carpeta es suficiente.
Guarda api/opencollection.yml:
opencollection: 1.0.0
info:
name: Tareas locales
request:
auth:
type: bearer
token: "{{token}}"
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .gitLa autenticación queda en la colección. Cada petición utilizará auth: inherit para heredarla. En la interfaz, esto corresponde a configurar Bearer Token en Auth y usar la variable {{token}}, sin añadir de nuevo el prefijo Bearer dentro de su valor. Bruno construye la cabecera Authorization según su configuración de Bearer.
Entornos y variables sin guardar credenciales en Git
Un entorno agrupa valores que cambian según el destino. Por ejemplo, baseUrl puede apuntar a tu equipo o a un entorno de pruebas. Guarda api/environments/local.yml:
name: local
variables:
- name: baseUrl
value: http://127.0.0.1:3037
- name: token
value: "{{process.env.LAB_TOKEN}}"{{baseUrl}} y {{token}} son interpolaciones de variables de Bruno. La segunda obtiene su valor del entorno del proceso o del .env de la colección, según la documentación de dotenv.
Guarda api/.env.example con el valor ficticio:
LAB_TOKEN=solo-laboratorio-localDuplica ese archivo como api/.env y selecciona el entorno local en Bruno. En macOS o Linux:
cp api/.env.example api/.envEn PowerShell puedes usar Copy-Item api/.env.example api/.env.
Un segundo entorno se crea con otro archivo bajo environments/, cambiando su nombre y baseUrl. No necesitas copiar las peticiones. Antes de ejecutar una colección con POST, PUT o DELETE, confirma que el destino sea el laboratorio: cambiar de entorno también cambia dónde se producen sus efectos.
Para credenciales reales, utiliza valores personales o credenciales temporales del entorno de pruebas. Un .env es texto legible y debe quedar excluido de Git; tampoco debe aparecer en capturas, logs ni reportes. Las variables marcadas como secretas tienen su propio comportamiento documentado, que conviene verificar al exportar y automatizar. El almacenamiento local por sí solo no protege una credencial comprometida. Profundizamos en ello en variables de entorno y gestores de secretos.
GET y POST: guardar peticiones que comprueban algo
Crea api/01-listar.yml:
info:
name: Listar tareas
type: http
seq: 1
http:
method: GET
url: "{{baseUrl}}/tasks"
auth: inherit
runtime:
scripts:
- type: tests
code: |
test("Respuesta esperada", () => {
expect(res.status).to.equal(200);
expect(res.body).to.be.an("array");
});
settings:
timeout: 5000info.seq establece la posición de la petición. http contiene los datos enviados; runtime.scripts incorpora comprobaciones JavaScript. El test verifica tanto el estado HTTP como que el cuerpo sea una lista.
En la interfaz puedes crear la petición con Create Request, elegir GET, escribir {{baseUrl}}/tasks y pulsar Send. Inspecciona el estado, las cabeceras y el cuerpo; la respuesta inicial será una lista vacía si acabas de arrancar la API. Las pruebas se editan en Tests y se ejecutan al enviar la petición.
Ahora guarda api/02-crear.yml:
info:
name: Crear tarea
type: http
seq: 2
http:
method: POST
url: "{{baseUrl}}/tasks"
auth: inherit
headers:
- name: Content-Type
value: application/json
body:
type: json
data: |
{"title":"Revisar contrato de la API","done":false}
runtime:
scripts:
- type: tests
code: |
test("Respuesta esperada", () => {
expect(res.status).to.equal(201);
expect(res.body.id).to.be.a("string");
expect(res.body.done).to.equal(false);
bru.setVar("taskId", res.body.id);
});
settings:
timeout: 5000El POST envía JSON y espera un 201, un identificador y done: false. bru.setVar conserva el identificador como variable de ejecución para las siguientes peticiones. No lo escribimos como valor fijo en un archivo: cada ejecución puede crear una tarea distinta.
Las pruebas de Bruno permiten definir expectativas sobre la respuesta. Un 200 o 201 por sí solo no demuestra que el contenido sea correcto. En una API real conviene comprobar el contrato y los campos relevantes, evitando expectativas frágiles sobre tiempos exactos o valores aleatorios.
PUT y DELETE: completar el ciclo de vida
Guarda api/03-reemplazar.yml:
info:
name: Reemplazar tarea
type: http
seq: 3
http:
method: PUT
url: "{{baseUrl}}/tasks/{{taskId}}"
auth: inherit
headers:
- name: Content-Type
value: application/json
body:
type: json
data: |
{"title":"Contrato revisado","done":true}
runtime:
scripts:
- type: tests
code: |
test("Respuesta esperada", () => {
expect(res.status).to.equal(200);
expect(res.body.title).to.equal("Contrato revisado");
expect(res.body.done).to.equal(true);
});
settings:
timeout: 5000Guarda api/04-eliminar.yml:
info:
name: Eliminar tarea
type: http
seq: 4
http:
method: DELETE
url: "{{baseUrl}}/tasks/{{taskId}}"
auth: inherit
runtime:
scripts:
- type: tests
code: |
test("Respuesta esperada", () => {
expect(res.status).to.equal(204);
});
settings:
timeout: 5000Finalmente, api/05-comprobar.yml:
info:
name: Comprobar eliminación
type: http
seq: 5
http:
method: GET
url: "{{baseUrl}}/tasks/{{taskId}}"
auth: inherit
runtime:
scripts:
- type: tests
code: |
test("Respuesta esperada", () => {
expect(res.status).to.equal(404);
expect(res.body.error).to.equal("NOT_FOUND");
});
settings:
timeout: 5000La secuencia es deliberada: crear produce taskId, PUT lo utiliza, DELETE elimina el recurso y el GET final comprueba su ausencia. El 404 final representa un resultado correcto del test, porque es lo esperado después de eliminar.
Puedes enviarlas individualmente en ese orden o ejecutar la colección completa. Si intentas el PUT antes del POST, no habrá un identificador válido. Para pruebas independientes, cada caso necesitaría preparar y limpiar sus propios datos; aquí la dependencia entre peticiones forma parte del ejercicio.
Ejecutar la colección desde la terminal
Desde la raíz del proyecto, con la API todavía activa:
mkdir -p reports
cd api
pnpm exec bru run --env local --sandbox=safe --bail \
--reporter-json ../reports/bruno.json \
--reporter-junit ../reports/bruno.xmlEn PowerShell, crea reports y escribe el comando pnpm exec bru run con sus opciones en una sola línea.
--env selecciona el entorno, --bail detiene la ejecución ante un fallo y los reporters generan archivos para inspección o CI. La referencia de opciones y la guía de reportes describen estas banderas.
La CLI utiliza Safe Mode por defecto desde la versión 3. Lo indicamos explícitamente para que el ejemplo no dependa de una configuración implícita. No hace falta Developer Mode ni acceso a módulos externos para estos tests. Una colección ajena puede contener scripts y peticiones con efectos: revísala antes de ejecutarla, incluso si utiliza Safe Mode.
En la ejecución del laboratorio se verificaron estos resultados:
| Petición | Estado observado | Comprobación |
|---|---|---|
GET /tasks | 200 | Devuelve una lista |
POST /tasks | 201 | Crea una tarea pendiente y guarda su ID |
PUT /tasks/{{taskId}} | 200 | Reemplaza título y estado |
DELETE /tasks/{{taskId}} | 204 | Elimina la tarea |
| GET del recurso eliminado | 404 | Devuelve NOT_FOUND |
Pasaron las cinco peticiones y los cinco tests. Los tiempos dependen del equipo, por lo que no los usamos como una promesa de rendimiento. También puedes ejecutar solo el listado:
pnpm exec bru run 01-listar.yml --env local --sandbox=safePara verificar que el pipeline detectará errores, cambia temporalmente la expectativa del listado a 299: la respuesta seguirá siendo 200, pero el test fallará y la CLI terminará con código distinto de cero. Restaura después 200. Este comportamiento se comprobó durante la preparación del artículo.
Versionar y compartir la colección
En la raíz, guarda .gitignore:
node_modules/
.env
.env.*
!.env.example
reports/Las reglas excluyen también el .env dentro de api. El ejemplo de configuración se incluye porque solo contiene datos ficticios. Antes de compartir, comprueba qué vas a añadir:
cd ..
git init
git add server.mts package.json pnpm-lock.yaml .gitignore api
# Si creaste pnpm-workspace.yaml, añádelo también.
git diff --cached --stat
git diff --cached
git status --shortConfirma que api/.env no esté entre los archivos preparados. Después crea el commit:
git commit -m "Añadir API local y colección de pruebas Bruno"Cuando exista un remoto del equipo, comparte esa rama mediante el flujo de revisión habitual. Cada persona clona el repositorio, instala las dependencias, prepara su .env, inicia la API y abre api en Bruno. No necesita pedir una exportación actualizada por chat.
La colaboración con Git desde la terminal permite revisar cambios en URL, cuerpo o expectativas como cualquier otro archivo. Si cambia el contrato de la API, incluye su colección en la misma revisión. Si aparecen conflictos, revisa también su significado: fusionar dos YAML válidos no garantiza que la secuencia resultante tenga sentido. El artículo de merge, rebase y cherry-pick explica las opciones de integración.
Llevarlo a CI sin convertirlo en otro proyecto
El paso central del pipeline es el mismo bru run. El trabajo de CI necesita instalar desde el lockfile, levantar la API, esperar a que esté lista y devolver el resultado de los tests. Un script Bash como este puede guardarse como ci-test.sh en la raíz:
#!/usr/bin/env bash
set -euo pipefail
pnpm install --frozen-lockfile
mkdir -p reports
export LAB_TOKEN=solo-laboratorio-local
node --experimental-strip-types server.mts > reports/api.log 2>&1 &
api_pid=$!
trap 'kill "$api_pid" 2>/dev/null || true' EXIT
ready=false
for attempt in {1..30}; do
if ! kill -0 "$api_pid" 2>/dev/null; then
echo "La API no pudo iniciarse" >&2
exit 1
fi
if curl --fail --silent http://127.0.0.1:3037/health > /dev/null; then
ready=true
break
fi
sleep 1
done
if [ "$ready" != true ]; then
echo "La API no estuvo lista a tiempo" >&2
exit 1
fi
cd api
pnpm exec bru run --env local --sandbox=safe --bail \
--reporter-json ../reports/bruno.json \
--reporter-junit ../reports/bruno.xmlEjecútalo con bash ci-test.sh. Requiere Bash, curl, Node.js y pnpm en el runner. La variable del ejemplo sigue siendo ficticia; para un entorno remoto se obtiene del almacén de secretos del pipeline, con permisos limitados, y nunca se escribe en el YAML versionado. La colección utiliza process.env.LAB_TOKEN, por lo que el proceso de la CLI también debe recibirla.
Configura tu plataforma para conservar los reportes incluso si fallan los tests, revisando antes su contenido: pueden incluir cuerpos o cabeceras sensibles. No publiques esos artefactos indiscriminadamente. Evita dar credenciales de producción a pipelines de contribuciones no confiables.
Este flujo ejecuta un escenario funcional pequeño. No sustituye pruebas unitarias, cobertura de autorización, pruebas de carga ni una especificación completa. Para ejecutar casos en paralelo habría que separar sus datos; compartir un taskId mutable entre ejecuciones concurrentes sería una fuente de fallos.
Cuándo merece la pena incorporarlo
Bruno resulta útil cuando las peticiones forman parte del conocimiento del equipo y quieres mantenerlas cerca del código: onboarding, reproducción de errores, cambios de contratos y comprobaciones funcionales en CI.
El coste está en mantener ese artefacto. Alguien debe revisar los tests, cuidar los datos de prueba, resolver conflictos y actualizar entornos. La migración desde otra herramienta también exige comprobar autenticación, variables y scripts; importar una colección no demuestra que conserve todo su comportamiento.
Si solo necesitas consultar una respuesta ocasional, HTTPie puede ser suficiente. Si tu equipo depende de funciones específicas de otra plataforma, evalúa esas necesidades antes de cambiar. Una prueba razonable consiste en migrar un flujo pequeño, ejecutarlo desde dos equipos y desde CI, y comprobar si el control mediante Git mejora realmente la colaboración.
Al terminar el laboratorio, detén el servidor con Ctrl+C. Puedes conservar la colección como ejemplo o eliminar únicamente la carpeta bruno-api-lab que creaste para este ejercicio. No quedan servicios externos ni datos persistentes asociados al programa.
Referencias
- Bruno: OpenCollection y formato de archivos, estructura YAML y creación de colecciones.
- Instalación de Bruno y versión 4.3.0.
- Bearer Token, dotenv y variables secretas.
- Tests, opciones de la CLI, reportes y colaboración con Git.
- Postman, almacenamiento en Insomnia y HTTPie CLI.
- Node.js HTTP y soporte de TypeScript.
