Bruno API Testing: The Git-Native Postman Alternative

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 run

The 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.bru

The 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.bru

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

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

You'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:

  1. Add new requests → create a .bru file → open a PR
  2. Update an existing request → edit the file → the diff shows exactly what changed
  3. 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
.env

Comparison 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.bru

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

Read more

Start now free