02.07.2026 β€’ 14 min read

Hurl | API Testing and Automation with Plain Text Requests

Cover Image

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

FeaturecurlPostman/NewmanHurl
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

FeaturecurlPostmanNewmanHurlhttpie
TypeCLI toolGUI appCLI runnerCLI toolCLI tool
LanguageCJavaScriptJavaScriptRustPython
AssertionsβŒβœ…βœ…βœ…βŒ
Chain requests⚠️ Scriptβœ…βœ…βœ…βŒ
CI/CD nativeβš οΈβš οΈβœ…βœ…βŒ
SyntaxFlagsGUI/JSONJSONPlain textFlags
DependenciesNoneNode.jsNode.jsNonePython
SpeedFastSlowMediumFastestMedium
InstallPre-installedApp storenpmSingle binarypip

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 "&lt;script&gt;"

# 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