
Introduction π―
You know the workflow. Write a curl command. Copy-paste it. Run it. Check the output manually. Repeat. Wonder why the API broke in staging.
Hurl changes this. Itβs a command-line tool written in Rust that runs and tests HTTP requests using plain text files. Think of it as curl with built-in assertions β fast, scriptable, CI/CD-friendly, and zero dependencies.
# login.hurl β This is a complete API test
POST https://api.example.com/auth/login
{
"email": "user@example.com",
"password": "secretpass"
}
HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.token" matches /^[A-Za-z0-9_-]+$/
# Use the token in the next request
GET https://api.example.com/users/me
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "user@example.com"
jsonpath "$.role" != "guest"
Thatβs it. No framework. No config files. No npm install. Just a .hurl file and a terminal.
If youβve ever wished curl could assert results and chain requests, this article is for you. Weβll go from basic requests to production-ready API test suites. Letβs build. π
Part 1: Why Hurl?
1.1 The Problem with curl for Testing
curl is powerful but verbose for testing:
# You have to manually check the output
response=$(curl -s -w "\n%{http_code}" https://api.example.com/users/1)
status_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | head -n-1)
if [ "$status_code" != "200" ]; then
echo "FAIL: Expected 200, got $status_code"
exit 1
fi
# Now manually parse JSON...
email=$(echo "$body" | jq -r '.email')
if [ "$email" != "user@example.com" ]; then
echo "FAIL: Email mismatch"
exit 1
fi
1.2 The Problem with Postman/Newman
Postman is great for exploration but problematic for automation:
- GUI-heavy: Not scriptable without extra tooling
- Newman overhead: Requires Node.js,
package.json, collection JSON exports - Slow: Boot time, collection parsing, environment resolution
- Fragile: JSON exports break on schema changes
1.3 Hurlβs Advantages
| Feature | curl | Postman/Newman | Hurl |
|---|---|---|---|
| Setup | β Built-in | β npm install | β Single binary |
| Assertions | β Manual | β GUI | β Built-in |
| Chain requests | β οΈ Script it | β Tests tab | β Native |
| CI/CD | β οΈ Script it | β Newman | β Native |
| Speed | β Fast | β Slow | β Fast (Rust) |
| Syntax | π§ Flags | π JSON | π Plain text |
| Dependencies | β None | β Node.js | β None |
Part 2: Installation
# macOS
brew install hurl
# Windows
scoop install hurl
# Ubuntu/Debian
curl -LO https://github.com/Orange-OpenSource/hurl/releases/latest/download/hurl_latest_amd64.deb
sudo dpkg -i hurl_latest_amd64.deb
# Cargo (from source)
cargo install hurl
# Docker
docker pull ghcr.io/orange-opensource/hurl:latest
Verify installation:
hurl --version
# hurl 5.0.0 (x86_64-unknown-linux-gnu)
Part 3: Syntax Basics
3.1 Request Format
A Hurl file is a sequence of requests. Each request has a request section and an optional assertions section:
# Comments start with #
# Method + URL
GET https://api.example.com/health
# Optional request body
POST https://api.example.com/users
{
"name": "John",
"email": "john@example.com"
}
# Optional headers
PUT https://api.example.com/users/1
Content-Type: application/json
Authorization: Bearer token123
{
"name": "John Updated"
}
# Optional assertions
HTTP 200
[Asserts]
header "X-Request-Id" exists
jsonpath "$.id" == 1
3.2 Request Methods
GET /api/health
POST /api/users
PUT /api/users/1
PATCH /api/users/1
DELETE /api/users/1
OPTIONS /api/users
HEAD /api/users
3.3 Query Parameters
# Simple query params
GET https://api.example.com/search?q=kysely&page=1&limit=10
# Or use [QueryParams] section
GET https://api.example.com/search
[QueryParams]
q: kysely
page: 1
limit: 10
sort: created_at
order: desc
3.4 Request Body
# JSON body
POST https://api.example.com/users
{
"name": "John",
"email": "john@example.com"
}
# Form URL-encoded
POST https://api.example.com/login
[Form]
username: john
password: secret
# Multipart form data (file upload)
POST https://api.example.com/upload
[Form]
file: file.txt; contentType: text/plain
description: My document
# XML body (SOAP)
POST https://api.example.com/soap
Content-Type: application/xml
<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>
<GetUser>
<Id>1</Id>
</GetUser>
</soap:Body>
</soap:Envelope>
# Raw body
POST https://api.example.com/raw
`` `binary`
binary data here
`` ``
Part 4: Assertions β The Real Power
4.1 Status Code Assertions
GET https://api.example.com/health
HTTP 200
# Or use explicit assert
[Asserts]
status == 200
# Other status checks
GET https://api.example.com/protected
HTTP 401
GET https://api.example.com/not-found
HTTP 404
# Range assertion
GET https://api.example.com/anything
[Asserts]
status >= 200
status < 300
4.2 Header Assertions
GET https://api.example.com/health
[Asserts]
# Header exists
header "Content-Type" exists
header "X-Request-Id" exists
# Header value equals
header "Content-Type" == "application/json"
# Header contains
header "Content-Type" contains "json"
# Header matches regex
header "X-Request-Id" matches /^[a-f0-9-]{36}$/
# Header count
headers "Set-Cookie" count == 3
4.3 JSON Assertions (JSONPath)
This is where Hurl shines for API testing:
GET https://api.example.com/users/1
[Asserts]
# Simple value
jsonpath "$.id" == 1
jsonpath "$.name" == "John Doe"
jsonpath "$.email" == "john@example.com"
jsonpath "$.active" == true
jsonpath "$.score" == 98.5
jsonpath "$.address" == null
# Nested objects
jsonpath "$.address.city" == "New York"
jsonpath "$.address.geo.lat" == 40.7128
# Array access
jsonpath "$.tags[0]" == "admin"
jsonpath "$.tags[-1]" == "user"
# Array length
jsonpath "$.tags" count == 3
# Array contains
jsonpath "$.tags" contains "admin"
# Array matches each element
jsonpath "$.tags[*]" matches /^[a-z]+$/
# Exists / not exists
jsonpath "$.id" exists
jsonpath "$.deleted_at" not exists
# Type checks
jsonpath "$.id" is integer
jsonpath "$.name" is string
jsonpath "$.active" is boolean
jsonpath "$.tags" is array
jsonpath "$.address" is object
# Comparison operators
jsonpath "$.age" >= 18
jsonpath "$.score" > 90
jsonpath "$.balance" <= 1000
jsonpath "$.debt" < 0
4.4 XML Assertions (XPath)
POST https://api.example.com/soap
Content-Type: application/xml
<?xml version="1.0" encoding="UTF-8"?>
<GetUser><Id>1</Id></GetUser>
[Asserts]
xpath "//*[local-name()='Name']" == "John Doe"
xpath "count(//*[local-name()='Tag'])" == 3
xpath "//User/@id" == "1"
4.5 Regex Assertions
GET https://api.example.com/users/1
[Asserts]
# Body matches regex
body matches /"email":\s*"[^"]+@[^"]+"/
# Captures with regex (see Part 5)
body matches /"token":"(?P<token>[^"]+)"/
4.6 Duration Assertions
GET https://api.example.com/slow-endpoint
[Asserts]
# Response time under 500ms
duration < 500
# Exact timing
duration >= 100
duration <= 1000
Part 5: Captures β Chaining Requests
This is Hurlβs killer feature for API test automation. Capture values from one response and use them in subsequent requests:
5.1 Capturing Values
# Step 1: Login and capture token
POST https://api.example.com/auth/login
{
"email": "user@example.com",
"password": "secretpass"
}
HTTP 200
[Captures]
token: jsonpath "$.token"
user_id: jsonpath "$.user.id"
username: jsonpath "$.user.name"
# Step 2: Use captured values
GET https://api.example.com/users/{{user_id}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.name" == "{{username}}"
5.2 Capture Types
POST https://api.example.com/auth/login
{ "email": "user@example.com", "password": "pass" }
HTTP 200
[Captures]
# JSONPath capture
token: jsonpath "$.token"
# Header capture
request_id: header "X-Request-Id"
set_cookie: header "Set-Cookie"
# Regex capture
session_id: body matches /session_id=(?P<value>[a-f0-9]+)/
# XPath capture (XML responses)
user_name: xpath "//*[local-name()='Name']"
# Status capture
status_code: status
# Variable capture (from environment)
base_url: variable "BASE_URL"
5.3 Complex Chaining Example
# 1. Register
POST https://api.example.com/auth/register
{
"email": "test@example.com",
"password": "securepass123",
"name": "Test User"
}
HTTP 201
[Captures]
user_id: jsonpath "$.id"
reg_token: jsonpath "$.verification_token"
# 2. Verify email
GET https://api.example.com/auth/verify?token={{reg_token}}
HTTP 200
# 3. Login
POST https://api.example.com/auth/login
{
"email": "test@example.com",
"password": "securepass123"
}
HTTP 200
[Captures]
auth_token: jsonpath "$.token"
refresh_token: jsonpath "$.refresh_token"
# 4. Create a post
POST https://api.example.com/posts
Authorization: Bearer {{auth_token}}
{
"title": "My First Post",
"content": "Hello World!"
}
HTTP 201
[Captures]
post_id: jsonpath "$.id"
# 5. Verify the post exists
GET https://api.example.com/posts/{{post_id}}
Authorization: Bearer {{auth_token}}
HTTP 200
[Asserts]
jsonpath "$.title" == "My First Post"
jsonpath "$.author.id" == {{user_id}}
# 6. Delete the post
DELETE https://api.example.com/posts/{{post_id}}
Authorization: Bearer {{auth_token}}
HTTP 204
# 7. Confirm deletion
GET https://api.example.com/posts/{{post_id}}
Authorization: Bearer {{auth_token}}
HTTP 404
Part 6: Templating & Variables
6.1 Environment Variables
# Pass variables via command line
hurl --variable token=abc123 --variable user_id=1 test.hurl
# Or from environment
export TOKEN=abc123
export USER_ID=1
hurl --variable token=$TOKEN --variable user_id=$USER_ID test.hurl
Use in .hurl files:
GET https://api.example.com/users/{{user_id}}
Authorization: Bearer {{token}}
6.2 Variable Files
Create vars.env:
BASE_URL=https://api.example.com
TOKEN=abc123
USER_ID=1
hurl --variables-file vars.env test.hurl
6.3 Base URL
# Use --BaseUrl flag
GET /api/health
# Or define in file
GET https://api.example.com/api/health
hurl --base-url https://api.example.com test.hurl
Part 7: Structuring Test Suites
7.1 File-per-Endpoint
tests/
βββ auth/
β βββ login.hurl
β βββ register.hurl
β βββ refresh-token.hurl
βββ users/
β βββ get-user.hurl
β βββ update-user.hurl
β βββ delete-user.hurl
βββ posts/
β βββ create-post.hurl
β βββ list-posts.hurl
β βββ search-posts.hurl
βββ setup/
βββ seed-data.hurl
7.2 Request-per-File with Includes
tests/auth/login.hurl:
POST {{BASE_URL}}/auth/login
{
"email": "{{TEST_EMAIL}}",
"password": "{{TEST_PASSWORD}}"
}
HTTP 200
[Asserts]
jsonpath "$.token" exists
jsonpath "$.token" matches /^[A-Za-z0-9_-]+$/
tests/flows/user-lifecycle.hurl:
# Include setup
source auth/register.hurl
# Include main test
source users/get-user.hurl
# Include cleanup
source users/delete-user.hurl
7.3 CI/CD Integration
Makefile:
.PHONY: test test-api test-smoke test-regression
# Run all API tests
test-api:
hurl --test --color tests/**/*.hurl
# Smoke tests (critical path only)
test-smoke:
hurl --test tests/smoke/**/*.hurl
# Regression tests (full suite)
test-regression:
hurl --test --report-html api-test-results/ tests/**/*.hurl
# With environment
test-staging:
hurl --test \
--variables-file staging.env \
--base-url https://staging-api.example.com \
tests/**/*.hurl
# CI pipeline
ci-test:
hurl --test \
--variables-file ci.env \
--max-time 30 \
--retry 3 \
--report-junit api-results.xml \
tests/**/*.hurl
7.4 GitHub Actions
name: API Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
api-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- name: Install Hurl
run: |
curl -LO https://github.com/Orange-OpenSource/hurl/releases/latest/download/hurl_latest_amd64.deb
sudo dpkg -i hurl_latest_amd64.deb
- name: Start API server
run: |
npm install
npm run dev &
sleep 5
- name: Run API tests
run: |
hurl --test \
--variables-file ci.env \
--base-url http://localhost:3000 \
--max-time 10 \
--report-junit test-results.xml \
tests/**/*.hurl
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.xml
Part 8: Advanced Patterns
8.1 GraphQL Testing
# Query
POST https://api.example.com/graphql
Content-Type: application/json
{
"query": "{ users { id name email } }"
}
HTTP 200
[Asserts]
jsonpath "$.data.users" is array
jsonpath "$.data.users[0].id" exists
# Mutation with variables
POST https://api.example.com/graphql
Content-Type: application/json
{
"query": "mutation($input: CreateUserInput!) { createUser(input: $input) { id name } }",
"variables": {
"input": {
"name": "John",
"email": "john@example.com"
}
}
}
HTTP 200
[Asserts]
jsonpath "$.data.createUser.id" is integer
jsonpath "$.data.createUser.name" == "John"
8.2 Session-Based Authentication
# Login (sets session cookie automatically)
POST https://example.com/login
[Form]
username: admin
password: secret
HTTP 302
[Asserts]
header "Location" == "/dashboard"
# Subsequent request uses session cookie automatically
GET https://example.com/dashboard
HTTP 200
[Asserts]
body contains "Welcome, Admin"
8.3 Testing File Uploads
POST https://api.example.com/upload
Authorization: Bearer {{token}}
[Form]
file: ./test-files/document.pdf; contentType: application/pdf
metadata: {"name": "Test Doc", "tags": ["test"]}; contentType: application/json
HTTP 201
[Asserts]
jsonpath "$.id" exists
jsonpath "$.size" > 0
8.4 Testing with Cookies
# Set a cookie
GET https://api.example.com/set-cookie
[Cookies]
session_id: abc123
preference: dark
HTTP 200
# Cookie is sent automatically
GET https://api.example.com/check-cookie
HTTP 200
[Asserts]
body contains "session_id=abc123"
8.5 Retry & Timeout
# Retry failed requests (up to 3 times)
hurl --retry 3 --test tests/**/*.hurl
# Timeout per request (in seconds)
hurl --max-time 30 --test tests/**/*.hurl
# Connect timeout
hurl --connect-timeout 10 --test tests/**/*.hurl
8.6 Parallel Execution
# Run multiple files in parallel
hurl --test --parallel tests/**/*.hurl
# With job count
hurl --test --parallel --jobs 4 tests/**/*.hurl
Part 9: Reporting & Debugging
9.1 Output Formats
# Standard output (human-readable)
hurl tests/**/*.hurl
# JSON output
hurl --json tests/**/*.hurl
# JUnit XML (for CI/CD)
hurl --report-junit results.xml tests/**/*.hurl
# HTML report
hurl --report-html results/ tests/**/*.hurl
# TAP format
hurl --report-tap results.tap tests/**/*.hurl
9.2 Debugging Failed Tests
# Verbose output
hurl --verbose tests/auth/login.hurl
# Very verbose (shows TLS handshake, etc.)
hurl --very-verbose tests/auth/login.hurl
# Output response body on failure
hurl --error-format pretty tests/**/*.hurl
# Continue on failure (don't stop at first error)
hurl --fail-at-end tests/**/*.hurl
9.3 Pretty Output Example
$ hurl --verbose tests/auth/login.hurl
* Cookie file does not exist: /tmp/hurl-cookie
* Running Hurl tests
*
* Loading vars.env
*
* ---- input
*
* POST https://api.example.com/auth/login
*
> POST /auth/login
> Content-Type: application/json
> {
> "email": "user@example.com",
> "password": "****"
> }
>
* Trying 10.0.0.1:443...
* Connected to api.example.com
* SSL connection using TLSv1.3
*
< HTTP/1.1 200 OK
< Content-Type: application/json
< X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
< {
< "token": "eyJhbGciOiJIUzI1NiIs..."
< }
*
[Asserts] status == 200 β
[Asserts] header "Content-Type" == "application/json" β
[Asserts] jsonpath "$.token" exists β
[Captures] token = jsonpath "$.token" β
*
* ---- success β
*
* Hurl: Tests: 1/1 passed
Part 10: Real-World Test Suite
Hereβs a complete test suite for a typical REST API:
tests/auth/register-and-login.hurl
# Register new user
POST {{BASE_URL}}/auth/register
Content-Type: application/json
{
"email": "{{TEST_EMAIL}}",
"password": "{{TEST_PASSWORD}}",
"name": "Test User"
}
HTTP 201
[Asserts]
jsonpath "$.id" is integer
jsonpath "$.email" == "{{TEST_EMAIL}}"
jsonpath "$.name" == "Test User"
[Captures]
new_user_id: jsonpath "$.id"
# Login
POST {{BASE_URL}}/auth/login
Content-Type: application/json
{
"email": "{{TEST_EMAIL}}",
"password": "{{TEST_PASSWORD}}"
}
HTTP 200
[Asserts]
jsonpath "$.token" exists
jsonpath "$.token" matches /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
[Captures]
auth_token: jsonpath "$.token"
tests/users/crud.hurl
# Get user profile
GET {{BASE_URL}}/users/me
Authorization: Bearer {{auth_token}}
HTTP 200
[Asserts]
jsonpath "$.id" == {{new_user_id}}
jsonpath "$.email" == "{{TEST_EMAIL}}"
# Update profile
PATCH {{BASE_URL}}/users/me
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"name": "Updated User",
"bio": "Full-stack developer"
}
HTTP 200
[Asserts]
jsonpath "$.name" == "Updated User"
jsonpath "$.bio" == "Full-stack developer"
# Verify update persisted
GET {{BASE_URL}}/users/me
Authorization: Bearer {{auth_token}}
HTTP 200
[Asserts]
jsonpath "$.name" == "Updated User"
jsonpath "$.bio" == "Full-stack developer"
# List users (admin only)
GET {{BASE_URL}}/users?page=1&limit=10
Authorization: Bearer {{admin_token}}
HTTP 200
[Asserts]
jsonpath "$.data" is array
jsonpath "$.data" count >= 1
jsonpath "$.total" is integer
tests/users/unauthorized.hurl
# Access protected route without token
GET {{BASE_URL}}/users/me
HTTP 401
[Asserts]
jsonpath "$.error" == "Unauthorized"
# Access with invalid token
GET {{BASE_URL}}/users/me
Authorization: Bearer invalid-token-123
HTTP 401
[Asserts]
jsonpath "$.error" == "Invalid token"
# Access admin route as regular user
GET {{BASE_URL}}/admin/users
Authorization: Bearer {{auth_token}}
HTTP 403
[Asserts]
jsonpath "$.error" == "Forbidden"
tests/posts/search.hurl
# Create test posts
POST {{BASE_URL}}/posts
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"title": "Hurl Testing Guide",
"content": "Learn how to test APIs with Hurl",
"tags": ["testing", "api", "hurl"]
}
HTTP 201
[Captures]
post_id: jsonpath "$.id"
# Search by keyword
GET {{BASE_URL}}/posts/search?q=Hurl&limit=5
Authorization: Bearer {{auth_token}}
HTTP 200
[Asserts]
jsonpath "$.data" is array
jsonpath "$.data[0].title" contains "Hurl"
# Filter by tag
GET {{BASE_URL}}/posts?tag=testing
Authorization: Bearer {{auth_token}}
HTTP 200
[Asserts]
jsonpath "$.data" is array
jsonpath "$.data[*].tags" contains "testing"
# Cleanup
DELETE {{BASE_URL}}/posts/{{post_id}}
Authorization: Bearer {{auth_token}}
HTTP 204
Part 11: Hurl vs Other Tools
| Feature | curl | Postman | Newman | Hurl | httpie |
|---|---|---|---|---|---|
| Type | CLI tool | GUI app | CLI runner | CLI tool | CLI tool |
| Language | C | JavaScript | JavaScript | Rust | Python |
| Assertions | β | β | β | β | β |
| Chain requests | β οΈ Script | β | β | β | β |
| CI/CD native | β οΈ | β οΈ | β | β | β |
| Syntax | Flags | GUI/JSON | JSON | Plain text | Flags |
| Dependencies | None | Node.js | Node.js | None | Python |
| Speed | Fast | Slow | Medium | Fastest | Medium |
| Install | Pre-installed | App store | npm | Single binary | pip |
Part 12: Tips & Best Practices
12.1 Project Structure
api-tests/
βββ .hurlrc # Global Hurl config
βββ tests/
β βββ smoke/ # Critical path (run on every PR)
β β βββ health.hurl
β β βββ auth-flow.hurl
β βββ regression/ # Full suite (run before deploy)
β β βββ users/
β β βββ posts/
β β βββ search/
β βββ fixtures/ # Setup/teardown
β βββ seed-users.hurl
β βββ cleanup.hurl
βββ vars/
β βββ local.env
β βββ staging.env
β βββ production.env
βββ Makefile
12.2 Naming Conventions
# File names: {method}-{resource}.hurl
# GET /users β get-users.hurl
# POST /users β create-user.hurl
# PATCH /users/1 β update-user.hurl
# DELETE /users/1 β delete-user.hurl
# Or descriptive:
# auth-register.hurl
# auth-login.hurl
# users-crud.hurl
12.3 Security Testing
# SQL Injection test
GET https://api.example.com/users?id=1%27%20OR%201%3D1%20--
HTTP 200
[Asserts]
jsonpath "$.error" exists
jsonpath "$.users" not exists
# XSS test
POST https://api.example.com/comments
Content-Type: application/json
{
"content": "<script>alert('xss')</script>"
}
HTTP 200
[Asserts]
body contains "<script>"
# Rate limiting test
GET https://api.example.com/health
HTTP 200
GET https://api.example.com/health
HTTP 429
[Asserts]
header "Retry-After" exists
Conclusion
Hurl does one thing and does it exceptionally well: run and test HTTP requests from plain text files. Itβs not trying to be a full testing framework β itβs the missing piece between βcurl worksβ and βmy API is tested.β
Why adopt Hurl:
- Zero dependencies β single Rust binary, no Node.js, no Python
- Plain text syntax β readable by humans, diffable in Git, reviewable in PRs
- Built-in assertions β JSONPath, XPath, regex, headers, status, duration
- Request chaining β capture values, use in next request, complete workflows
- CI/CD native β JUnit XML reports, parallel execution, retry logic
- Fast β Rust-speed execution, no interpreter overhead
It wonβt replace Playwright for browser testing or Vitest for unit tests. But for API testing and automation? Itβs the simplest, fastest tool available.
Start with one .hurl file for your most critical endpoint. Run it. See the green checkmarks. Then add more. Before long, youβll have a complete API test suite that runs in seconds and catches regressions before they reach production.
Give it a try. Your CI pipeline will thank you. π
Further Reading
- Hurl Documentation
- Hurl GitHub
- Hurl Playground β Try Hurl in your browser
- Hurl vs curl β Feature comparison