REST API 20 ๐ API Testing with Postman, Bruno, and cURL
API testing tools fall into three architectural categories, and the category determines what the tool is good at. cURL is a command-line primitive: a single binary that sends an HTTP request and prints the response. Postman is a cloud-first platform: collections sync to Postman’s servers, scripts run in a built-in JavaScript runtime, and the Collection Runner executes test suites against environments . Bruno is a local-first client: each request is a plain-text file in the project repository, versioned with Git, executed by a CLI that returns non-zero on failure . The choice between them is not about which tool can send a requestโall three canโbut about where the test definitions live and who can review them.
Postman’s testing model is the most mature. Tests are JavaScript assertions using the pm object and Chai syntax: pm.test("status", () => pm.response.to.have.status(200)). Pre-request scripts run before the request, post-response scripts run after, and collections group requests into runnable suites . The Collection Runner executes a collection against an environment, and Newman (or the Postman CLI) runs it in CI. The cost is the cloud dependency: free-tier collections live on Postman’s servers, collaboration is limited to three users, and offline mode was removed in 2023, requiring a login even for local requests .
Bruno’s model is designed for the Git workflow. A collection is a folder; each request is a .bru file (or YAML in OpenCollection format) that can be diffed, reviewed in a pull request, and rolled back with standard Git commands . Tests use the Chai library in a Tests tab: test("status", () => expect(res.status).to.equal(200)), with res holding the response and bru managing environment variables . The CLI runs from the collection directory: bru run --env staging, exiting with code 1 if any test fails, which is exactly what a CI pipeline needs to block a merge . The trade-off is ecosystem size: Postman has more integrations, more public collections, and more documentation; Bruno is smaller but stores everything locally without an account .
cURL is the baseline that every other tool imports. Every API documentation page includes a curl example, browser dev tools can copy a request as curl, and Postman and Bruno both accept curl commands as input . The syntax is shell: -X sets the method, -H adds a header, -d sends a body, and @filename reads the body from a file . For a one-off request from documentation, curl is the fastest path: no account, no collection, no project file. For a test suite, curl becomes a shell script that grows unmanageable as the number of requests, environments, and assertions increases . The universal availability is the strength; the lack of a test framework is the limit.
This chapter covers the three tools’ testing models, the assertion syntax each uses, the CI integration path that turns a collection into a merge gate, and the migration friction that appears when a team switches between them. It assumes familiarity with HTTP requests and the basic structure of a test assertion.
Key point: cURL sends a request; Postman and Bruno run assertion suites. Postman stores collections in its cloud and runs them with Newman; Bruno stores requests as plain-text files in the repository and runs them with its CLI. Choose the storage model that matches how the team reviews codeโcloud-synced for collaboration, local-first for Git review.
Why API testing tools split into three categories
cURL is a transport, not a framework. A curl command is a single HTTP exchange. It has no concept of a collection, an environment, or an assertion. The --fail flag returns a non-zero exit code on HTTP errors, and --write-out can extract the status code or timing, but any actual test logic must be written in shell, and shell test scripts are brittle as they grow . The tool is the universal baseline: every API documentation page includes a curl example, browser dev tools can copy a request as curl, and both Postman and Bruno accept curl as import format .
Postman is a platform with a JavaScript runtime. The pm object exposes the request, the response, environment variables, and test assertions. The Collection Runner executes requests in sequence, passing data between them via variables. Newman is the headless runner that CI pipelines use, and the Postman CLI is its successor . The platform’s value is the integrated environment: one place for requests, environments, tests, mocks, and documentation. The cost is the cloud dependencyโcollections sync to Postman’s servers, and offline mode was removed in 2023 .
Bruno is a file format with a runner. Each request is a .bru file containing the method, URL, headers, body, and tests in a plain-text format. The collection is a directory tree, so Git diffs show exactly which request changed and how. The CLI (bru run) executes the collection and exits non-zero on test failure, which is the behavior a CI pipeline needs . The file-based model means no account, no sync, and no cloud dependency. The cost is ecosystem size: fewer integrations, fewer public collections, and a smaller community than Postman .
The trade-off. Postman’s collaboration features are valuable when the team is distributed and the collection is a shared artifact. Bruno’s Git-native storage is valuable when the collection should be reviewed in pull requests like code. cURL’s zero-dependency model is valuable when the request is a one-off check or a shell script. The tools overlap in capability but not in workflow, and the workflow is what determines which one fits.
a. cURL: the universal baseline
The curl command syntax maps directly to the HTTP request. -X sets the method, -H adds a header, -d sends a request body, and -i includes response headers in the output. -s silences the progress meter, -S shows errors even with -s, and -o writes the body to a file . The -w flag extracts information from the response, such as the status code or total time.
# Basic GET request with response headers.
curl -i https://api.example.com/v1/users/42
# POST with JSON body and authentication header.
curl -X POST https://api.example.com/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Rohit", "email": "rohit@example.com"}'
# Extract the status code for a shell check.
status=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health)
if [ "$status" -ne 200 ]; then
echo "Health check failed: $status"
exit 1
fi
The last example is the curl testing pattern: send the request, extract the status code with -w, and compare it in shell. It works for a health check. It does not scale to a suite of twenty endpoints with assertions on response bodies, because each assertion becomes another shell conditional and another place for quoting to break.
The --fail flag is a partial substitute for assertions: it returns exit code 22 on HTTP 4xx and 5xx, which a shell script can check. --fail-with-body does the same but still prints the response body, which is useful for diagnosing the failure . Neither flag can assert on a response field, which is where the shell-script approach runs out.
b. Postman: collections, the pm runtime, and Newman
A Postman collection is a JSON document containing requests, folders, variables, pre-request scripts, and test scripts. The Tests tab contains JavaScript that runs after the response arrives, using the pm object. pm.test defines a named assertion, pm.response exposes the response, and pm.expect provides Chai-style assertions.
// Postman Tests tab: status and body assertions.
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response has user id", function () {
const body = pm.response.json();
pm.expect(body).to.have.property("id");
pm.expect(body.id).to.be.a("number");
});
pm.test("Response time under 500ms", function () {
pm.expect(pm.response.responseTime).to.be.below(500);
});
The collection runner passes data between requests through variables: a login request sets pm.collectionVariables.set("token", body.token), and subsequent requests reference {{token}} in their headers . This chaining is what makes Postman collections useful for integration tests that span multiple endpoints.
Newman is the headless runner. The command newman run collection.json -e staging.json executes the collection against the staging environment and exits non-zero if any test fails. The Postman CLI is the successor and adds features that Newman lacks . The CI integration is a single step in the pipeline:
- name: Run API tests
run: |
newman run api-tests.postman_collection.json \
-e staging.postman_environment.json \
--reporters cli,junit \
--reporter-junit-export results.xml
The JUnit reporter produces a file that most CI systems can parse, which turns the collection into a standard test report.
c. Bruno: file-based collections and the bru CLI
A Bruno collection is a directory. Each request is a .bru file, and the format is plain text with sections for the request definition and the tests. The Tests tab uses the Chai expect syntax with res as the response object and bru for environment variables.
meta {
name: Get User
type: http
seq: 1
}
get {
url: {{baseUrl}}/v1/users/42
body: none
auth: bearer
}
auth:bearer {
token: {{token}}
}
tests {
test("status is 200", function() {
expect(res.status).to.equal(200);
});
test("body has user id", function() {
const body = res.getBody();
expect(body).to.have.property("id");
});
}
The file is readable, diffable, and reviewable in a pull request. A change to the URL or an added header appears as a line in the diff, not as a JSON blob. Environment variables live in environments/*.bru files, also plain text.
The CLI runs from the collection directory. bru run --env staging executes every request and its tests, and exits with code 1 if any test fails . The --reporter-junit flag produces a JUnit report, and --reporter-html produces an HTML report. The CI integration is a single step:
- name: Run Bruno API tests
run: |
bru run --env staging --reporter-junit results.xml
The import path from Postman is supported but not lossless. Postman scripts that use pm must be translated to res and bru, and authentication configurations may not transfer cleanly . The migration is a one-time cost, and the result is a collection that lives in the repository instead of a cloud service.
Complete Example Session
# ============================================
# PART 1: CURL โ ONE-OFF REQUEST
# ============================================
# Verify the endpoint responds.
curl -i https://api.example.com/v1/users/42
# POST with JSON and auth.
curl -X POST https://api.example.com/v1/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Rohit"}'
# ============================================
# PART 2: CURL โ SHELL HEALTH CHECK
# ============================================
status=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health)
[ "$status" -eq 200 ] || { echo "FAIL: $status"; exit 1; }
# ============================================
# PART 3: CURL โ IMPORT FORMAT
# ============================================
# Browser dev tools: Copy โ Copy as cURL.
# Postman: Import โ Raw text โ paste curl.
# Bruno: Import โ cURL โ paste.
# ============================================
# PART 4: POSTMAN โ TESTS TAB
# ============================================
# pm.test("Status code is 200", function () {
# pm.response.to.have.status(200);
# });
#
# pm.test("Has user id", function () {
# pm.expect(pm.response.json()).to.have.property("id");
# });
# ============================================
# PART 5: POSTMAN โ CHAINING REQUESTS
# ============================================
# Login request:
# const body = pm.response.json();
# pm.collectionVariables.set("token", body.token);
#
# Next request header:
# Authorization: Bearer {{token}}
# ============================================
# PART 6: POSTMAN โ NEWMAN IN CI
# ============================================
# newman run api.postman_collection.json \
# -e staging.postman_environment.json \
# --reporters cli,junit \
# --reporter-junit-export results.xml
# ============================================
# PART 7: BRUNO โ REQUEST FILE
# ============================================
# meta { name: Get User, type: http }
# get { url: {{baseUrl}}/v1/users/42 }
# tests {
# test("status", function() {
# expect(res.status).to.equal(200);
# });
# }
# ============================================
# PART 8: BRUNO โ CLI IN CI
# ============================================
# bru run --env staging --reporter-junit results.xml
# Exit code 1 on test failure โ CI blocks merge.
# ============================================
# PART 9: MIGRATION FRICTION
# ============================================
# Postman โ Bruno:
# - pm.test โ test, pm.response โ res.
# - pm.expect โ expect.
# - Collection variables โ environment files.
# - Auth config may not transfer; re-authenticate.
#
# Bruno โ Postman:
# - Reverse translation, similar friction.
# ============================================
# PART 10: THE DECISION FLOW
# ============================================
# One-off request or shell script? โ cURL.
# Team collaboration + ecosystem? โ Postman.
# Git review + local storage? โ Bruno.
# CI gate for any of them โ Newman / bru run.
Quick Reference
Tool Comparison
| Aspect | cURL | Postman | Bruno |
|---|---|---|---|
| Storage | None (command) | Cloud-synced | Local files (Git) |
| Account required | No | Yes | No |
| Test runtime | Shell | pm JavaScript | Chai JavaScript |
| Runner | Shell script | Newman / Postman CLI | bru run |
| Assertions | --fail, -w | pm.test | test |
| CI report | Shell exit code | JUnit via Newman | JUnit via bru run |
| Collaboration | None | Built-in | Via Git |
cURL Flags
| Flag | Purpose |
|---|---|
-X | HTTP method |
-H | Add header |
-d | Request body |
-i | Include response headers |
-s / -S | Silent / show errors |
-o | Write body to file |
-w "%{http_code}" | Extract status code |
--fail | Non-zero exit on 4xx/5xx |
Postman Test Syntax
| Object | Purpose |
|---|---|
pm.test(name, fn) | Define assertion |
pm.response | Response object |
pm.response.json() | Parse body |
pm.expect | Chai assertions |
pm.collectionVariables | Shared variables |
Bruno Test Syntax
| Object | Purpose |
|---|---|
test(name, fn) | Define assertion |
res.status | HTTP status |
res.getBody() | Parsed body |
bru | Environment access |
expect | Chai assertions |
Best Practices
โ Do This:
# Use curl for one-off checks and docs examples. // โ
curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health
# Store collections in Git for review. // โ
# Bruno: .bru files in the repository.
# Postman: export JSON and commit it.
# Run the collection runner in CI with JUnit output. // โ
newman run collection.json --reporters junit
bru run --reporter-junit results.xml
# Chain requests with variables, not hardcoded values. // โ
pm.collectionVariables.set("token", body.token)
โ Don’t Do This:
# Build a large test suite as a shell script. // โ
# Quoting breaks; assertions become unreadable.
# Keep collections only in Postman's cloud. // โ
# No code review; drift from the API.
# Commit hardcoded tokens in collection files. // โ
# Use environment files that are gitignored.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Shell test script unmaintainable | curl has no assertion framework | Move to Bruno or Postman |
| CI cannot run collection | Cloud login required | Use Newman/bru run with env files |
| Secrets committed | Environment files in Git | Gitignore environment files |
| Migration loses tests | pm vs res translation | Manually verify after import |
| Offline Postman fails | Login required since 2023 | Use Bruno for offline work |
Real-World Examples
1. cURL Health Check
curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health
2. cURL POST with Auth
curl -X POST https://api.example.com/v1/users \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "Rohit"}'
3. Postman Assertion
pm.test("Status 200", () => pm.response.to.have.status(200));
4. Postman Chaining
pm.collectionVariables.set("token", pm.response.json().token);
5. Newman in CI
newman run api.json -e staging.json --reporters junit
6. Bruno Request
get { url: {{baseUrl}}/v1/users/42 }
7. Bruno Test
test("status", () => expect(res.status).to.equal(200));
8. Bruno CLI
bru run --env staging --reporter-junit results.xml
9. Git Review of Bruno
git diff api/users/get-user.bru
10. Import cURL
# Postman/Bruno: Import โ paste curl command.
Visual
The Three Categories
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ TOOL CATEGORIES โ
โ โ
โ cURL: โ
โ Transport. Single request. โ
โ No collection. Shell script. โ
โ โ
โ Postman: โ
โ Platform. Cloud-synced collections. โ
โ pm runtime. Newman runner. โ
โ โ
โ Bruno: โ
โ File format. Git-native collections. โ
โ Chai tests. bru CLI. โ
โ โ
โ All three send HTTP requests. โ
โ They differ in where tests live. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Storage Model
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ WHERE DO TESTS LIVE? โ
โ โ
โ cURL: shell script, in repo. โ
โ Diffable. No assertion framework. โ
โ โ
โ Postman: JSON collection, cloud-synced. โ
โ Exportable. Review via export. โ
โ โ
โ Bruno: .bru files, in repo. โ
โ Diffable. PR-reviewed. โ
โ โ
โ Git-native + assertions โ Bruno. โ
โ Cloud collaboration + ecosystem โ Postman. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
CI Integration
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PIPELINE STEP โ
โ โ
โ Postman: โ
โ newman run collection.json โ
โ -e env.json --reporters junit โ
โ โ
โ Bruno: โ
โ bru run --env staging โ
โ --reporter-junit results.xml โ
โ โ
โ Both exit non-zero on test failure. โ
โ Both emit JUnit for CI parsing. โ
โ Merge blocked on failure. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Migration Friction
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ POSTMAN โ BRUNO โ
โ โ
โ pm.test("x", fn) โ test("x", fn) โ
โ pm.response โ res โ
โ pm.expect โ expect โ
โ pm.collectionVariables.set โ bru.setVar โ
โ โ
โ Auth config may not transfer. โ
โ Scripts need manual review after import. โ
โ One-time cost; result is Git-reviewable. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| cURL role | One-off requests, shell scripts, import format |
| Postman role | Cloud-synced collections, pm runtime |
| Bruno role | Git-native collections, Chai tests |
| Postman runner | Newman, Postman CLI |
| Bruno runner | bru run |
| cURL status check | -w "%{http_code}", --fail |
| Postman assertion | pm.test + pm.expect |
| Bruno assertion | test + expect |
| CI report format | JUnit (both Postman and Bruno) |
Key takeaways:
- The tools differ in where test definitions live, not in HTTP capability. cURL has no collection; Postman stores collections in its cloud; Bruno stores them as plain-text files in the repository.
- cURL is the universal baseline. Every API documentation page includes a curl example, browser dev tools copy requests as curl, and both Postman and Bruno import curl commands. Use it for one-off checks and shell health probes.
- Postman’s
pmruntime is the most mature test framework.pm.test,pm.expect, and the Collection Runner are well-documented and widely used. Newman runs collections in CI with JUnit output. - Bruno’s
.brufiles are reviewable in pull requests. The file-based format means a URL change or a new header appears as a diff line, not a JSON blob. Thebru runCLI exits non-zero on test failure, making it a CI gate. - The storage model determines the workflow. Cloud-synced collections require a login and a sync service; local files require Git but no account. The choice is about how the team reviews and collaborates, not about which tool can send a request.
- Migration between Postman and Bruno is not lossless.
pmscripts must be translated tores/bru, and auth configurations may not transfer. Plan for manual verification after import.
Remember: API testing tools are storage and execution models wrapped around HTTP. cURL sends a request and exits; Postman stores collections in its cloud and runs them with Newman; Bruno stores requests as files and runs them with bru run. All three can verify that an endpoint responds. Only the collection tools can express a suite of assertions that chains requests, passes tokens, and blocks a merge. Choose cURL for the one-off check, Postman for cloud collaboration and ecosystem breadth, and Bruno for Git-native collections that are reviewed like code. The test definitions are the artifact that matters; the tool is the container that holds them.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!