Bruno API Testing: The Git-Native Postman Alternative
If you've ever dealt with Postman's cloud sync forcing you to log in, its collection format being a massive JSON blob that makes Git diffs unreadable, or the frustration of your entire team's API workspace living in a proprietary cloud — Bruno was built to solve exactly those problems.
Bruno is an open-source API client that stores all your collections directly on your filesystem in a plain-text format called Bru. That means your API tests live alongside your code, travel with your repository, and get the same review process as any other change.
What Bruno Is (and Isn't)
Bruno is a desktop API client — think Postman or Insomnia, but with a radically different philosophy around data ownership. Your collections are never synced to a third-party server. There is no account required. Every request, environment variable, and test script lives in .bru files on disk.
This makes Bruno particularly compelling for teams that already treat infrastructure-as-code seriously. Your API tests become version-controlled artifacts, not an external dependency.
Bruno is not a headless testing runner by default (though the CLI makes it one), and it's not a mock server or a load testing tool. It's focused squarely on API exploration and testing.
Installing Bruno
Bruno ships as a desktop app for macOS, Windows, and Linux, plus a CLI package for automation:
# macOS via Homebrew
brew install bruno
# CLI for CI/CD pipelines
npm install -g @usebruno/cli
# Or run without installing
npx @usebruno/cli runThe desktop app is available at usebruno.com and the source is on GitHub under a MIT-like open license.
The Bru File Format
The central innovation in Bruno is the .bru format. Instead of a single enormous JSON collection file, each request is its own plain-text file:
meta {
name: Get User Profile
type: http
seq: 1
}
get {
url: {{baseUrl}}/api/users/{{userId}}
body: none
auth: bearer
}
auth:bearer {
token: {{authToken}}
}
headers {
Accept: application/json
X-Request-ID: {{$randomUUID}}
}
tests {
test("status is 200", function() {
expect(res.status).to.equal(200);
});
test("user has required fields", function() {
const body = res.getBody();
expect(body).to.have.property('id');
expect(body).to.have.property('email');
expect(body.email).to.match(/@/);
});
}This is a complete HTTP request with auth, headers, and assertions — in 30 lines of readable text. A git diff on this is immediately understandable. A code reviewer can see exactly what changed and why.
Organizing Collections
Collections in Bruno map directly to directories. A typical structure looks like:
my-api/
bruno.json # collection metadata
environments/
local.bru
staging.bru
production.bru
auth/
login.bru
refresh-token.bru
logout.bru
users/
list-users.bru
get-user.bru
create-user.bru
update-user.bru
delete-user.bru
orders/
create-order.bru
get-order.bruThe bruno.json file at the root is minimal:
{
"version": "1",
"name": "My API",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}Environment Variables
Bruno handles environments as .bru files too, keeping them in version control (with the option to gitignore sensitive values):
vars {
baseUrl: http://localhost:8080
apiVersion: v1
}
vars:secret [
authToken
apiKey
]The vars:secret block marks variables as sensitive — they're stored in a separate .env file that you add to .gitignore, while the variable names themselves (but not values) are committed. This gives you the best of both worlds: the team knows which variables exist, but credentials stay off the repository.
Switch environments in the GUI with a dropdown, or pass them on the CLI:
bru run --env staging auth/login.bruScripting: Pre-Request and Post-Response
Bruno supports JavaScript for pre-request scripts and test assertions, using a Chai-style assertion library:
meta {
name: Create Order
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/orders
body: json
auth: bearer
}
auth:bearer {
token: {{authToken}}
}
body:json {
{
"product_id": "{{productId}}",
"quantity": 2,
"shipping_address": {
"street": "123 Test St",
"city": "Testville",
"zip": "12345"
}
}
}
script:pre-request {
// Generate a unique idempotency key
bru.setVar("idempotencyKey", Date.now().toString());
}
script:post-response {
// Store the order ID for downstream requests
const body = res.getBody();
if (res.status === 201 && body.id) {
bru.setVar("orderId", body.id);
console.log("Order created:", body.id);
}
}
tests {
test("order is created", function() {
expect(res.status).to.equal(201);
});
test("order has valid structure", function() {
const body = res.getBody();
expect(body).to.have.property('id');
expect(body).to.have.property('status');
expect(body.status).to.equal('pending');
expect(body.total).to.be.a('number').and.greaterThan(0);
});
test("response time is acceptable", function() {
expect(res.responseTime).to.be.lessThan(2000);
});
}The bru.setVar() call in script:post-response is how you chain requests together. The order ID stored here becomes available to any subsequent request in the same collection run.
Running Chained Requests
A common pattern is creating a resource and then operating on it. Here's how that looks with Bruno's variable chaining:
Step 1 — auth/login.bru:
script:post-response {
const body = res.getBody();
bru.setVar("authToken", body.access_token);
bru.setVar("userId", body.user.id);
}Step 2 — users/get-user.bru:
get {
url: {{baseUrl}}/api/users/{{userId}}
}When you run the collection in order, the token from login flows automatically into subsequent requests.
CLI Usage for CI/CD
The bru CLI turns Bruno collections into automated test suites:
# Run a single request
bru run auth/login.bru --env staging
# Run all requests in a folder
bru run users/ --env staging
# Run the entire collection
bru run --env staging
# Run with a specific output format
bru run --env staging --reporter junit --output results.xml
# Run and fail the process on test failures (important for CI)
bru run --env staging || exit 1GitHub Actions Integration
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Bruno CLI
run: npm install -g @usebruno/cli
- name: Start API server
run: |
docker compose up -d api
npx wait-on http://localhost:8080/health
- name: Run Bruno collection
env:
AUTH_TOKEN: ${{ secrets.STAGING_AUTH_TOKEN }}
BASE_URL: http://localhost:8080
run: |
bru run --env ci --reporter junit --output test-results.xml
- name: Publish test results
uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: test-results.xmlYou'll need a ci environment file for this:
# environments/ci.bru
vars {
baseUrl: http://localhost:8080
}
vars:secret [
authToken
]And a .env file (generated during CI from secrets, not committed):
authToken=<injected from CI secret>Team Collaboration Without the Cloud
Because Bruno stores everything on disk, team collaboration uses standard Git workflows:
- Add new requests → create a
.brufile → open a PR - Update an existing request → edit the file → the diff shows exactly what changed
- Rename or reorganize → move/rename files → Git tracks the history
This is fundamentally different from Postman's approach where collection changes are tracked in Postman's own version history, disconnected from your code commits. With Bruno, the PR that adds a new endpoint also includes the API test for that endpoint, reviewed together.
For teams with .env files containing real credentials, a .gitignore entry keeps secrets off the repo while still allowing environment variable names to be shared:
# .gitignore
environments/*.env
.envComparison with Postman and Insomnia
| Feature | Bruno | Postman | Insomnia |
|---|---|---|---|
| Storage | Filesystem (.bru files) | Postman cloud | Filesystem or cloud |
| Git-friendly | Excellent (plain text) | Poor (JSON blob) | Moderate |
| Offline-first | Yes | Requires account | Yes |
| CLI testing | Yes (bru CLI) | Yes (newman) | Yes (inso) |
| Open source | Yes (MIT-like) | No | Core yes, sync no |
| Price | Free | Free tier + paid | Free + paid |
| Scripting | JavaScript (Chai) | JavaScript (Chai) | JavaScript |
The main limitation of Bruno compared to Postman is ecosystem maturity — Postman has years of integrations, a larger community, and more polish in edge cases. Bruno is younger and occasionally rough around the edges, but it's improving rapidly and the core value proposition is compelling.
Practical Tips
Prefix request filenames with sequence numbers to control run order:
01-login.bru
02-get-profile.bru
03-create-resource.bru
04-verify-resource.bru
05-cleanup.bruUse collection-level scripts for shared setup and teardown:
# collection.bru (root script file)
script:pre-request {
// Add timestamp to every request
bru.setRequestHeader("X-Timestamp", new Date().toISOString());
}Validate response schemas in test blocks:
tests {
test("response matches schema", function() {
const body = res.getBody();
expect(body).to.be.an('object');
expect(body.items).to.be.an('array');
body.items.forEach(item => {
expect(item).to.have.all.keys(['id', 'name', 'price', 'stock']);
expect(item.price).to.be.a('number').and.at.least(0);
});
});
}Summary
Bruno's bet is that API tests belong next to application code — in Git, in pull requests, in the same review process. The .bru format makes this practical rather than painful. If your team already treats tests as code, Bruno fits naturally into that workflow without requiring any cloud infrastructure or vendor accounts.
The CLI is production-ready for CI/CD use, the scripting is expressive enough for real-world chaining and validation, and the Git-native design eliminates an entire class of collaboration problems that plague teams using cloud-sync API clients.
For greenfield projects or teams already frustrated with Postman's direction, Bruno is worth a serious look.