Hurl: Run and Test HTTP Requests from Plain Text
Hurl: Run and Test HTTP Requests from Plain Text
Testing HTTP APIs often means juggling between GUI clients, writing verbose test scripts, or maintaining brittle curl commands stitched together with shell scripts. Hurl takes an entirely different approach: you write your HTTP requests in plain text files, add assertions right alongside them, and run the whole thing from the command line. Built on top of libcurl, Hurl is fast, scriptable, and fits naturally into CI/CD pipelines.
Developed by Orange (the European telecom company) and released as open source, Hurl has quickly gained traction among developers who want the simplicity of curl with the testing power of a proper framework. If you have ever wished you could write HTTP request tests as easily as you write markdown, Hurl is worth your attention.
Why Hurl?
Before diving into installation and usage, it helps to understand what makes Hurl different from tools like Postman, Bruno, or even raw curl scripts.
Plain text format: Hurl files are human-readable text. No JSON configuration files, no GUI project exports, no binary formats. You can read a .hurl file and immediately understand what it does.
Assertions built in: Unlike curl, Hurl lets you assert on status codes, headers, body content, JSONPath queries, XPath expressions, regex matches, and more -- all within the same file as the request.
Request chaining: Capture values from one response and use them in subsequent requests. This is essential for testing flows like "create a resource, then fetch it, then delete it."
CI-friendly: Hurl outputs JUnit XML, TAP, and JSON reports. It returns proper exit codes. It runs in seconds, not minutes.
No runtime dependencies: Hurl is a single static binary. No Node.js, no Python, no Java runtime required.
Installation
Hurl provides packages for every major platform. Choose your preferred method:
macOS
brew install hurl
Ubuntu/Debian
curl -LO https://github.com/Orange-OpenSource/hurl/releases/latest/download/hurl_amd64.deb
sudo dpkg -i hurl_amd64.deb
Fedora/RHEL
curl -LO https://github.com/Orange-OpenSource/hurl/releases/latest/download/hurl-x86_64.rpm
sudo rpm -i hurl-x86_64.rpm
Arch Linux
pacman -S hurl
Cargo (any platform)
cargo install hurl
Docker
docker run --rm ghcr.io/orange-opensource/hurl:latest --version
Verify your installation:
hurl --version
Your First Hurl File
Create a file called basic.hurl:
GET https://httpbin.org/get
HTTP 200
[Asserts]
header "content-type" contains "application/json"
jsonpath "$.url" == "https://httpbin.org/get"
Run it:
hurl basic.hurl
That single file does three things: sends a GET request, checks the status code is 200, and runs two assertions on the response. If any assertion fails, Hurl exits with a non-zero code and tells you exactly what went wrong.
Hurl File Syntax
A Hurl file is a sequence of entries. Each entry is one HTTP request followed by optional response assertions. Let's break down the syntax.
Request Section
Every entry starts with an HTTP method and URL:
GET https://api.example.com/users
You can add headers, query parameters, and a request body:
POST https://api.example.com/users
Content-Type: application/json
Authorization: Bearer {{token}}
{
"name": "Alice",
"email": "[email protected]"
}
Query Parameters
Instead of encoding parameters in the URL, you can list them cleanly:
GET https://api.example.com/search
[QueryStringParams]
q: hurl testing
page: 1
limit: 20
Form Data
For URL-encoded form submissions:
POST https://api.example.com/login
[FormParams]
username: admin
password: secret123
For multipart form data with file uploads:
POST https://api.example.com/upload
[MultipartFormData]
file: file,profile.jpg; image/jpeg
description: Profile photo
Response Section
After the request, you specify expected response status and assertions:
HTTP 201
[Asserts]
header "Location" exists
jsonpath "$.id" isInteger
jsonpath "$.name" == "Alice"
Assertions
Hurl's assertion system is one of its strongest features. You can assert on virtually every aspect of the response.
Status Code
HTTP 200
HTTP 404
HTTP 500
Headers
[Asserts]
header "content-type" == "application/json; charset=utf-8"
header "cache-control" contains "max-age"
header "x-custom-header" exists
header "x-deprecated" not exists
Body Assertions
For plain text bodies:
[Asserts]
body contains "success"
body matches "order-[0-9]+"
JSONPath
JSONPath assertions are the bread and butter of API testing:
[Asserts]
jsonpath "$.data" count == 10
jsonpath "$.data[0].name" == "Alice"
jsonpath "$.data[0].age" >= 18
jsonpath "$.data[0].tags" includes "admin"
jsonpath "$.meta.total" isInteger
jsonpath "$.meta.total" > 0
XPath (for HTML/XML)
[Asserts]
xpath "//h1" == "Welcome"
xpath "count(//li)" == 5
Duration
You can even assert on response timing:
[Asserts]
duration < 500
This fails if the response takes longer than 500 milliseconds -- useful for performance regression testing.
Certificate and SSL
[Asserts]
certificate "Expire-Date" daysAfterNow > 30
Capturing Values
Real-world API testing means chaining requests. You create a resource, capture its ID, then use that ID in subsequent requests. Hurl handles this with captures.
# Step 1: Create a user
POST https://api.example.com/users
Content-Type: application/json
{
"name": "Bob",
"email": "[email protected]"
}
HTTP 201
[Captures]
user_id: jsonpath "$.id"
auth_token: header "X-Auth-Token"
# Step 2: Fetch the user we just created
GET https://api.example.com/users/{{user_id}}
Authorization: Bearer {{auth_token}}
HTTP 200
[Asserts]
jsonpath "$.name" == "Bob"
jsonpath "$.email" == "[email protected]"
# Step 3: Delete the user
DELETE https://api.example.com/users/{{user_id}}
Authorization: Bearer {{auth_token}}
HTTP 204
The [Captures] section grabs values from the response, and {{variable}} interpolates them into later requests. This three-step flow is a single Hurl file, executed top to bottom.
Capture Sources
You can capture from multiple parts of the response:
[Captures]
token: jsonpath "$.access_token"
location: header "Location"
csrf: xpath "//input[@name='csrf']/@value"
session: cookie "session_id"
body_text: body
regex_match: body regex "order-([0-9]+)"
Variables and Templating
Beyond captures, you can inject variables from the command line or environment:
hurl --variable host=https://staging.example.com \
--variable token=abc123 \
api-tests.hurl
In the Hurl file:
GET {{host}}/api/health
Authorization: Bearer {{token}}
HTTP 200
You can also use --variables-file to load variables from a properties file:
# vars.env
host=https://staging.example.com
token=abc123
[email protected]
hurl --variables-file vars.env api-tests.hurl
Real-World Testing Scenarios
Authentication Flow
# Login
POST https://api.example.com/auth/login
Content-Type: application/json
{
"email": "[email protected]",
"password": "testpass123"
}
HTTP 200
[Captures]
access_token: jsonpath "$.access_token"
refresh_token: jsonpath "$.refresh_token"
[Asserts]
jsonpath "$.expires_in" == 3600
# Access protected resource
GET https://api.example.com/me
Authorization: Bearer {{access_token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "[email protected]"
# Refresh the token
POST https://api.example.com/auth/refresh
Content-Type: application/json
{
"refresh_token": "{{refresh_token}}"
}
HTTP 200
[Captures]
new_access_token: jsonpath "$.access_token"
Testing Error Handling
# Missing required field
POST https://api.example.com/users
Content-Type: application/json
{
"email": "[email protected]"
}
HTTP 422
[Asserts]
jsonpath "$.errors[0].field" == "name"
jsonpath "$.errors[0].message" contains "required"
# Unauthorized access
GET https://api.example.com/admin/dashboard
HTTP 401
[Asserts]
jsonpath "$.error" == "unauthorized"
# Not found
GET https://api.example.com/users/99999999
HTTP 404
Pagination Testing
# First page
GET https://api.example.com/items
[QueryStringParams]
page: 1
per_page: 5
HTTP 200
[Captures]
total: jsonpath "$.meta.total"
[Asserts]
jsonpath "$.data" count == 5
jsonpath "$.meta.page" == 1
jsonpath "$.meta.has_next" == true
# Second page
GET https://api.example.com/items
[QueryStringParams]
page: 2
per_page: 5
HTTP 200
[Asserts]
jsonpath "$.meta.page" == 2
CI/CD Integration
Hurl was designed for CI from the start. Here is how to integrate it into your pipeline.
GitHub Actions
name: API Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Hurl
run: |
curl -LO https://github.com/Orange-OpenSource/hurl/releases/latest/download/hurl_amd64.deb
sudo dpkg -i hurl_amd64.deb
- name: Start API server
run: |
docker compose up -d
sleep 5
- name: Run API tests
run: |
hurl --test --report-junit results.xml \
--variable host=http://localhost:3000 \
tests/*.hurl
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: results.xml
GitLab CI
api-tests:
image: ghcr.io/orange-opensource/hurl:latest
script:
- hurl --test --report-junit results.xml tests/*.hurl
artifacts:
reports:
junit: results.xml
Command-Line Options for CI
# Run in test mode (summary output, non-zero exit on failure)
hurl --test tests/*.hurl
# Generate JUnit XML report
hurl --test --report-junit report.xml tests/*.hurl
# Generate HTML report
hurl --test --report-html report/ tests/*.hurl
# Run with retry on failure (useful for flaky external APIs)
hurl --test --retry 3 --retry-interval 2000 tests/*.hurl
# Set a global timeout
hurl --test --connect-timeout 5 --max-time 30 tests/*.hurl
# Verbose output for debugging
hurl --test --very-verbose tests/*.hurl
Hurl vs. Other Tools
Hurl vs. curl
curl is a transfer tool. Hurl is a testing tool built on libcurl. You get the same protocol support but with assertions, captures, chaining, and reporting built in. If you are currently writing shell scripts that pipe curl output to jq and then grep for values, Hurl replaces that entire pattern.
Hurl vs. Postman/Bruno
Postman and Bruno are GUI-first API clients with testing bolted on. Hurl is CLI-first with no GUI at all. If your team reviews API tests in pull requests, Hurl's plain text format makes diffs readable. If you need interactive exploration, Postman or Bruno are better choices for that phase; you can then translate your findings into Hurl files for automated testing.
Hurl vs. REST Client (VS Code)
The REST Client extension uses a similar .http file format for sending requests, but it lacks assertions, captures, and CI integration. Hurl files are close enough to .http format that you can often copy requests between them.
Hurl vs. pytest + requests
Python-based API testing gives you a full programming language, which is both a strength and a weakness. Hurl's declarative format means less code to maintain, and non-developers can read and write the tests. For complex conditional logic, a programming language is better; for straightforward request-response testing, Hurl is simpler.
Tips and Best Practices
Organize tests by feature: Keep one Hurl file per feature or endpoint group. Name them descriptively: auth-flow.hurl, user-crud.hurl, search-api.hurl.
Use variables for environments: Never hardcode URLs. Always use {{host}} and pass the value at runtime. This lets you run the same tests against dev, staging, and production.
Test error cases explicitly: Do not just test the happy path. Write entries for 400, 401, 403, 404, and 422 responses. These are the cases that break in production.
Add duration assertions for critical paths: If your login endpoint must respond in under 200ms, assert it. Catch performance regressions before they ship.
Keep Hurl files in your API repo: Store tests next to the code they test. They should be part of the same pull request as the API changes.
Use the --very-verbose flag for debugging: When a test fails, rerun with --very-verbose to see the full request and response, including headers and timing.
Conclusion
Hurl brings the simplicity of curl to the world of API testing. Its plain text format is easy to write, easy to review, and easy to maintain. Assertions and captures eliminate the need for shell script glue. CI integration with JUnit reporting makes it a natural fit for automated pipelines. If your current API testing setup involves too many moving parts, Hurl is a compelling alternative that does one thing and does it well.