API Testing skill for Claude Code
Prove every endpoint's contract, including who can't call it. Claude picks one API test format for the repo, builds an endpoint-by-case matrix that includes validation errors, expired tokens, wrong roles and cross-tenant access, and writes self-contained tests that create their own data. Responses are checked against the OpenAPI schema plus the fields each case is about, and CI runs the suite against a fresh container with JUnit output and service logs.
- name
api-testing - size254 lines · ≈2,650 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds HTTP API test suites that run the same locally and in CI — choosing between Hurl, Bruno, Postman/Newman, and code-based tests (supertest, pytest with httpx, REST Assured), a per-endpoint matrix of happy, validation, authentication, authorization and edge cases, token capture and auth flows, JSON Schema and OpenAPI response assertions, test data isolation, and JUnit-reporting CI jobs against a containerized service. Use when writing API tests, turning a Postman collection into CI checks, testing auth and permission rules on endpoints, or validating responses against a schema. Try asking
- Write Hurl tests for the orders API including auth and permission cases
- Turn our Postman collection into something that runs in GitHub Actions
- Check that users can't read other tenants' invoices through the API
Use it when
- Adding tests for REST or JSON HTTP endpoints
- Moving manual Postman checks into CI
- Verifying authentication and authorization rules per endpoint
- Validating response bodies against an OpenAPI schema
Skip it when
- Two services need compatibility checks before deploy: use contract-testing
- You need throughput or latency numbers under load: use load-testing
How to check the result
The suite passes twice in a row and file by file against a fresh service, and fails with a named endpoint and assertion when a cross-tenant read is deliberately allowed.
The common mistake it prevents
Testing only happy paths and status codes, so missing authorization checks and leaked response fields ship unnoticed.
A good API suite proves each endpoint’s contract from the outside: correct status codes, headers and body shape for valid input, the right 4xx for every class of bad input, and no access to another user’s data. Done well means: the suite lives in git as plain text, runs headless against a freshly started service, creates its own data, and fails CI with a readable JUnit report.
Pick the tool
| Situation | Default | Why |
|---|---|---|
| Black-box tests of any stack, CI-first, reviewers read diffs | Hurl | Plain-text files, captures + asserts, single binary, JUnit output |
| Team wants a GUI client and tests in the same files | Bruno | Collections stored as files in git, bru run CLI |
| Existing Postman collections | Newman (or the Postman CLI) | Run what exists; move to git-stored exports |
| Tests need DB setup, fixtures, complex logic, or run in-process | Code: supertest (Node), pytest + httpx (Python), REST Assured (JVM) | Full language, shares factories with unit tests |
| Consumer/provider compatibility or fuzzing from OpenAPI | See contract-testing | Pact, Schemathesis, oasdiff live there |
Pick one primary format per repo. Two parallel API suites drift.
Ground rules
- Assert more than the status: content type, key fields and types, and the side effect (follow a POST with a GET, or check the DB through a test-only endpoint).
- Every protected endpoint gets four auth cases: no token (401), invalid/expired token (401), valid token without permission (403 or 404), another tenant’s resource (403 or 404, never 200).
- Each test creates its own data with unique values (
run_idsuffixes) and does not depend on order or on data another test created, unless it is an explicit chained flow in one file. - No secrets in the repo. Tokens and passwords come from CI secrets or a gitignored env file; collections reference variables only.
- Never run destructive suites (DELETE, bulk updates) against production or shared staging without explicit user confirmation and a dedicated test tenant.
Workflow
- [ ] 1. Inventory endpoints (OpenAPI spec, router files) and existing tests
- [ ] 2. Build the endpoint x case matrix; prioritize by risk
- [ ] 3. Set up environments, variables, and the auth bootstrap
- [ ] 4. Write tests: happy path, validation, auth, edge cases
- [ ] 5. Add schema assertions for response bodies
- [ ] 6. Run against a local containerized service; fix data isolation issues
- [ ] 7. Wire CI with JUnit reports and a health-check wait
1. Inventory
ls openapi.* api/*.yaml docs/openapi* 2>/dev/null
grep -rnE "(app|router)\.(get|post|put|patch|delete)\(" src | wc -l # Express-style
grep -rnE "@(Get|Post|Put|Patch|Delete)Mapping|@app\.(get|post)" src | wc -l
ls tests/api *.hurl bruno/ postman/ 2>/dev/null
2. Case matrix per endpoint
| Case | Expected | Example |
|---|---|---|
| Happy path, minimal body | 200/201, body shape, Location header on create | POST /orders with required fields |
| Happy path, all optional fields | Fields round-trip | GET after POST returns same values |
| Missing required field / wrong type | 400 or 422 with field-level error | quantity: "two" |
| Boundary values | Accepted at limit, rejected beyond | max length, 0, negative, huge numbers |
| Unknown id | 404 | random UUID |
| No / bad token | 401 with WWW-Authenticate if the API uses it | |
| Wrong role | 403 | member calling admin endpoint |
| Other tenant’s object (BOLA/IDOR) | 403 or 404 | user B GETs user A’s order id |
| Duplicate / conflict | 409 or idempotent 200 | same Idempotency-Key twice |
| Pagination | Stable order, next cursor, last page empty | limit=1 walk |
| Content negotiation | 415 on wrong content type, 406 if relevant | text/plain body |
| Rate limit (if specified) | 429 with Retry-After | burst in a dedicated test |
Prioritize: money, auth, data deletion, and anything public first; then CRUD breadth.
3–4. Hurl example (auth capture, chained flow, negative cases)
# tests/api/orders.hurl
POST {{base_url}}/api/auth/login
Content-Type: application/json
{ "email": "{{user_a_email}}", "password": "{{user_a_password}}" }
HTTP 200
[Captures]
token_a: jsonpath "$.access_token"
POST {{base_url}}/api/orders
Authorization: Bearer {{token_a}}
Content-Type: application/json
{ "sku": "SKU-{{run_id}}", "quantity": 2 }
HTTP 201
[Captures]
order_id: jsonpath "$.id"
[Asserts]
header "Location" endsWith "/api/orders/{{order_id}}"
jsonpath "$.quantity" == 2
jsonpath "$.status" == "pending"
GET {{base_url}}/api/orders/{{order_id}}
Authorization: Bearer {{token_a}}
HTTP 200
[Asserts]
jsonpath "$.sku" == "SKU-{{run_id}}"
duration < 1000
# validation
POST {{base_url}}/api/orders
Authorization: Bearer {{token_a}}
Content-Type: application/json
{ "sku": "SKU-{{run_id}}", "quantity": "two" }
HTTP 422
[Asserts]
jsonpath "$.errors[0].field" == "quantity"
# unauthenticated
GET {{base_url}}/api/orders/{{order_id}}
HTTP 401
hurl --test --variables-file env/local.env --variable run_id=$(date +%s) \
--report-junit build/api-junit.xml tests/api/*.hurl
Hurl runs files in parallel under --test; keep each file self-contained,
or serialize with --jobs 1 while fixing order dependence. Captures are per file.
Bruno
- Collection folder in git (
bruno/), environments inbruno/environments/, secrets via--env-varor a gitignored.envreferenced as{{process.env.NAME}}. - Assertions in each request’s tests/assert block; capture tokens in a post-response
script with
bru.setVar("token", res.body.access_token). - CI:
cd bruno && npx @usebruno/cli run --env ci --reporter-junit ../build/bruno.xml. Exit code is non-zero on any failed assertion.
Postman / Newman
npx newman run postman/collection.json -e postman/ci.env.json \
--env-var "password=$API_PASSWORD" \
--reporters cli,junit --reporter-junit-export build/newman.xml --bail
Export collections to git and review diffs; a collection that lives only in a workspace is not a test suite.
Code-based (pytest + httpx)
# tests/api/test_orders.py
import uuid, httpx, pytest
@pytest.fixture
def client(base_url, token_a):
with httpx.Client(base_url=base_url, headers={"Authorization": f"Bearer {token_a}"}, timeout=10) as c:
yield c
def test_other_tenant_cannot_read_order(client, base_url, token_b):
created = client.post("/api/orders", json={"sku": f"SKU-{uuid.uuid4()}", "quantity": 1})
assert created.status_code == 201
resp = httpx.get(f"{base_url}/api/orders/{created.json()['id']}",
headers={"Authorization": f"Bearer {token_b}"})
assert resp.status_code in (403, 404)
In-process alternatives skip the network and run faster: supertest(app) for Express,
FastAPI TestClient, Spring MockMvc/WebTestClient. Keep a smaller black-box suite
against the deployed container as well.
5. Schema assertions
- Validate every success body against the OpenAPI component schema, not a hand-written
copy. JS: compile with
ajv(+ajv-formats) from the spec’scomponents.schemas; Python:jsonschemaoropenapi-coreagainst the spec file. - Set
additionalProperties: falsein test validation (or a strict mode) when you need to catch leaked fields such aspassword_hashor internal ids. - Schema checks catch shape; they do not replace value assertions on the fields that matter for the case.
Auth flows
| Scheme | Bootstrap |
|---|---|
| Username/password to JWT | Login request once per file/session, capture token |
| OAuth2 client credentials | POST to token endpoint with client id/secret from CI secrets; cache until expires_in |
| OAuth2 auth code / SSO | Use a test-only token issuer or service account; do not script the browser login in API tests |
| API keys | Separate keys per role in CI secrets; rotate test keys like real ones |
| Cookie sessions | Capture Set-Cookie, send Cookie; include CSRF token if required |
Test expiry explicitly: a token signed with a past exp or a revoked session must get 401.
7. CI
# GitHub Actions excerpt
- run: docker compose -f docker-compose.test.yml up -d --wait # waits for healthchecks
- run: ./scripts/seed-test-users.sh
- run: |
hurl --test --variables-file env/ci.env --variable run_id=${{ github.run_id }} \
--report-junit build/api-junit.xml tests/api/*.hurl
env: { HURL_VARIABLE_user_a_password: '${{ secrets.API_TEST_PASSWORD }}' }
- if: always()
uses: actions/upload-artifact@v4
with: { name: api-junit, path: build/api-junit.xml }
- if: always()
run: docker compose -f docker-compose.test.yml logs api > build/api.log
Current Hurl reads HURL_VARIABLE_<name> environment variables as variables (older
releases used a bare HURL_<name> prefix); --variable name=$SECRET works everywhere.
Collect service logs on failure; most API test
failures are diagnosed from server logs, not the assertion.
Gotchas
- Asserting on full response bodies (or snapshots) makes every new field a failure; assert the fields the case is about plus the schema.
- Timestamps and ids differ per run; never compare them to literals.
- Tests passing against a seeded staging database with leftover data hide create-path bugs; start from an empty, migrated database in CI.
- 404 vs 403 for other tenants’ objects is a deliberate API decision; assert the chosen one consistently, and never accept 200.
- Retries in HTTP clients (httpx transport retries, axios-retry) hide flaky endpoints in tests; disable them in the test client.
- Running the same suite against production for smoke checks: read-only requests only, dedicated monitoring account, and the user’s explicit go-ahead.
Verification
- Run the suite twice back to back against the same service: both pass (no data collisions).
- Run one file alone: it passes (no hidden order dependence).
- Break the API on purpose (return 200 for another tenant, drop a required field): the suite fails with a message naming the endpoint and assertion.
- CI publishes the JUnit report and service logs on failure.
Output: coverage summary for a PR
### API tests added
| Endpoint | Happy | Validation | 401 | 403/BOLA | Edge |
|---|---|---|---|---|---|
| POST /api/orders | yes | quantity type, missing sku | yes | other tenant | idempotency key |
Gaps left intentionally: <endpoint — reason>
Run: `hurl --test --variables-file env/local.env tests/api/*.hurl` About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.