| |

REST API 20 ๐ŸŒ API Testing with Postman, Bruno, and cURL

โฑ๏ธ Estimated read: 15 min | |

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

AspectcURLPostmanBruno
StorageNone (command)Cloud-syncedLocal files (Git)
Account requiredNoYesNo
Test runtimeShellpm JavaScriptChai JavaScript
RunnerShell scriptNewman / Postman CLIbru run
Assertions--fail, -wpm.testtest
CI reportShell exit codeJUnit via NewmanJUnit via bru run
CollaborationNoneBuilt-inVia Git

cURL Flags

FlagPurpose
-XHTTP method
-HAdd header
-dRequest body
-iInclude response headers
-s / -SSilent / show errors
-oWrite body to file
-w "%{http_code}"Extract status code
--failNon-zero exit on 4xx/5xx

Postman Test Syntax

ObjectPurpose
pm.test(name, fn)Define assertion
pm.responseResponse object
pm.response.json()Parse body
pm.expectChai assertions
pm.collectionVariablesShared variables

Bruno Test Syntax

ObjectPurpose
test(name, fn)Define assertion
res.statusHTTP status
res.getBody()Parsed body
bruEnvironment access
expectChai 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

PitfallWhy It HappensFix
Shell test script unmaintainablecurl has no assertion frameworkMove to Bruno or Postman
CI cannot run collectionCloud login requiredUse Newman/bru run with env files
Secrets committedEnvironment files in GitGitignore environment files
Migration loses testspm vs res translationManually verify after import
Offline Postman failsLogin required since 2023Use 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

ItemValue
cURL roleOne-off requests, shell scripts, import format
Postman roleCloud-synced collections, pm runtime
Bruno roleGit-native collections, Chai tests
Postman runnerNewman, Postman CLI
Bruno runnerbru run
cURL status check-w "%{http_code}", --fail
Postman assertionpm.test + pm.expect
Bruno assertiontest + expect
CI report formatJUnit (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 pm runtime 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 .bru files 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. The bru run CLI 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. pm scripts must be translated to res/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!