CI Integration Patterns for HTTP API Testing DSLs
Writing HTTP API tests is the easy part. The test files exist, the assertions look right, and they pass on your laptop. Then CI comes into play and everything falls apart: secrets are not available, the test environment is not ready, tests run in the wrong order, results do not appear in the PR interface, and failures do not block deployments.
CI integration for HTTP API testing DSLs — tools like Hurl, httpyac, and the .http file format — requires thinking through a set of problems that are not covered in the tool documentation. This post covers those problems and the patterns that solve them, drawn from real pipeline configurations across GitHub Actions, GitLab CI, and CircleCI.
The Core CI Challenges
Running .http, .hurl, or httpyac files in CI is not simply "install the tool, run the files." The challenges are:
- Environment targeting — tests must hit the right environment (staging, not production)
- Secrets management — tokens and credentials cannot live in the test files
- Test data — the database must have predictable data for assertions to work
- Service readiness — the API must be deployed and healthy before tests run
- Test ordering — some tests depend on others (create before read)
- Parallelism — large test suites need to run concurrently without interfering
- Result reporting — failures need to appear in the PR interface, not just the CI log
- Deployment gating — test failures must block deploys, not just produce warnings
Each of these has solutions. Working through them in order builds a reliable CI pipeline.
Environment Targeting
HTTP API tests are environment-specific at the URL level. The cleanest approach is parameterizing the base URL and passing it as a CI variable.
For Hurl:
GET https://{{base_url}}/api/health
HTTP 200hurl --variable base_url=$STAGING_BASE_URL tests/**/*.hurlFor httpyac:
### Health check
GET {{baseUrl}}/api/healthhttpyac run tests/**/*.http --env stagingWith httpyac's environment system, staging maps to an environment block in .env.json:
{
"staging": {
"baseUrl": "https://api.staging.example.com"
},
"production": {
"baseUrl": "https://api.example.com"
}
}Never hard-code environment-specific URLs in test files. A test that only works against one environment is a test that cannot be reused and will fail when someone runs it differently.
Secrets Management
Secrets — API tokens, passwords, service credentials — must come from CI's secret store, not from test files or repository configuration.
Pattern: Environment variable injection
Both Hurl and httpyac read from environment variables. Pass secrets through CI environment variables and reference them in tests:
# Hurl: reference env vars with $processEnv pseudo-variable
GET https://{{base_url}}/api/protected
Authorization: Bearer {{token}}
HTTP 200# GitHub Actions step
- name: Run API tests
<span class="hljs-built_in">env:
STAGING_TOKEN: <span class="hljs-variable">${{ secrets.STAGING_API_TOKEN }}
BASE_URL: <span class="hljs-variable">${{ secrets.STAGING_BASE_URL }}
run: <span class="hljs-pipe">|
hurl --variable base_url=<span class="hljs-variable">$BASE_URL \
--variable token=<span class="hljs-variable">$STAGING_TOKEN \
tests/**/*.hurl# httpyac: use $processEnv
@token = {{$processEnv STAGING_TOKEN}}
GET {{baseUrl}}/api/protected
Authorization: Bearer {{token}}Pattern: Secrets file generation
For tools that read from a file (httpyac's .env.json), generate the file from CI secrets during the build step rather than committing it:
- name: Create test environment config
run: |
cat > tests/.env.json << EOF
{
"ci": {
"baseUrl": "${{ secrets.STAGING_BASE_URL }}",
"token": "${{ secrets.STAGING_TOKEN }}",
"adminToken": "${{ secrets.STAGING_ADMIN_TOKEN }}"
}
}
EOFNever log secrets. GitHub Actions redacts known secrets automatically, but custom scripts may print them. Avoid echo $TOKEN or set -x in steps that handle credentials.
Test Data Management
API tests that assert specific values need predictable data. Three patterns cover most cases:
Pattern 1: Long-lived static fixtures
Create specific accounts and resources in the staging environment that persist across test runs. Tests reference them by known IDs. Simple, reliable, no setup overhead.
Downside: Fixtures can drift if tests accidentally modify them. Protect fixture data with a dedicated service account that has limited write permissions.
Pattern 2: Setup and teardown in CI
A CI step before the test run creates required data and exports identifiers as environment variables:
- name: Seed test data
id: seed
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
ADMIN_TOKEN: ${{ secrets.STAGING_ADMIN_TOKEN }}
run: |
RESPONSE=$(curl -sf -X POST "$BASE_URL/api/test/seed" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scenario": "api-tests"}')
echo "TEST_USER_ID=$(echo $RESPONSE | jq -r '.userId')" >> $GITHUB_ENV
echo "TEST_ORG_ID=$(echo $RESPONSE | jq -r '.orgId')" >> $GITHUB_ENV
- name: Run tests
env:
TEST_USER_ID: ${{ env.TEST_USER_ID }}
TEST_ORG_ID: ${{ env.TEST_ORG_ID }}
run: |
hurl --variable base_url=$BASE_URL \
--variable test_user_id=$TEST_USER_ID \
--variable test_org_id=$TEST_ORG_ID \
tests/**/*.hurl
- name: Cleanup test data
if: always()
run: |
curl -sf -X DELETE "$BASE_URL/api/test/seed" \
-H "Authorization: Bearer $ADMIN_TOKEN"The if: always() on cleanup ensures it runs even when tests fail.
Pattern 3: Database snapshots
Before tests, restore the staging database from a known-good snapshot. After tests, the database is in a predictable state for the next run. This is the most robust approach but requires infrastructure for snapshot management. Worth the investment for large test suites.
Service Readiness Checks
Tests will fail if they start before the API is ready. After a deployment, wait for the health check to succeed before running tests:
- name: Wait for staging to be ready
run: |
MAX_ATTEMPTS=30
ATTEMPT=0
until curl -sf "${{ secrets.STAGING_BASE_URL }}/api/health" > /dev/null; do
ATTEMPT=$((ATTEMPT + 1))
if [ $ATTEMPT -ge $MAX_ATTEMPTS ]; then
echo "Service did not become ready in time"
exit 1
fi
echo "Waiting for service... attempt $ATTEMPT/$MAX_ATTEMPTS"
sleep 10
done
echo "Service is ready"For Hurl specifically, you can use a retry loop in the .hurl file itself:
# Wait for service readiness (retry up to 30 times with 2 second delays)
GET https://{{base_url}}/api/health
[Options]
retry: 30
retry-interval: 2000
HTTP 200The retry option makes Hurl poll the endpoint until it succeeds or the retry limit is reached. This is cleaner than a separate shell loop.
Test Ordering and Dependencies
Some tests must run in a specific order — you cannot delete a resource before creating it. Two approaches:
Approach 1: Self-contained files
Each .hurl or .http file contains a complete, ordered sequence: create, read, update, delete. The file is a single transaction that leaves no state behind.
# user-crud.hurl — complete lifecycle in one file
POST https://{{base_url}}/api/users
Content-Type: application/json
{"name": "Test User", "email": "test-{{$uuid}}@example.com"}
HTTP 201
[Captures]
user_id: jsonpath "$.id"
GET https://{{base_url}}/api/users/{{user_id}}
HTTP 200
[Asserts]
jsonpath "$.name" == "Test User"
PATCH https://{{base_url}}/api/users/{{user_id}}
Content-Type: application/json
{"name": "Updated User"}
HTTP 200
DELETE https://{{base_url}}/api/users/{{user_id}}
HTTP 204This pattern is the most reliable for CI because each file is independent and can run in any order relative to other files.
Approach 2: Explicit ordering in CI
When cross-file ordering is unavoidable, run files explicitly in sequence:
- name: Run tests in order
run: |
hurl --variable base_url=$BASE_URL tests/setup/create-fixtures.hurl
hurl --variable base_url=$BASE_URL tests/auth/**/*.hurl
hurl --variable base_url=$BASE_URL tests/users/**/*.hurl
hurl --variable base_url=$BASE_URL tests/products/**/*.hurl
hurl --variable base_url=$BASE_URL tests/teardown/cleanup.hurlParallel Test Execution
For suites with 50+ test files, sequential execution is too slow. Parallelize across CI jobs:
# GitHub Actions matrix parallelism
jobs:
api-tests:
strategy:
matrix:
test-group: [auth, users, products, orders, webhooks]
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_linux_amd64.tar.gz
tar -xzf hurl_linux_amd64.tar.gz && sudo mv hurl /usr/local/bin/
- name: Run ${{ matrix.test-group }} tests
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
TOKEN: ${{ secrets.STAGING_TOKEN }}
run: |
hurl --variable base_url=$BASE_URL \
--variable token=$TOKEN \
--report-junit results-${{ matrix.test-group }}.xml \
tests/${{ matrix.test-group }}/**/*.hurl
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.test-group }}
path: results-*.xmlFor parallel jobs to work without interfering, test data must be isolated. Either use unique identifiers (UUIDs generated at runtime) or separate test accounts per test group.
Result Reporting
CI log output is not good enough for team visibility. Test results should appear in the pull request interface, where they are visible to everyone reviewing the PR.
JUnit XML is the universal format. Both Hurl and httpyac can output JUnit XML, which GitHub Actions, GitLab CI, CircleCI, and Jenkins all understand:
# Collect results from parallel jobs and report
collect-results:
needs: [api-tests]
runs-on: ubuntu-latest
if: always()
steps:
- name: Download all results
uses: actions/download-artifact@v4
with:
pattern: test-results-*
merge-multiple: true
- name: Publish test report
uses: mikepenz/action-junit-report@v4
with:
report_paths: '*.xml'
check_name: API Test Results
fail_on_failure: trueHTML reports for stakeholders. Hurl's --report-html generates a browsable report showing each request, the response, and which assertions passed or failed. Publish it as a CI artifact:
- name: Generate HTML report
if: always()
run: |
hurl --variable base_url=$BASE_URL \
--report-html ./html-report \
tests/**/*.hurl
- name: Upload HTML report
uses: actions/upload-artifact@v4
if: always()
with:
name: api-test-report
path: html-report/Deployment Gating
Test failures should block deployments. In GitHub Actions, this is automatic when tests run before the deploy job and the deploy job needs the test job:
jobs:
api-tests:
# ... test configuration
deploy-production:
needs: [api-tests] # deploy only runs if api-tests succeeds
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Deploy to production
run: ./deploy.sh productionIn GitLab CI, use job dependencies and when: on_success:
api-tests:
stage: test
script:
- httpyac run tests/**/*.http --env staging --junit results.xml
artifacts:
reports:
junit: results.xml
deploy-production:
stage: deploy
needs: [api-tests]
when: on_success
script:
- ./deploy.sh production
only:
- mainThe key principle: CI should not allow code to reach production if the API contract tests fail. A failing contract test means a consumer service will break. That is not a risk to accept.
Monitoring and Scheduled Testing
Beyond per-commit testing, schedule API tests against production on a cron schedule to catch drift:
# GitHub Actions scheduled run
on:
schedule:
- cron: '0 * * * *' # Every hour
workflow_dispatch: # Manual trigger
jobs:
production-smoke-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Hurl
run: # ... install steps
- name: Run smoke tests against production
env:
TOKEN: ${{ secrets.PROD_API_TOKEN }}
run: |
hurl --variable base_url=https://api.example.com \
--variable token=$TOKEN \
tests/smoke/**/*.hurl
- name: Notify on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: '{"text": "Production API smoke tests failed. Check: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}Running smoke tests hourly against production catches issues that slip through: an expired certificate, a misconfigured load balancer, a third-party dependency that went down. This is a lightweight continuous monitoring layer that requires no additional infrastructure beyond CI.
For more sophisticated continuous monitoring — including browser-level journey tests that verify the full user experience, not just API endpoints — HelpMeTest runs Robot Framework and Playwright tests continuously in the cloud. You write what you want to verify in natural language, and the system generates, runs, and alerts you to failures. The Pro plan is $100/month. It complements your HTTP DSL tests by covering the UI and end-to-end flow layer that API tests cannot reach.
Complete Pipeline Reference
Putting it all together, here is a complete GitHub Actions workflow for a mature API testing setup:
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
schedule:
- cron: '0 */4 * * *' # Every 4 hours
jobs:
api-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
test-group: [smoke, auth, users, products, orders]
steps:
- uses: actions/checkout@v4
- name: Install Hurl
run: |
VERSION=$(curl -s https://api.github.com/repos/Orange-OpenSource/hurl/releases/latest | jq -r '.tag_name')
curl -LO "https://github.com/Orange-OpenSource/hurl/releases/download/${VERSION}/hurl_linux_amd64.tar.gz"
tar -xzf hurl_linux_amd64.tar.gz && sudo mv hurl /usr/local/bin/
- name: Wait for staging
run: |
until curl -sf "${{ secrets.STAGING_BASE_URL }}/api/health"; do sleep 5; done
- name: Seed test data
if: matrix.test-group != 'smoke'
run: |
source ./scripts/seed-${{ matrix.test-group }}.sh
echo "SEED_IDS=$SEED_IDS" >> $GITHUB_ENV
- name: Run tests
env:
BASE_URL: ${{ secrets.STAGING_BASE_URL }}
TOKEN: ${{ secrets.STAGING_TOKEN }}
SEED_IDS: ${{ env.SEED_IDS }}
run: |
hurl --variable base_url=$BASE_URL \
--variable token=$TOKEN \
--report-junit results-${{ matrix.test-group }}.xml \
--report-html report-${{ matrix.test-group }}/ \
tests/${{ matrix.test-group }}/**/*.hurl
- name: Cleanup test data
if: always() && matrix.test-group != 'smoke'
run: ./scripts/cleanup-${{ matrix.test-group }}.sh
- uses: actions/upload-artifact@v4
if: always()
with:
name: results-${{ matrix.test-group }}
path: |
results-*.xml
report-*/
report:
needs: [api-tests]
runs-on: ubuntu-latest
if: always()
steps:
- uses: actions/download-artifact@v4
with:
pattern: results-*
merge-multiple: true
- uses: mikepenz/action-junit-report@v4
with:
report_paths: '*.xml'
check_name: API Tests
fail_on_failure: true
deploy:
needs: [api-tests]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && success()
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh productionConclusion
The patterns in this post address the real friction points in CI integration for HTTP API testing: environment management, secret handling, test data, service readiness, parallel execution, result visibility, and deployment gating. Working through each one turns ad-hoc API test runs into a reliable CI gate.
The tools — Hurl, httpyac, plain .http files — are all capable of fitting into these patterns. The choice between them matters less than the discipline of wiring them correctly into the pipeline. Tests that do not run in CI do not catch regressions. Tests that run in CI but do not block deployments do not prevent incidents.
Invest the setup time once and the pipeline protects you on every subsequent commit.