Hay herramientas que usas porque todo el mundo las usa. Y hay herramientas que usas porque, cuando las pruebas, ya no puedes volver atrás. Bruno pertenece a la segunda categoría.
Estuve usando Postman desde que empecé en el mundo del desarrollo. Era la opción por defecto, la que todo bootcamp enseñaba, la que todo tutorial usaba. Pero en algún momento empecé a sentir que Postman me daba más fricción que comodidad. Fue entonces cuando llegó Bruno.
Este artículo es técnico, sí. Pero también es personal. Es la historia de por qué cambié, qué encontré, cómo lo uso, y por qué creo que todo desarrollador que trabaje con APIs debería, al menos, probarlo.
El problema que nadie quería admitir
En mayo de 2023, Postman eliminó el modo que te permitía usar la herramienta sin cuenta ni conexión a internet. A partir de ese momento, si querías trabajar con tus colecciones, necesitabas crearte una cuenta y sincronizar todo en la nube de Postman.
Puede parecer un detalle menor. No lo es.
Cuando trabajas en proyectos con clientes donde defines especificaciones API con variables de entorno que apuntan a endpoints privados, credenciales de staging o tokens temporales, meter esos datos en la nube de un tercero es una decisión que hay que justificar. Y en muchos contextos corporativos o de cumplimiento, directamente no está permitido.
Además existe otro problema más sutil: las colecciones de Postman se guardan en un formato JSON propietario. Eso significa que si quieres versionar tus colecciones con Git el diff es prácticamente ilegible. Comparar dos versiones de una colección es un ejercicio de frustración. No puedes hacer una revisión de código decente sobre algo así.
Bruno nació exactamente para resolver esto.
¿Qué es Bruno?
Bruno es un cliente de API de código abierto. Es una alternativa a Postman, Insomnia o Apidog, pero con una filosofía radicalmente distinta. Su premisa está escrita en su propia web: "Bruno es un desafío open source al movimiento de convertirlo todo en una plataforma inflada. Una herramienta de desarrollo debe ser extensible y trabajar con el resto de tu stack, no en contra de él."
El nombre viene del golden retriever de Anoop M D, su creador. Eso ya dice algo del espíritu del proyecto.
Bruno almacena todo en tu sistema de archivos como carpetas y archivos de texto plano. Usa su propio lenguaje llamado Bru, un DSL diseñado para definir peticiones HTTP de forma legible. Y aquí está la clave que lo cambia todo: nunca sincroniza nada con la nube. No hay login. No hay cuenta. No hay telemetría. Tus datos se quedan en tu máquina.
Si quieres compartir una colección con tu equipo, lo haces a través de Git. Como cualquier otro archivo de código.
El formato .bru: cuando las APIs se tratan como código
Esto es lo que hace especial a Bruno desde el punto de vista técnico. Un archivo .bru tiene este aspecto. En este caso es una petición a un endpoint de inferencia que recibe features y devuelve una predicción:
meta {
name: Predict
type: http
seq: 1
}
post {
url: {{base_url}}/api/v1/predict
body: json
auth: bearer
}
auth:bearer {
token: {{access_token}}
}
headers {
Accept: application/json
X-Model-Version: v2
}
body:json {
{
"features": [5.1, 3.5, 1.4, 0.2],
"threshold": 0.5,
"return_probabilities": true
}
}
assert {
res.status: eq 200
res.body.prediction: isDefined
res.body.probabilities: isArray
res.responseTime: lt 300
}Ese archivo es texto plano. Se puede leer, se puede revisar en un pull request, se puede comparar entre versiones, se puede buscar con grep. Es exactamente igual que cualquier otro archivo de tu proyecto.
Las colecciones de Bruno viven dentro del mismo repositorio que el código. Cuando alguien modifica un endpoint, el cambio es visible en el historial de Git con contexto completo.
Entornos: separar desarrollo, staging y producción sin dolor
Una de las funcionalidades que más me gusta es el sistema de entornos. Bruno permite definir múltiples entornos, cada uno con sus propias variables, y cambiar entre ellos con un clic desde la interfaz.
Cuando estoy desarrollando localmente con FastAPI, el entorno de desarrollo se ve así:
vars {
base_url: http://localhost:8000
access_token: dev-token-local-123
confidence_threshold: 0.5
}Y el equivalente apuntando al endpoint desplegado en producción:
vars {
base_url: https://api.miproyecto.com
access_token: {{process.env.PROD_TOKEN}}
confidence_threshold: 0.75
}Fíjate en ese {{process.env.PROD_TOKEN}}. Bruno lee variables de un archivo .env en la raíz de la colección y las expone con la sintaxis process.env.*. Ese archivo va en el .gitignore. El archivo del entorno de producción sí se versiona en Git, pero sin ningún secreto escrito en él.
Cambiar entre entornos es literalmente un clic en el desplegable de la esquina superior derecha. Cambio de entorno, cambio de región, cambio de modelo, cambio de umbral de confianza... todo sin tocar una sola línea de la petición.
Bruno como herramienta de exploración antes de escribir código Python
Aquí está el flujo que más valor me aporta en proyectos de ML. Cuando construyo un endpoint de inferencia con FastAPI, Bruno es la primera herramienta que abro antes de escribir una sola línea del cliente Python.
El ciclo es este: defino el endpoint en FastAPI, lo lanzo en local, abro Bruno, construyo la petición, valido que la respuesta tiene la estructura correcta, ajusto los parámetros, y solo cuando todo funciona como espero, escribo el cliente Python definitivo.
El endpoint FastAPI podría verse así:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sklearn.pipeline import Pipeline
import numpy as np
app = FastAPI()
# El modelo se cargaría en el startup del servidor, aquí simplificado
pipeline: Pipeline # cargado desde disco con joblib, por ejemplo
class PredictRequest(BaseModel):
features: list[float]
threshold: float = 0.5
return_probabilities: bool = False
class PredictResponse(BaseModel):
prediction: int
probabilities: list[float] | None = None
model_version: str
@app.post("/api/v1/predict", response_model=PredictResponse)
async def predict(request: PredictRequest):
if len(request.features) != 4:
raise HTTPException(
status_code=422,
detail="Se esperan exactamente 4 features"
)
X = np.array(request.features).reshape(1, -1)
proba = pipeline.predict_proba(X)[0].tolist()
prediction = int(np.argmax(proba))
return PredictResponse(
prediction=prediction,
probabilities=proba if request.return_probabilities else None,
model_version="v2"
)Mientras desarrollo ese endpoint, Bruno me permite iterarlo de forma interactiva sin escribir código adicional. Pruebo distintos valores de features, cambio el threshold, activo y desactivo return_probabilities, y veo exactamente qué devuelve el modelo en cada caso.
Una vez que el endpoint está validado en Bruno, escribo el cliente Python que lo consumirá en producción o en otros servicios:
import httpx
from dataclasses import dataclass
@dataclass
class Prediction:
prediction: int
probabilities: list[float] | None
model_version: str
class MLApiClient:
def __init__(self, base_url: str, token: str, timeout: float = 5.0):
self._client = httpx.Client(
base_url=base_url,
headers={"Authorization": f"Bearer {token}"},
timeout=timeout
)
def predict(
self,
features: list[float],
threshold: float = 0.5,
return_probabilities: bool = False
) -> Prediction:
response = self._client.post(
"/api/v1/predict",
json={
"features": features,
"threshold": threshold,
"return_probabilities": return_probabilities,
}
)
response.raise_for_status()
data = response.json()
return Prediction(
prediction=data["prediction"],
probabilities=data.get("probabilities"),
model_version=data["model_version"]
)
def close(self):
self._client.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()La colección de Bruno y el cliente Python coexisten en el mismo repositorio. Son dos formas de hablar con la misma API: Bruno para exploración interactiva durante el desarrollo, el cliente Python para consumo programático en producción.
Tests de integración: Bruno y pytest trabajando juntos
Uno de los flujos que más me ha gustado descubrir es la combinación de Bruno CLI con pytest en el mismo pipeline de CI. Los dos se complementan perfectamente porque validan capas distintas.
Bruno valida el contrato HTTP del endpoint: que devuelve el código de estado correcto, que la estructura de la respuesta es la esperada, que la latencia está dentro de un rango aceptable. Pytest valida la lógica interna: que el modelo produce predicciones coherentes, que el preprocesado es correcto, que los casos extremos están manejados.
Los tests de integración con pytest para ese endpoint podrían verse así:
import pytest
import httpx
import time
BASE_URL = "http://localhost:8000"
HEADERS = {"Authorization": "Bearer dev-token-local-123"}
@pytest.fixture(scope="session")
def client():
with httpx.Client(base_url=BASE_URL, headers=HEADERS) as c:
yield c
def test_predict_devuelve_clase_valida(client):
response = client.post(
"/api/v1/predict",
json={"features": [5.1, 3.5, 1.4, 0.2], "threshold": 0.5}
)
assert response.status_code == 200
data = response.json()
assert isinstance(data["prediction"], int)
assert data["prediction"] >= 0
def test_predict_probabilidades_suman_uno(client):
response = client.post(
"/api/v1/predict",
json={
"features": [5.1, 3.5, 1.4, 0.2],
"threshold": 0.5,
"return_probabilities": True,
}
)
assert response.status_code == 200
probs = response.json()["probabilities"]
assert abs(sum(probs) - 1.0) < 0.01
def test_predict_rechaza_features_incorrectas(client):
response = client.post(
"/api/v1/predict",
json={"features": [1.0, 2.0], "threshold": 0.5}
)
assert response.status_code == 422
def test_predict_latencia_aceptable(client):
start = time.perf_counter()
client.post(
"/api/v1/predict",
json={"features": [5.1, 3.5, 1.4, 0.2], "threshold": 0.5}
)
elapsed_ms = (time.perf_counter() - start) * 1000
assert elapsed_ms < 300, f"Latencia demasiado alta: {elapsed_ms:.1f}ms"CI/CD: pytest y Bruno CLI en el mismo pipeline
Bruno tiene una CLI oficial instalable vía npm que permite ejecutar las colecciones desde el terminal:
npm install -g @usebruno/cli
bru run --env ci --reporter-junit bruno-results.xmlCombinado con pytest, el pipeline de GitHub Actions para un proyecto de ML quedaría así:
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Instalar dependencias Python
run: pip install -r requirements.txt
- name: Levantar API en background
run: uvicorn app.main:app --host 0.0.0.0 --port 8000 &
- name: Esperar a que la API esté lista
run: |
python - <<'EOF'
import httpx, time, sys
for _ in range(15):
try:
httpx.get("http://localhost:8000/health", timeout=2).raise_for_status()
print("API lista"); sys.exit(0)
except Exception:
time.sleep(1)
print("API no respondió a tiempo"); sys.exit(1)
EOF
- name: Tests de modelo y lógica con pytest
run: pytest tests/ -v --tb=short
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Instalar Bruno CLI
run: npm install -g @usebruno/cli
- name: Tests de contrato de API con Bruno
run: bru run --env ci --reporter-junit bruno-results.xml
working-directory: collections/mi-apiPytest valida la lógica interna del modelo y del preprocesado. Bruno valida que el contrato HTTP del endpoint no se ha roto con ningún cambio. Los dos tienen que pasar para que el PR se pueda mergear.
Lo que más me gusta en el día a día
Después de varios meses usando Bruno, hay cosas que ya doy por sentadas pero que antes me costaban tiempo.
El arranque instantáneo es una de ellas. Bruno abre en menos de dos segundos. Cuando estás en medio de una sesión de debug de un endpoint de FastAPI y necesitas lanzar una petición rápida para ver qué devuelve, esos segundos importan.
La colocación de colecciones junto al código cambia el flujo de trabajo por completo. La colección de Bruno vive en una subcarpeta del repositorio, al lado del código Python, los notebooks y los tests. Cuando hago checkout de una rama feature, las peticiones de API que corresponden a esa rama están ahí, en ese mismo checkout. No tengo que recordar qué versión de la colección de Postman corresponde a qué rama del código.
La interfaz es limpia y sin distracciones. No hay publicidad, no hay sugerencias de planes de pago por todas partes, no hay modales de bienvenida. Abres la herramienta y está tu colección, lista para usar.
Y la privacidad de datos. En proyectos con información sensible de clientes, la certeza de que nada sale de tu máquina a no ser que tú lo decidas explícitamente es una característica, no un detalle.
Planes y modelo de negocio
Bruno es open source (licencia MIT en su núcleo) y gratuito para uso individual. En 2024 introdujeron planes de pago: Pro y Ultimate. El plan Ultimate añade integración con Vault para gestión de secretos, un cliente Git integrado en la propia aplicación, explorador de archivos y funcionalidades avanzadas de diseño OpenAPI.
Para la mayoría de casos de uso individuales o de equipos pequeños, la versión gratuita es más que suficiente. Y lo importante: el modelo de negocio no depende de vender tus datos ni de forzar la nube. Depende de ofrecer valor adicional a quien lo necesita.
Recursos de interés
Sitio oficial y descarga: https://www.usebruno.com Documentación completa: https://docs.usebruno.com Repositorio en GitHub: https://github.com/usebruno/bruno Blog oficial con tutoriales: https://blog.usebruno.com Variables y process.env: https://docs.usebruno.com/variables/process-env CLI en npm: https://www.npmjs.com/package/@usebruno/cli Guía de integración con GitHub Actions: https://docs.usebruno.com/bru-cli/gitHubCLI Conversor de Postman a Bruno: https://docs.usebruno.com/converters/postman-to-bruno Changelog con todas las novedades: https://www.usebruno.com/changelog
Conclusión: herramientas que respetan tu flujo de trabajo
Lo que me hizo quedarme con Bruno no fue una feature concreta. Fue la filosofía que hay detrás. Una herramienta que almacena tus datos donde tú decides, que se integra con Git, que arranca rápido, que no te pide que crees una cuenta para hacer tu trabajo, y que tiene una comunidad activa que la hace crecer semana a semana.
Como alguien que trabaja constantemente con APIs, tener un gestor de APIs que se comporta como una herramienta de desarrollo y no como una plataforma SaaS encubierta marca la diferencia.
Si todavía usas Postman y no tienes una razón de peso para seguir haciéndolo, dale una tarde a Bruno. La migración desde Postman es directa con el conversor oficial, y en menos de una hora tendrás tus colecciones funcionando.
Y cuando veas el primer diff limpio de una colección en tu historial de Git, vas a entender exactamente a qué me refería.
