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¶
api-test/
├─ 1Password/ # Environment Files that can be populated by 1Password CLI
│ ├─ local.env
│ ├─ dev.env
│ ├─ stage.env
│ └─ prod.env
├─ bruno.json
├─ environments/
│ └─ ci.bru
├─ .env # Other Local env values (gitignored)
├─ collections/ # Root for all collections
│ ├── partitionCheck
│ │ ├── accounts
│ │ ├── folder.bru
│ │ ├── operations
│ │ ├── pdf-render
│ │ └── qr-lookup
| ├── <other-global-checks>
│ └─ <module>
│ ├─ routes/ # Just examples, non executable, tagged as disabled.
│ │ ├─ <route1>
│ │ ├─ <route2>
│ │ └─ <...>
│ └─ workflows/ # Real tests, executable.
│ ├─ <workflow1> # Scenarios can be tagged with Github tickets for reference.
│ ├─ <workflow2>
│ └─ <...>
├─ scripts/ # Typescript runners and helpers for tests/scripts
│ └─ **/<...>*.ts
├─ types/ # Typescript type definitions.
│ └─ bruno.d.ts # Unofficial Bruno typescript definitions. (temporary)
└─ resources/ # shared resources for tests/scripts and requests.
Note that Collections are simply folders.
Environments¶
The Bruno CLI and GUI allow to define environments in the environments folder.
For Arda’s practice, there is a single environment declared there with values that depend on
regular Environment variables. The environment variables are defined in *.env files in the 1Password folder and populated by the 1Password CLI. See the README file in the api-test repository.
In addition the GUI (and the VS Extension) allow to define local environments that are kept outside of the repository for local testing.
Headers and Request Variables¶
Bruno allows to define Headers and Request Variables in folders and requests. Best practice is to define the shared variables at the highest level possible and inherit them in the lower levels.
Example Requests¶
Every module (API Endpoint) should provide an example request for each defined Route. These request don’t need to be executable and serve to document the expected behavior and as templates for requests in workflows.
These requests should be tagged as disabled to prevent execution by the CI pipelines.
A convenient way to define these example requests is to import the OpenAPI spec for the relevant
API Endpoint.
Workflows¶
Workflows are intended to show specific sequences of requests that implement features in the system and can act as integration tests.
Workflows should be executable as a unit.
Requests in workflows are parameterized and connected through variables that are set in the pre and post scripts of the preceding requests using bru.setVar(<name>, <value>). These variables should be used in headers, variables, paths and body of the requests as well as in the tests associated with each request.
Typescript scripts¶
This section is still to be developed. It is intended to support extensions of the Bruno requests and to also be able to support more complex test scenarios than the linear execution currently provided by Bruno.
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 sendAuthorization: Bearer <token>automatically.
- Or: just set the header in BRU
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):
- In your collection’s Auth tab, choose OAuth 2.0 (v2).
- Pick your grant (e.g., Client Credentials), set token URL, client_id, client_secret (use env vars).
- Enable Auto-fetch (and Auto-refresh if your provider supports refresh tokens).
- 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)¶
- 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);
}
- 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
bruno/scripts/utils.js
Usage:
script:pre-request {
const { pickRandom } = require("utils.js");
const id = pickRandom([10, 11, 12]);
bru.setVar("userId", id);
}
Command Line¶
- Install Bruno CLI:
npm install -g @usebruno/cli - 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¶
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. |
Copyright: © Arda Systems 2025-2026, All rights reserved