Skip to content

How to test API's with Bruno

Installation

GUI

brew install bruno

CLI

  • Global: npm install -g @usebruno/cli
  • Local: npm install --save-dev @usebruno/cli

Folder Structure

your-repo/module/src/test/
└─ bruno/
   ├─ bruno.json
   ├─ environments/
   │  ├─ local.bru
   │  └─ prod.bru
   ├─ .env           # secrets for local runs (gitignored)
   ├─ scripts/       # shared JS helpers for tests/scripts
   │  └─ utils.js
   └─ api/
      ├─ health/
      │  └─ HEAD health.bru
      ├─ users/
      │  ├─ GET list users.bru
      │  ├─ GET user by id.bru
      │  ├─ POST create user.bru
      │  ├─ PUT update user.bru
      │  └─ DELETE user.bru
      └─ workflows/
         ├─ LOOP get many users.bru
         └─ POLL order status.bru

Note that Collections are simply folders.

Environments

environments/local.bru

vars {
  baseUrl: http://localhost:3000
  # Pull secrets from .env so they stay out of git
  accessToken: \{\{process.env.JWT_TOKEN\}\}
}

Requests

head {
  url: \{\{baseUrl\}\}/health
}

tests {
  test("status is 200", function () {
    expect(res.status).to.equal(200);
  });

  test("server header present", function () {
    // safer check for HEAD than relying on an empty body
    const server = res.getHeader("server");
    expect(server === undefined || typeof server === "string").to.be.true;
  });
}

GET

get {
  url: \{\{baseUrl\}\}/users
}

tests {
  test("200 OK", function () {
    expect(res.status).to.equal(200);
  });
  test("returns an array", function () {
    expect(res.body).to.be.an("array");
  });
}

POST

post {
  url: \{\{baseUrl\}\}/users
}
headers {
  content-type: application/json
}
body {
  {
    "name": "Alice",
    "email": "alice@example.com"
  }
}
tests {
  test("created", function () {
    expect(res.status).to.equal(201);
    expect(res.body.id).to.be.a("number");
  });
}

PUT

put {
  url: \{\{baseUrl\}\}/users/\{\{userId\}\}
}
headers {
  content-type: application/json
}
body {
  {
    "name": "Alice Smith"
  }
}
tests {
  test("updated", function () {
    expect(res.status).to.equal(200);
    expect(res.body.name).to.equal("Alice Smith");
  });
}

DELETE

delete {
  url: \{\{baseUrl\}\}/users/\{\{userId\}\}
}

tests {
  test("deleted", function () {
    // many APIs return 204 No Content on delete
    expect([200, 202, 204]).to.include(res.status);
  });
}

Authentication

Bearer Token

  • Easiest: use the Auth tab (automatic header)

    At the collection or request Auth tab, select Bearer Token and set the token (ideally via a variable like \{\{accessToken\}\}).
    Bruno will send Authorization: Bearer <token> automatically.

  • Or: just set the header in BRU
headers {
  Authorization: Bearer \{\{accessToken\}\}
}

JWT with OAuth2: UI Setup

Since Bruno v2, OAuth2 is built-in, supports collection/folder/request scoping, auto-fetch and auto-refresh, and exposes the token as a variable.

Setup (collection-level recommended):

  1. In your collection’s Auth tab, choose OAuth 2.0 (v2).
  2. Pick your grant (e.g., Client Credentials), set token URL, client_id, client_secret (use env vars).
  3. Enable Auto-fetch (and Auto-refresh if your provider supports refresh tokens).
  4. Save with a Token ID (e.g., idp).

Bruno will inject the access token into requests automatically. You can also use it directly as \{\{$oauth2.idp.access_token\}\}
or in scripts via bru.getOauth2CredentialVar('$oauth2.idp.access_token').

Example: verify your token & call an endpoint

# api/users/GET me.bru
get {
  url: \{\{baseUrl\}\}/me
  # Auth: "Inherit" from collection (OAuth2)
}

tests {
  test("authorized", function () {
    expect(res.status).to.equal(200);
  });

  test("token is a JWT", function () {
    const token = bru.getOauth2CredentialVar('$oauth2.idp.access_token');
    const parts = token.split('.');
    expect(parts.length).to.equal(3);

    // inspect claim issuer (quick decode without external libs)
    const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
    expect(payload.iss).to.be.a('string');
  });
}

JWT with OAuth: Script Setup

Option1: Pure “token request + script” (most portable, works in CLI & CI)

  1. Create a request that calls the provider’s token endpoint and stores the token to a runtime var:
# users/00-get-oauth-token.bru
meta { name: OAuth token (client_credentials) type: http seq: 0 }

post { url: \{\{oauth_token_url\}\} }

headers { content-type: application/x-www-form-urlencoded }

body:form-urlencoded {
  grant_type: client_credentials
  client_id: \{\{client_id\}\}
  client_secret: \{\{client_secret\}\}
  scope: \{\{scope\}\}
}

script:post-response {
  // Save for later requests in this run
  bru.setVar("access_token", res.body.access_token);
}
  1. In your real requests, reference {{access_token}} in the header (see the 10-list-users.bru earlier).

This “two-request” approach is the recommended fallback when you want OAuth2 to work consistently in headless/CLI runs
(and it’s how the Bruno team describes current behavior differences).

Alternative: Authorization Code/Password grants)

Authorization Code / Password grants (also UI-free)

Use the same pattern make a “Get Token” request that hits your provider’s /token endpoint with grant_type=authorization_code
(with code + redirect handling in your test environment) or grant_type=password, store access_token with bru.setVar,
and use it in subsequent requests. (Bruno supports these grant types.)

Option 2: Native OAuth2 config inside .bru (no UI clicks)

Bruno’s files can embed auth settings; the request (or collection/folder) defines an auth block and an auth:oauth2 block.

Example (client_credentials):

# users/20-create-user.bru
meta { name: Create user type: http seq: 20 }

auth { mode: oauth2 }

auth:oauth2 {
  grant_type: client_credentials
  access_token_url: \{\{oauth_token_url\}\}
  client_id: \{\{client_id\}\}
  client_secret: \{\{client_secret\}\}
  scope: \{\{scope\}\}
  add_token_to: header        # or "url"
  header_prefix: Bearer       # default is "Bearer"
  token_id: credentials       # optional: name the token
  auto_fetch_token: true
  auto_refresh_token: true
}

post { url: \{\{baseUrl\}\}/users }

body {
  { "email": "pat@example.com" }
}

With a token_id, you can also reference the token directly anywhere using:
Authorization: Bearer \{\{$oauth2.credentials.access_token\}\}

Notes:

  • Field names above reflect how the community examples/maintainers show them being serialized into .bru.
    Exact keys may evolve—if in doubt, create one sample through the UI in a scratch project and inspect the saved .bru
    (you still don’t need to use the UI in your repo).
  • Some older CLI versions didn’t trigger OAuth2 auto-fetch; pattern A works everywhere.

Conditionals and Loops

Bruno’s scripting runtime (pre-request / post-response / tests) exposes req, res, and bru helpers including bru.runRequest
(execute another request by path name) and bru.setNextRequest (jump the collection runner to a specific next request).

You can await requests in scripts.

Loop over IDs and run a request per ID

get {
  url: \{\{baseUrl\}\}/status  # harmless placeholder call
}

script:pre-request {
  // Drive: api/users/GET user by id.bru
  const ids = [1, 2, 3, 4];
  for (const id of ids) {
    bru.setVar("userId", id);
    await bru.runRequest("api/users/GET user by id"); // use path-like name
  }
}

Conditional flow / polling with setNextRequest

get {
  url: \{\{baseUrl\}\}/orders/\{\{orderId\}\}/status
}

tests {
  test("follow until done", function () {
    if (res.body.status === "PENDING") {
      // Re-run this request next in the collection runner
      bru.setNextRequest("api/workflows/POLL order status");
    } else {
      // Let the runner continue normally
      bru.setNextRequest(null);
      expect(["COMPLETED","FAILED"]).to.include(res.body.status);
    }
  });
}

Note

Avoid infinite loops with dynamic request hopping.

Shared Scripts

bruno/bruno.json

{
  "scripts": {
    "additionalContextRoots": ["./scripts"]
  }
}

bruno/scripts/utils.js

exports.pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)];

Usage:

script:pre-request {
  const { pickRandom } = require("utils.js");
  const id = pickRandom([10, 11, 12]);
  bru.setVar("userId", id);
}

Command Line

  1. Install Bruno CLI: npm install -g @usebruno/cli
  2. from the root of the collection (bruno/):

# Run all requests in the current folder
bru run

# Or point to a subfolder or a single request
bru run api/users
bru run api/users/"GET user by id.bru"

# Pick an environment and inject additional env vars at runtime
bru run --env local --env-var userId=123

# Only run requests whose names match a pattern
bru run --grep "users"

# Fail fast on first error, add a small delay between requests
bru run --fail-fast --delay-request 200

# Generate reports (JSON/JUnit/HTML) for CI artifacts
bru run --reporter-json out/results.json \
        --reporter-junit out/results.xml \
        --reporter-html out/results.html

`

CLI Options

bru run [files_or_folders...] [options]
bru import openapi [options]

Core & setup

-h, --help show help; --version show version.

--env <name> pick an environment.

--env-var <k=v> override a variable (repeatable).

--env-file <path> use a specific .bru env file.

--sandbox <developer|safe> choose JS sandbox (default: developer).

Data/iteration: --csv-file-path <file>, --json-file-path <file>, --iteration-count <n>.

-r recursive run through subfolders.

Request selection & flow

--tests-only run only requests that have tests/active assertions.

--bail stop on first failure.

--delay <ms> delay between requests.

--tags <t1,t2> run requests that have all these tags.

--exclude-tags <t1,t2> skip requests that have any of these tags.

--parallel run requests in parallel.

SSL / security / networking

--cacert <file> add a CA certificate.

--ignore-truststore use only your custom CA(s).

--client-cert-config <file> client certificate config (mTLS).

--insecure allow insecure server connections.

--disable-cookies don’t persist/send cookies.

--noproxy disable Bruno and system proxies.

Output & reporting

--reporter-json <file> write JSON report.

--reporter-junit <file> write JUnit XML.

--reporter-html <file> write HTML.

--reporter-skip-all-headers omit headers in reports.

--reporter-skip-headers <h1,h2> omit specific headers.

Deprecated: -o, –output and -f, –format (use reporters instead).
Bruno Docs

OpenAPI import (separate subcommand)

bru import openapi --source <spec.{yaml|json}|URL> \
[--output <dir> | --output-file <file.json>] \
[--collection-name "<name>"] \
[--insecure]

Options: –source/-s, –output/-o, –output-file/-f, –collection-name/-n, and –insecure (for fetching a spec over TLS with invalid certs).

Exit codes (handy in CI)

Exit Code Meaning
0 success
1 a request/test/assertion failed
2 output dir does not exist
3 request chain looped endlessly
4 called outside a collection root
5 input file does not exist
6 environment does not exist
7 env override not a string/object
8 env override malformed
9 invalid output format
255 other error.

Comments