TL;DR:
- Hurl is a CLI tool that runs HTTP requests from plain-text
.hurlfiles and validates responses — requests are readable, responses are assertable, and everything runs in CI without GUI setup - The assertion syntax covers status codes, headers, JSON body values (with JSONPath), XML (XPath), cookies, SSL certificates, and response times in a format that’s faster to read than Postman collections
- Hurl works as both a functional testing tool and a documentation format — a
.hurlfile is a readable spec of what your API should do
Most API testing workflows have a painful split: you write tests in Postman or Bruno, those tests live outside your repo or as bloated JSON collections, and running them in CI requires either exporting/re-importing or setting up a test runner that’s separate from your regular test suite. The tests and the code drift apart over time.
Hurl solves this by being boring in the right way. It’s a CLI tool that reads .hurl files (plain text, human-readable) and runs HTTP requests with assertions. No GUI. No JSON collections. No setup beyond installing the binary. A .hurl file goes in your repo next to your code, runs in CI with hurl --test your-api.hurl, and fails the build if an assertion fails.
It won’t replace unit tests or end-to-end browser tests, but for the class of tests that verify “does my API actually return the right thing” — integration tests, contract tests, smoke tests against a staging environment — Hurl is faster to write and easier to maintain than most alternatives.
A Real Hurl File
Here’s what a basic Hurl file looks like for a typical REST API:
# Create a user
POST https://api.example.com/users
Content-Type: application/json
Authorization: Bearer {{token}}
{
"email": "test@example.com",
"name": "Test User"
}
HTTP 201
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.id" exists
jsonpath "$.email" == "test@example.com"
[Captures]
user_id: jsonpath "$.id"
# Fetch the created user
GET https://api.example.com/users/{{user_id}}
Authorization: Bearer {{token}}
HTTP 200
[Asserts]
jsonpath "$.email" == "test@example.com"
jsonpath "$.name" == "Test User"
# Delete the user
DELETE https://api.example.com/users/{{user_id}}
Authorization: Bearer {{token}}
HTTP 204
A few things worth noting:
Chaining requests with captures. The [Captures] section extracts values from one response and makes them available as variables in subsequent requests. The user_id captured from the POST response is automatically substituted into the GET and DELETE URLs. No external variable passing or scripting needed.
Environment variables. The {{token}} is a Hurl variable. You pass it at runtime: hurl --variable token=$API_TOKEN your-api.hurl. In CI, this maps naturally to secrets.
The assertion syntax is declarative and readable. jsonpath "$.email" == "test@example.com" is significantly clearer than the Postman equivalent test script (pm.expect(pm.response.json().email).to.equal("test@example.com")). A developer unfamiliar with the codebase can read a .hurl file and understand what the API is supposed to do.
Running in CI
Hurl produces JUnit XML output with --report-junit, which most CI systems ingest directly:
# GitHub Actions example
- name: Run API tests
run: |
hurl --test --report-junit report.xml \
--variable token=${{ secrets.API_TOKEN }} \
--variable base_url=${{ env.STAGING_URL }} \
tests/api/*.hurl
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: API Tests
path: report.xml
reporter: java-junit
The --test flag makes Hurl exit non-zero if any assertion fails. Without it, Hurl just runs the requests and shows the responses without failing on assertion errors — useful for exploratory testing, not for CI.
For running specific test files or patterns: hurl --test tests/api/users*.hurl. For running against different environments: parametrise the base URL as a variable and override it per environment.
Assertions Beyond JSON
Hurl covers more than JSON responses:
# Assert on response time (useful for performance regression testing)
GET https://api.example.com/health
HTTP 200
[Asserts]
duration < 200 # milliseconds
# Assert on headers
header "Cache-Control" matches "max-age=\d+"
header "X-Request-Id" exists
# Assert on XML
GET https://api.example.com/feed.xml
HTTP 200
[Asserts]
xpath "//item" count == 10
xpath "//title/text()" == "My Feed"
# Assert on cookies
header "Set-Cookie" contains "session="
cookie "session" exists
cookie "session[Secure]" exists
The SSL certificate assertions are particularly useful for infrastructure tests: certificate "Subject" contains "example.com", certificate "Expire-Date" daysAfterNow > 30.
Where Hurl Fits in Your Test Pyramid
Hurl isn’t a replacement for unit tests, and it’s not trying to be. It targets the integration layer: does the running API behave correctly? That means it runs against a running service — either a local development server, a CI environment with the service started, or a staging environment.
A practical structure: unit tests catch logic errors in isolation, Hurl tests verify that your routes, authentication, serialisation, and database interactions work correctly end-to-end, and your browser-based E2E tests (Playwright, Cypress) verify the user-facing flows. Hurl replaces the manual Postman testing that often fills the gap between unit tests and E2E tests, and does it in a way that runs automatically.
The .hurl files also serve as living documentation. A tests/api/users.hurl file is a readable spec of what the users API should do: what requests it accepts, what responses it returns, what fields are required. Because it runs in CI, it stays accurate.
Installation
Hurl is a single binary written in Rust. On macOS: brew install hurl. On Linux: the GitHub releases page has pre-built binaries and a .deb package. On Windows: winget install Hurl. Or via Docker: docker run --rm -it ghcr.io/Orange-OpenSource/hurl.
It’s maintained by Orange Open Source, has active development, and the curl underlying it means it handles the full range of HTTP edge cases without surprises.
The one missing feature compared to GUI tools is a visual request builder. If you’re exploring a new API and want to click through endpoints before writing tests, Bruno or the Hurl VS Code extension (which adds send-request buttons and syntax highlighting) fills that gap.