API Testing & Quality Engineering
Hands-onBeginner 30 min· Testing foundations · 4 of 19

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

TopicPostmanBruno
Where collections liveIn the Postman app and cloud workspace, exported as JSON when neededAs plain text .bru files in a folder you choose
Works in GitBy exporting large JSON filesNaturally, one small file per request, readable diffs
AccountNeeded for many featuresNot needed for core use
Scripts and testsJavaScript with the pm objectJavaScript with bru, req, res, plus a simple assert block
CLI runnerNewmanBruno 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

  1. 1Open Bruno and choose Create Collection.
  2. 2Name it Brew and Bean API, and pick a location (a new empty folder such as brew-bean-tests). Bruno creates a folder with a bruno.json file inside.
  3. 3Click the environment dropdown (top right), choose Configure, and create an environment named Local with a variable baseUrl set to http://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:

environments/Local.bru
vars {
  baseUrl: http://127.0.0.1:8000
}

A Bruno environment file: a vars block with one variable.

Step 2: a request with assertions

  1. 1Right-click the collection, choose New Request, name it Get menu, method GET, URL {{baseUrl}}/menu.
  2. 2Click Send (or press Ctrl+Enter). You should see 200 and the menu JSON.
  3. 3Open the Assert tab. Add two rows: res.status eq 200, and res.body.length eq 8.
  4. 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:

Get menu.bru
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:

Create order.bru
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:

Get order.bru (assert and tests blocks)
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.

Bad phone.bru
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

  1. 1Right-click the collection and choose Run. Pick Run Collection.
  2. 2Requests run in order of their seq number (the seq value in the meta block). Drag requests in the sidebar to reorder them and Bruno updates seq for you.
  3. 3Read the results: each request shows its assertions and tests with a green tick or a red cross.
  4. 4Break something on purpose (set res.body.total to eq 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).

Terminal
npm install -g @usebruno/cli
cd brew-bean-tests
bru run --env Local

Runs every request in the folder against the Local environment and prints a pass/fail summary. The exit code tells CI whether the run passed.

Output
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 total

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

Prompt · Convert a curl command into a Bruno request
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

  1. Bruno stores each request as a plain text .bru file in a folder, so collections work naturally with Git.
  2. The Assert tab gives quick checks such as res.status eq 200; the Tests tab gives full JavaScript with expect.
  3. Use bru.setVar and bru.getVar for runtime values such as an order id, and environments for fixed values like baseUrl.
  4. Requests in a run follow their seq order, so create before you read.
  5. bru run --env Local runs a collection from the terminal and fits into CI.
  6. 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.

Start free