On this page10 sections

Bruno — Git-Based API Testing

Build an API test collection in Bruno — the open-source, offline-first, git-friendly alternative to Postman — using plain-text .bru files, environments, assertions, and version-controlled collection structure.

Role

Manual QA engineer

Difficulty

Intermediate

Time limit

~90 min

Category

api client

Bruno desktop app (free, open-source at usebruno.com)Any git client (optional — for the version-control extension step)

Scenario

Your team has been using Postman for API testing, but the collections are stored in Postman's cloud and cannot be reviewed in pull requests, versioned in git, or run without a Postman account. You have been asked to evaluate Bruno as an alternative: build an equivalent collection for the Reqres.in API, set up environments, add assertions, and explain the trade-offs of a plain-text, git-based collection format to the team. Your deliverable is a working Bruno collection with tests and environments, a short note for the team explaining what changes when they move from Postman, and a brief git-friendly demonstration showing that the collection files are readable human-edited text.

Requirements

  • 1.Install Bruno and create a new Collection named 'Reqres API'; create at least two sub-folders — 'Users' and 'Auth' — and add the following requests: GET /api/users?page=2, GET /api/users/2, GET /api/users/23, POST /api/users, PUT /api/users/2, DELETE /api/users/2, POST /api/login (success), POST /api/login (failure)
  • 2.Create two environments in Bruno: 'dev' and 'staging', each with a variable named baseUrl set to https://reqres.in; use {{baseUrl}} in all request URLs so no request contains a hardcoded host
  • 3.Add Bruno assertions to at least 5 requests; in Bruno the assertions block uses the syntax: assert { res.status: eq 200 res.body.page: eq 2 } — cover status code, a body field value, and a type check (e.g. res.body.data: isDefined)
  • 4.Add a script block to POST /api/login (success) that captures the token into a Bruno environment variable: in the 'Tests' tab, use bru.setEnvVar('authToken', res.body.token) and add an assertion that the token is a non-empty string
  • 5.Open at least three .bru files in a text editor (outside Bruno) and paste their contents into your deliverables — the plain-text format is the key differentiator from Postman's opaque JSON; annotate each section (meta, get/post, headers, body, assert) with a one-line comment explaining what it does
  • 6.Write a 5–8 sentence comparison note covering: (1) how Bruno stores collections as .bru files in a directory vs. Postman's cloud JSON export, (2) how environments are stored as plain text vs. Postman environment JSON, (3) what git diff looks like for a .bru file change vs. a Postman collection JSON change, (4) the main limitation of Bruno compared to Postman at the time of writing (e.g. smaller ecosystem, no built-in mock server, fewer integrations)

Starter data

  • Bruno download: usebruno.com — available for macOS, Windows, and Linux; free and open-source under MIT licence; no account or sign-in required
  • Bruno .bru file syntax (GET request example): meta { name: Get Users type: http seq: 1 } get { url: {{baseUrl}}/api/users body: none auth: none } assert { res.status: eq 200 res.body.page: isDefined }
  • Bruno .bru file syntax (POST request example): meta { name: Create User type: http seq: 4 } post { url: {{baseUrl}}/api/users body: json auth: none } headers { Content-Type: application/json } body:json { { "name": "QA Engineer", "job": "API tester" } } assert { res.status: eq 201 res.body.name: eq QA Engineer }
  • Bruno environments: stored as .bru files in a special 'environments' folder inside the collection directory; format: vars { baseUrl: https://reqres.in authToken: } — the plain-text format means environment files can be committed to git with initial values but sensitive current values are managed locally
  • Bruno scripting: use the 'Script' tab (Pre-request or Post-response) for JavaScript; post-response example to save token: bru.setEnvVar('authToken', res.body.token); — bru is Bruno's global API, equivalent to Postman's pm object
  • Reqres.in POST /api/login credentials: { "email": "eve.holt@reqres.in", "password": "cityslicka" } → 200, { "token": "QpwL5tpe83ilfWH2" }

Expected deliverables

  • A Bruno collection directory (or zip of the directory) containing .bru files for all 8 specified requests, organized into Users and Auth sub-folders
  • Two environment files (dev.bru and staging.bru) with baseUrl and authToken variables; all request URLs using {{baseUrl}}
  • Assertions added to at least 5 requests (status code, body field value, and isDefined checks)
  • POST /api/login post-response script saving the token via bru.setEnvVar('authToken', res.body.token)
  • Plain-text content of at least 3 .bru files with inline annotations explaining each block
  • A 5–8 sentence comparison note: Bruno vs. Postman on collection storage, environment format, git-diff readability, and Bruno's current limitations

Evaluation rubric

DimensionWhat reviewers look for
Collection structureIs the collection organized into sub-folders matching the resource structure (Users, Auth)? Do request names and seq numbers reflect the intended execution order? In Bruno, the seq field controls display order within a folder; requests with seq: 1, 2, 3 are grouped and ordered correctly. A flat collection with seq numbers all set to 1 is unordered and makes the collection hard to navigate.
Assertion qualityDo Bruno assertions verify both the status code and the response body? The assertion block syntax is strict — each line is key: operator value where key is a JSONPath-like expression (res.status, res.body.data, res.body.data[0].id), operator is eq, isDefined, isString, gt, lt, contains, or notNull. An assertion block with only res.status: eq 200 has not tested the response body at all.
Environment setupAre both environments defined with the correct variable names, and do all request URLs use {{baseUrl}} with no hardcoded hosts? Switching between 'dev' and 'staging' environments in Bruno must resolve all URLs to the correct base without editing any .bru file. The authToken environment variable should have an empty initial value — populated at runtime by the login script — not a hardcoded token string.
Plain-text format understandingDo the annotated .bru file excerpts demonstrate that the candidate understands what each block does — not just that they copied the syntax? The annotation for the assert block should explain that Bruno assertions are evaluated after the response is received, that eq is an exact equality check, and that isDefined passes as long as the field is present regardless of value. Understanding the format is the core skill this exercise tests.
Articulation of git-based benefitsDoes the comparison note explain concretely why git-based plain text is beneficial for teams — not just as a philosophy statement? 'You can version-control it' is too vague. 'A git diff for a .bru file change shows exactly which assertion was added or which URL was modified in a human-readable diff, making code review of API test changes as straightforward as reviewing application code changes' is concrete. The note should also honestly address Bruno's current limitations — a comparison that only lists advantages is not balanced.

Sample solution outline

  • GET /api/users?page=2 .bru file: meta { name: Get Users List type: http seq: 1 } get { url: {{baseUrl}}/api/users?page=2 body: none auth: none } assert { res.status: eq 200 res.body.page: eq 2 res.body.data: isDefined res.body.total: eq 12 }
  • POST /api/login (success) .bru file: meta { name: Login Success type: http seq: 1 } post { url: {{baseUrl}}/api/login body: json auth: none } headers { Content-Type: application/json } body:json { { "email": "eve.holt@reqres.in", "password": "cityslicka" } } assert { res.status: eq 200 res.body.token: isDefined } script:post-response { bru.setEnvVar('authToken', res.body.token); }
  • GET /api/users/23 .bru file: meta { name: Get User Not Found type: http seq: 3 } get { url: {{baseUrl}}/api/users/23 body: none auth: none } assert { res.status: eq 404 }
  • Annotated GET Users List: # meta block — request metadata: name shown in sidebar, seq controls display order; # get block — HTTP method and URL; {{baseUrl}} resolved from active environment; # assert block — evaluated after response; res.status is the HTTP status code; res.body.page is a JSONPath into the parsed JSON body; eq is strict equality; isDefined checks field exists
  • Comparison note excerpt: Bruno stores each request as a separate .bru file in a folder hierarchy on disk, so the collection is a directory committed to git like any other code. A Postman collection is a single opaque JSON file where one changed assertion produces a diff spanning hundreds of lines; a Bruno .bru change produces a 3-line diff showing exactly which assertion changed. Limitation: Bruno has no built-in mock server, no team collaboration workspace, and a smaller ecosystem of integrations than Postman; teams that rely heavily on Postman Monitors, Mock Servers, or API Gateway integrations will find Bruno requires additional tooling for those workflows.

Common mistakes

  • Hardcoding the base URL in request URLs instead of using {{baseUrl}} — a Bruno collection where every URL starts with https://reqres.in defeats the purpose of environments; switching to a staging environment would have no effect on any request
  • Confusing Bruno's assertion syntax with Postman's pm.expect syntax — Bruno assertions use the key: operator value format in a dedicated block, not JavaScript; writing pm.expect(res.status).to.equal(200) in a Bruno assert block is invalid syntax and will fail silently or produce a parse error
  • Committing the authToken value in the environment file — Bruno environment files are plain text; if the authToken current value contains a real API token and the file is committed, the token is exposed in git history; keep current values for sensitive variables out of version control by adding the environment file to .gitignore or by keeping authToken initial value empty
  • Not opening .bru files in a text editor to inspect the format — the plain-text format is Bruno's primary differentiator and the key point of this exercise; candidates who only use the Bruno GUI without examining the underlying files have missed the main learning objective
  • Treating Bruno's limited ecosystem as a dealbreaker without nuance — Bruno is the right choice for teams whose primary need is version-controlled, reviewable API tests; it is less suitable for teams that depend on Postman Monitors or workspace collaboration; the comparison note should reflect this trade-off honestly rather than either dismissing Bruno or ignoring its limitations
  • Not adding a post-response script to the login request and instead manually copying the token into the environment — manual token management is the problem this exercise is designed to solve; bru.setEnvVar() automates the token chain the same way pm.environment.set() does in Postman

Submission checklist

  • Bruno collection directory (or zip) with at least 8 .bru request files in Users and Auth sub-folders
  • Two environment files (dev.bru, staging.bru) with baseUrl and authToken variables
  • All request URLs using {{baseUrl}} — no hardcoded hostnames
  • Assertions on at least 5 requests covering status code, body field value, and isDefined
  • Post-response script on POST /api/login saving token via bru.setEnvVar()
  • Plain-text content of at least 3 .bru files with inline annotations
  • 5–8 sentence comparison note covering collection format, environment format, git-diff readability, and Bruno limitations

Extension ideas

  • +Initialise the Bruno collection directory as a git repository, make a change to one assertion in a .bru file, and run git diff — paste the diff output as a deliverable and compare it in length and readability to a Postman collection JSON diff for a similar change
  • +Run the Bruno collection from the command line using the Bruno CLI (npm install -g @usebruno/cli; bru run --env dev): capture the terminal output and note how it compares to a Newman run for the equivalent Postman collection
  • +Add a variables block to one request to define a request-scoped variable (e.g. the user ID) and use it in both the URL and an assertion — this demonstrates Bruno's in-request variable scoping, which is different from Postman's local variable pattern