Bruno: a free, Git-friendly API client
Build the same Brew & Bean test collection in Bruno, an open-source API client that stores every request as a plain text file. Learn assertions, scripts, environments, and how to run it from the command line.
In this tutorial you will rebuild the Brew & Bean checks from the Postman lesson in Bruno. Bruno is an open-source API client for desktop. Its main difference from Postman is that a collection is just a folder of plain text files on your disk. That means you can put it in Git, review changes in a pull request and share it with your team without any cloud account.
Prerequisites: the practice API running at http://127.0.0.1:8000 (see *Testing APIs by hand*), Bruno installed from usebruno.com, and Node.js if you want the command-line runner. At the time of writing (2026) Bruno has a free open-source edition and also paid editions with extra features. Check usebruno.com for what each includes. Everything in this lesson uses core features.
Postman and Bruno compared
Two good tools, different habits
| Topic | Postman | Bruno |
|---|---|---|
| Where collections live | In the Postman app and cloud workspace, exported as JSON when needed | As plain text .bru files in a folder you choose |
| Works in Git | By exporting large JSON files | Naturally, one small file per request, readable diffs |
| Account | Needed for many features | Not needed for core use |
| Scripts and tests | JavaScript with the pm object | JavaScript with bru, req, res, plus a simple assert block |
| CLI runner | Newman | Bruno CLI (bru) |
Neither is 'the best'. Many companies use Postman, so know it. Many teams like Bruno because the tests live next to the code. Learning one makes the other easy, as the ideas are the same.
Step 1: create the collection
- 1Open Bruno and choose Create Collection.
- 2Name it
Brew and Bean API, and pick a location (a new empty folder such asbrew-bean-tests). Bruno creates a folder with abruno.jsonfile inside. - 3Click the environment dropdown (top right), choose Configure, and create an environment named
Localwith a variablebaseUrlset tohttp://127.0.0.1:8000. Save and select it.
Look in your folder with a file explorer. You will find bruno.json and an environments folder with Local.bru. This is the whole collection, with no hidden database. The environment file looks like this:
vars {
baseUrl: http://127.0.0.1:8000
}A Bruno environment file: a vars block with one variable.
Step 2: a request with assertions
- 1Right-click the collection, choose New Request, name it
Get menu, method GET, URL{{baseUrl}}/menu. - 2Click Send (or press Ctrl+Enter). You should see 200 and the menu JSON.
- 3Open the Assert tab. Add two rows:
res.statuseq200, andres.body.lengtheq8. - 4Save with Ctrl+S and send again. The Tests results panel shows both assertions passing.
Bruno's Assert tab is a no-code way to check things: left side is what to look at (res.status, res.body.total, res.headers), then an operator (eq, neq, gt, lt, contains and more) and the expected value. For anything more complex, use the Tests tab with JavaScript. Here is the saved request file Bruno wrote for you, with a JavaScript test added:
meta {
name: Get menu
type: http
seq: 1
}
get {
url: {{baseUrl}}/menu
body: none
auth: none
}
assert {
res.status: eq 200
res.body.length: eq 8
}
tests {
test("every item has a positive price", function () {
res.body.forEach(function (item) {
expect(item.price).to.be.above(0);
});
});
}A whole request in one small text file: URL, assertions and a test. res is the response and expect is the Chai assertion function.
Step 3: create an order and keep its id
Create a request Create order: POST {{baseUrl}}/orders. In the Body tab choose JSON and paste the order. In Assert add res.status eq 201, res.body.total eq 390 and res.body.status eq received. Then open the Vars tab, or the Script tab, to save the id for later requests. The script version is:
meta {
name: Create order
type: http
seq: 2
}
post {
url: {{baseUrl}}/orders
body: json
auth: none
}
body:json {
{
"customer_name": "Asha",
"phone": "9876543210",
"items": [
{"item_id": "espresso", "qty": 2},
{"item_id": "croissant", "qty": 1}
]
}
}
assert {
res.status: eq 201
res.body.total: eq 390
res.body.status: eq received
}
script:post-response {
bru.setVar("orderId", res.body.id);
}After the response, bru.setVar stores the order id as a runtime variable called orderId.
Create Get order: GET {{baseUrl}}/orders/{{orderId}}. Because the create request ran first and saved orderId, the URL now works. Add a test that reads the variable back:
assert {
res.status: eq 200
res.body.customer_name: eq Asha
}
tests {
test("it is the order we created", function () {
expect(res.body.id).to.equal(bru.getVar("orderId"));
});
}Runtime variables set with bru.setVar are read with bru.getVar, or as {{orderId}} inside a URL or body.
Runtime variables do not persist
Runtime variables set by bru.setVar are not saved to disk, and they disappear when you close Bruno. That is deliberate: ids from a test run should not be committed to Git. Put fixed values in the environment, and temporary values in runtime variables.
Step 4: negative tests
Add a request Bad phone: POST {{baseUrl}}/orders with the phone set to 12345, and an assertion res.status eq 422. Add another, Unknown order: GET {{baseUrl}}/orders/does-not-exist with res.status eq 404.
Think of each failing request as a guard. If someone later weakens the validation, the test turns red.
meta {
name: Bad phone
type: http
seq: 4
}
post {
url: {{baseUrl}}/orders
body: json
auth: none
}
body:json {
{
"customer_name": "Asha",
"phone": "12345",
"items": [{"item_id": "espresso", "qty": 1}]
}
}
assert {
res.status: eq 422
}Step 5: run the whole collection
- 1Right-click the collection and choose Run. Pick Run Collection.
- 2Requests run in order of their
seqnumber (theseqvalue in themetablock). Drag requests in the sidebar to reorder them and Bruno updatesseqfor you. - 3Read the results: each request shows its assertions and tests with a green tick or a red cross.
- 4Break something on purpose (set
res.body.totaltoeq 400), run again, and see the failure message.
Step 6: run it from the terminal
The Bruno CLI runs a collection without the app. Install it once, then run it from inside the collection folder (the one containing bruno.json).
npm install -g @usebruno/cli
cd brew-bean-tests
bru run --env LocalRuns every request in the folder against the Local environment and prints a pass/fail summary. The exit code tells CI whether the run passed.
Get menu (200 OK) - 9 ms
Assertions
res.status: eq 200
res.body.length: eq 8
Tests
every item has a positive price
...
Requests: 4 passed, 4 total
Assertions: ... passed
Tests: 2 passed, 2 totalThe exact layout of the summary depends on the CLI version, but you should see passes and totals at the end. To run a single request, give its file name: bru run "Get menu.bru" --env Local. To run one sub-folder, pass its path.
Why Git-friendly matters
Put the collection folder in your project's Git repository. Now:
- A teammate adding a test shows up in a pull request as a few readable lines in one file.
- The tests live in the same repo as the code, so a change to the API and its tests ship together.
- CI can run bru run --env Local on every push (a later lesson sets this up with GitHub Actions).
- There is no shared account or cloud workspace to manage.
Add .env style secrets files to .gitignore. Bruno can read secrets from a local file that you do not commit. Check Bruno's docs for the current way to mark a variable as secret.
Convert this curl command into a Bruno .bru request file. Include meta (name, type http, seq), the method block with the URL using {{baseUrl}}, the body block, and an assert block that checks the status code is [expected status] and the main fields of the response.
[paste curl command]What to expect: Compare with the examples in this lesson. Check the block names (get/post, body:json, assert, tests) and that the values match what your API really returns. If Bruno cannot open the file, fix the syntax by creating the same request in the app and comparing.
Try it: Port your Postman collection
Add these requests to your Bruno collection, each with at least one assertion:
1. Move to preparing: PATCH {{baseUrl}}/orders/{{orderId}}/status with {"status": "preparing"}, expect 200 and status preparing.
2. Skip a status: PATCH the same order with {"status": "picked_up"}, expect 409.
3. Quantity 11: POST an order with qty 11, expect 422.
Then run the collection from the CLI and confirm all requests pass.
Key takeaways
- Bruno stores each request as a plain text
.brufile in a folder, so collections work naturally with Git. - The Assert tab gives quick checks such as
res.status eq 200; the Tests tab gives full JavaScript withexpect. - Use
bru.setVarandbru.getVarfor runtime values such as an order id, and environments for fixed values likebaseUrl. - Requests in a run follow their
seqorder, so create before you read. bru run --env Localruns a collection from the terminal and fits into CI.- Postman and Bruno teach the same skills: saved requests, variables, assertions and automated runs.
Check yourself
Finishing the quiz marks the lesson as done.
1. What is the biggest practical difference between Bruno and Postman for a team using Git?
2. In Bruno, which is the right way to save the id from a create response for later requests?
3. What does the assertion res.body.total: eq 390 check?
4. You run bru run --env Local in the wrong folder and it fails. What should you check?
5. Why do runtime variables not get saved to disk?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.