API Testing Tools Guide: Bruno, Hoppscotch, HTTPie, and curl
API Testing Tools Guide: Bruno, Hoppscotch, HTTPie, and curl
The era of Postman as the default API client is over. Between cloud-gated features, mandatory accounts, and increasingly aggressive upselling, developers have moved on. The alternatives are better in almost every way: faster, open source, git-friendly, and designed for how developers actually work. This guide covers four tools that cover the spectrum from GUI clients to command-line utilities, with practical examples for each.

Quick Comparison
| Feature | Bruno | Hoppscotch | HTTPie | curl |
|---|---|---|---|---|
| Type | Desktop GUI | Web + Desktop | CLI + Web | CLI |
| Open source | Yes (MIT) | Yes (MIT) | Partially (CLI is OSS) | Yes |
| Storage | Filesystem (git-friendly) | Cloud or local | History file | None (stdin/stdout) |
| Scripting | JavaScript | JavaScript | N/A | Bash |
| Environment management | Yes (.env files) | Yes (GUI) | Yes (sessions) | Manual (variables) |
| Collection testing | Yes | Yes | N/A | Shell scripts |
| CI integration | CLI runner | CLI runner | Direct | Direct |
| Collaboration | Git (shared collections) | Cloud sync or git | N/A | Scripts in repo |
| Offline support | Full | Desktop app only | Full | Full |
| Price | Free | Free (self-host) or cloud | Free (CLI) / $8/mo (web) | Free |
Bruno: The Git-Native API Client
Bruno stores everything as plain files on your filesystem. No cloud sync, no accounts, no lock-in. Your API collections live in your git repository alongside your code, reviewed in PRs just like any other code change.
Collection Structure
api-collections/
├── collection.bru
├── environments/
│ ├── local.bru
│ ├── staging.bru
│ └── production.bru
├── auth/
│ ├── login.bru
│ ├── refresh-token.bru
│ └── register.bru
├── users/
│ ├── get-user.bru
│ ├── list-users.bru
│ ├── create-user.bru
│ └── update-user.bru
└── orders/
├── create-order.bru
├── get-order.bru
└── list-orders.bru
Request Files
Each .bru file is a plain text file describing a single request:
meta {
name: Create User
type: http
seq: 1
}
post {
url: {{baseUrl}}/api/users
body: json
auth: bearer {{accessToken}}
}
headers {
Content-Type: application/json
X-Request-ID: {{$guid}}
}
body:json {
{
"name": "Alice Johnson",
"email": "[email protected]",
"role": "admin"
}
}
script:pre-request {
// Run before the request
const timestamp = new Date().toISOString();
bru.setVar("requestTime", timestamp);
}
script:post-response {
// Run after the response
if (res.status === 201) {
bru.setEnvVar("lastUserId", res.body.id);
}
}
tests {
test("should return 201", function() {
expect(res.status).to.equal(201);
});
test("should return user with id", function() {
expect(res.body.id).to.be.a("string");
expect(res.body.name).to.equal("Alice Johnson");
});
test("should set created timestamp", function() {
expect(res.body.createdAt).to.be.a("string");
});
}
Environment Files
// environments/local.bru
vars {
baseUrl: http://localhost:3000
accessToken: dev-token-12345
}
vars:secret [
dbConnectionString
]
// environments/staging.bru
vars {
baseUrl: https://api-staging.example.com
accessToken: {{process.env.STAGING_TOKEN}}
}
Running Collections in CI
# Install the Bruno CLI
npm install -g @usebruno/cli
# Run all requests in a collection
bru run --env local
# Run a specific folder
bru run auth/ --env local
# Run with a specific environment and output JUnit XML
bru run --env staging --output results.xml --format junit
CI Integration with GitHub Actions
name: API Tests
on: [push]
jobs:
api-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm install -g @usebruno/cli
- name: Start API server
run: |
npm start &
sleep 5
- name: Run API tests
run: bru run api-collections/ --env local --output results.xml --format junit
- uses: dorny/test-reporter@v1
if: always()
with:
name: API Test Results
path: results.xml
reporter: jest-junit
When to Choose Bruno
- You want API collections version-controlled in git
- Collaboration happens through PRs, not cloud sync
- You need offline support with zero cloud dependency
- You want pre/post-request scripting and test assertions
- Migrating from Postman (Bruno imports Postman collections)
Hoppscotch: The Fast, Minimal Web Client
Hoppscotch (formerly Postwoman) is a lightweight, web-based API client. It loads instantly, works in any browser, and can be self-hosted. The desktop app adds local file access and system proxy support.
Key Features
Hoppscotch is opinionated about staying fast. The interface is stripped down to essentials:
# Quick request from the web app:
# 1. Open https://hoppscotch.io
# 2. Set method and URL
# 3. Hit Send
# No account required for basic use
Request Scripting
Hoppscotch uses pre-request and post-request scripts, similar to Postman:
// Pre-request script
const timestamp = Date.now();
pw.env.set("timestamp", timestamp.toString());
// Generate HMAC signature for authenticated API
const crypto = require("crypto-js");
const secret = pw.env.get("apiSecret");
const message = `${pw.env.get("apiKey")}:${timestamp}`;
const signature = crypto.HmacSHA256(message, secret).toString();
pw.env.set("signature", signature);
// Post-request script
const response = pw.response.body;
// Chain requests: save token for subsequent calls
if (response.token) {
pw.env.set("authToken", response.token);
}
// Assertions
pw.test("Status is 200", () => {
pw.expect(pw.response.status).toBe(200);
});
pw.test("Response has data array", () => {
pw.expect(response.data).toBeType("array");
pw.expect(response.data.length).toBeGreaterThan(0);
});
Self-Hosting
# Docker (single container)
docker run -d \
--name hoppscotch \
-p 3000:3000 \
-p 3100:3100 \
-p 3170:3170 \
-e DATABASE_URL="postgresql://user:pass@db:5432/hoppscotch" \
-e JWT_SECRET="your-secret" \
-e REDIRECT_URL="http://localhost:3000" \
-e WHITELISTED_ORIGINS="http://localhost:3000" \
hoppscotch/hoppscotch:latest
# Docker Compose (full setup with database)
# See: https://github.com/hoppscotch/hoppscotch/blob/main/docker-compose.yml
When to Choose Hoppscotch
- You want an instant, browser-based experience with no installation
- You need a self-hosted Postman alternative for your organization
- Real-time WebSocket and GraphQL testing are important
- You prefer a minimal, fast interface over feature-rich desktop apps
HTTPie: The Developer-Friendly CLI
HTTPie makes HTTP requests from the command line feel natural. Where curl requires you to remember flags and quoting rules, HTTPie uses intuitive syntax that reads almost like English.
Basic Requests
# GET request (HTTPie adds https:// and Accept: application/json by default)
http GET api.example.com/users
# POST with JSON body (key=value becomes JSON automatically)
http POST api.example.com/users name="Alice" email="[email protected]" age:=30
# Note: = for strings, := for raw JSON (numbers, booleans, arrays)
http POST api.example.com/users \
name="Bob" \
active:=true \
tags:='["admin", "beta"]' \
metadata:='{"source": "api"}'
# Headers
http GET api.example.com/users Authorization:"Bearer token123" Accept:application/json
# Query parameters
http GET api.example.com/users page==2 limit==50 sort==name
Comparison: HTTPie vs curl
The same request in both tools:
# curl (verbose, hard to read)
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-H "Authorization: Bearer token123" \
-d '{"name": "Alice", "email": "[email protected]", "role": "admin"}'
# HTTPie (concise, readable)
http POST api.example.com/users \
Authorization:"Bearer token123" \
name="Alice" \
email="[email protected]" \
role="admin"
Sessions (Persistent State)
HTTPie sessions persist headers, cookies, and auth across requests:
# Create a named session and login
http --session=dev POST api.example.com/auth/login \
email="[email protected]" \
password="secret"
# Subsequent requests reuse the session (cookies, headers)
http --session=dev GET api.example.com/users
# The session stores cookies automatically
# No need to manually copy-paste tokens between requests
File Upload and Download
# Upload a file
http POST api.example.com/upload file@./report.pdf
# Upload with form fields
http --form POST api.example.com/documents \
file@./report.pdf \
title="Q4 Report" \
category="finance"
# Download a file
http --download GET api.example.com/reports/q4.pdf
# Pipe response to a file
http GET api.example.com/data > output.json
Scripting with HTTPie
#!/bin/bash
# API health check script using HTTPie
ENDPOINTS=(
"api.example.com/health"
"api.example.com/users"
"api.example.com/orders"
)
for endpoint in "${ENDPOINTS[@]}"; do
status=$(http --check-status --print=h HEAD "$endpoint" 2>/dev/null | head -1)
if [ $? -eq 0 ]; then
echo "OK: $endpoint"
else
echo "FAIL: $endpoint"
fi
done
When to Choose HTTPie
- You live in the terminal and want a human-friendly HTTP client
- You are tired of curl's flag syntax
- You need persistent sessions for API exploration
- You want pretty-printed, colorized JSON output by default
curl: The Universal Standard
curl is installed on virtually every Unix system. It is the lingua franca of HTTP examples -- every API's documentation includes curl examples. It is not the friendliest tool, but it is the most universal and powerful.
Essential curl Commands
# GET request
curl https://api.example.com/users
# POST with JSON
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "[email protected]"}'
# POST with data from a file
curl -X POST https://api.example.com/users \
-H "Content-Type: application/json" \
-d @user.json
# Include response headers in output
curl -i https://api.example.com/users
# Only show response headers
curl -I https://api.example.com/users
# Verbose mode (shows request/response headers, TLS handshake)
curl -v https://api.example.com/users
# Silent mode (no progress bar)
curl -s https://api.example.com/users | jq .
# Follow redirects
curl -L https://example.com/old-endpoint
# Set timeout
curl --max-time 10 https://api.example.com/slow-endpoint
Authentication
# Bearer token
curl -H "Authorization: Bearer eyJhbGci..." https://api.example.com/users
# Basic auth
curl -u username:password https://api.example.com/admin
# API key in header
curl -H "X-API-Key: abc123" https://api.example.com/data
# Client certificate
curl --cert client.pem --key client-key.pem https://api.example.com/secure
Advanced curl Patterns
# Timing breakdown (useful for performance debugging)
curl -w "\nDNS: %{time_namelookup}s\nConnect: %{time_connect}s\nTLS: %{time_appconnect}s\nFirst byte: %{time_starttransfer}s\nTotal: %{time_total}s\n" \
-o /dev/null -s https://api.example.com/users
# Output:
# DNS: 0.012s
# Connect: 0.045s
# TLS: 0.123s
# First byte: 0.234s
# Total: 0.267s
# Retry with exponential backoff
curl --retry 3 --retry-delay 2 --retry-all-errors \
https://api.example.com/flaky-endpoint
# Send multipart form data
curl -F "[email protected]" \
-F "title=Q4 Report" \
-F "category=finance" \
https://api.example.com/upload
# Test multiple endpoints in parallel
curl --parallel --parallel-max 5 \
https://api.example.com/users \
https://api.example.com/orders \
https://api.example.com/products
curl in CI Pipelines
#!/bin/bash
# API smoke test for CI
BASE_URL="${API_URL:-http://localhost:3000}"
check_endpoint() {
local path=$1
local expected_status=$2
local actual_status
actual_status=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}${path}")
if [ "$actual_status" = "$expected_status" ]; then
echo "PASS: ${path} (${actual_status})"
return 0
else
echo "FAIL: ${path} expected ${expected_status}, got ${actual_status}"
return 1
fi
}
failures=0
check_endpoint "/health" "200" || ((failures++))
check_endpoint "/api/users" "401" || ((failures++)) # Should require auth
check_endpoint "/api/nonexistent" "404" || ((failures++))
if [ $failures -gt 0 ]; then
echo "${failures} endpoint(s) failed"
exit 1
fi
echo "All endpoints passed"
When to Choose curl
- You need the most portable, universally available HTTP client
- You are writing CI scripts that cannot assume any tool beyond curl and jq
- You need advanced features: client certificates, proxy support, timing analysis
- You are writing documentation (curl examples are universally understood)
Environment Management Patterns
Managing different environments (local, staging, production) is a critical part of API testing. Each tool handles this differently.
Bruno: File-Based Environments
// environments/local.bru
vars {
baseUrl: http://localhost:3000
apiKey: dev-key-123
}
// environments/staging.bru
vars {
baseUrl: https://staging-api.example.com
apiKey: {{process.env.STAGING_API_KEY}}
}
# Switch environments from CLI
bru run --env local
bru run --env staging
HTTPie: Named Sessions
# Each session stores state independently
http --session=local GET localhost:3000/users
http --session=staging GET staging-api.example.com/users
curl: Shell Variables and .env Files
# .env.local
API_URL=http://localhost:3000
API_KEY=dev-key-123
# .env.staging
API_URL=https://staging-api.example.com
API_KEY=staging-key-456
# Usage
source .env.local
curl -H "X-API-Key: ${API_KEY}" "${API_URL}/users"
Shared Pattern: dotenv + Makefile
A pattern that works with any CLI tool:
# Makefile
ENV ?= local
include .env.$(ENV)
export
test-api:
bru run api-tests/ --env $(ENV)
smoke-test:
./scripts/smoke-test.sh
health-check:
curl -sf $(API_URL)/health && echo "OK" || echo "FAIL"
# Usage
make test-api ENV=local
make test-api ENV=staging
make smoke-test ENV=production
Request Chaining and Workflow Testing
Real API testing involves sequences: authenticate, create a resource, verify it, update it, delete it. Here is how to build that workflow in Bruno and with shell scripts.
Bruno: Chained Requests
// 1-login.bru
meta {
name: Login
seq: 1
}
post {
url: {{baseUrl}}/auth/login
body: json
}
body:json {
{
"email": "[email protected]",
"password": "{{adminPassword}}"
}
}
script:post-response {
bru.setEnvVar("accessToken", res.body.token);
}
// 2-create-user.bru
meta {
name: Create User
seq: 2
}
post {
url: {{baseUrl}}/users
body: json
auth: bearer {{accessToken}}
}
body:json {
{
"name": "Test User {{$timestamp}}",
"email": "test-{{$randomInt}}@example.com"
}
}
script:post-response {
bru.setEnvVar("testUserId", res.body.id);
}
tests {
test("user created", function() {
expect(res.status).to.equal(201);
});
}
// 3-verify-user.bru
meta {
name: Verify User
seq: 3
}
get {
url: {{baseUrl}}/users/{{testUserId}}
auth: bearer {{accessToken}}
}
tests {
test("user exists", function() {
expect(res.status).to.equal(200);
expect(res.body.id).to.equal(bru.getEnvVar("testUserId"));
});
}
Shell: curl + jq Workflow
#!/bin/bash
set -euo pipefail
BASE_URL="http://localhost:3000"
# Step 1: Login
echo "Logging in..."
LOGIN_RESPONSE=$(curl -s -X POST "${BASE_URL}/auth/login" \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "secret"}')
TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.token')
# Step 2: Create user
echo "Creating user..."
CREATE_RESPONSE=$(curl -s -X POST "${BASE_URL}/users" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"name": "Test User", "email": "[email protected]"}')
USER_ID=$(echo "$CREATE_RESPONSE" | jq -r '.id')
# Step 3: Verify
echo "Verifying user ${USER_ID}..."
GET_RESPONSE=$(curl -s "${BASE_URL}/users/${USER_ID}" \
-H "Authorization: Bearer ${TOKEN}")
FETCHED_NAME=$(echo "$GET_RESPONSE" | jq -r '.name')
if [ "$FETCHED_NAME" = "Test User" ]; then
echo "PASS: User created and verified"
else
echo "FAIL: Expected 'Test User', got '${FETCHED_NAME}'"
exit 1
fi
# Step 4: Cleanup
curl -s -X DELETE "${BASE_URL}/users/${USER_ID}" \
-H "Authorization: Bearer ${TOKEN}" > /dev/null
echo "Cleanup complete"
Decision Framework
| If you need... | Choose |
|---|---|
| Git-friendly API collections with tests | Bruno |
| Browser-based, instant, no install | Hoppscotch |
| Human-readable CLI for daily API work | HTTPie |
| Maximum portability and scripting power | curl |
| CI smoke tests with zero dependencies | curl |
| Team collaboration without cloud vendor | Bruno (git) or Hoppscotch (self-hosted) |
| Postman migration | Bruno (imports Postman collections) |
Combining Tools
In practice, most developers use multiple tools:
- Bruno for organized, versioned API test suites that run in CI
- HTTPie for quick ad-hoc requests during development
- curl for scripts, CI pipelines, and documentation examples
- Hoppscotch for quick browser-based testing when you do not want to open a terminal
There is no conflict between these tools. Use Bruno for structured test collections, HTTPie for interactive exploration, and curl when you need maximum portability. The APIs do not care which client sends the requests.
Summary
The API testing tool landscape has fragmented in a healthy way. Bruno gives you git-native collections that live alongside your code. Hoppscotch provides an instant, browser-based experience you can self-host. HTTPie makes the command line feel human. And curl remains the universal constant -- available everywhere, capable of everything. Pick the GUI client that fits your workflow (Bruno for teams, Hoppscotch for speed), add HTTPie for comfortable terminal work, and keep curl in your back pocket for scripts and CI. The best API testing setup uses multiple tools, each for what it does best.