Testing No. 125

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.

  • nameapi-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.

~/.claude/skills/api-testing/SKILL.md

--- name: api-testing description: Builds HTTP API test suites that run the same locally and in CI — choosing between Hurl, Bruno, Postman/Newman, and code-based tests… license: MIT ---

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

SituationDefaultWhy
Black-box tests of any stack, CI-first, reviewers read diffsHurlPlain-text files, captures + asserts, single binary, JUnit output
Team wants a GUI client and tests in the same filesBrunoCollections stored as files in git, bru run CLI
Existing Postman collectionsNewman (or the Postman CLI)Run what exists; move to git-stored exports
Tests need DB setup, fixtures, complex logic, or run in-processCode: supertest (Node), pytest + httpx (Python), REST Assured (JVM)Full language, shares factories with unit tests
Consumer/provider compatibility or fuzzing from OpenAPISee contract-testingPact, 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_id suffixes) 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

CaseExpectedExample
Happy path, minimal body200/201, body shape, Location header on createPOST /orders with required fields
Happy path, all optional fieldsFields round-tripGET after POST returns same values
Missing required field / wrong type400 or 422 with field-level errorquantity: "two"
Boundary valuesAccepted at limit, rejected beyondmax length, 0, negative, huge numbers
Unknown id404random UUID
No / bad token401 with WWW-Authenticate if the API uses it
Wrong role403member calling admin endpoint
Other tenant’s object (BOLA/IDOR)403 or 404user B GETs user A’s order id
Duplicate / conflict409 or idempotent 200same Idempotency-Key twice
PaginationStable order, next cursor, last page emptylimit=1 walk
Content negotiation415 on wrong content type, 406 if relevanttext/plain body
Rate limit (if specified)429 with Retry-Afterburst 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 in bruno/environments/, secrets via --env-var or a gitignored .env referenced 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’s components.schemas; Python: jsonschema or openapi-core against the spec file.
  • Set additionalProperties: false in test validation (or a strict mode) when you need to catch leaked fields such as password_hash or internal ids.
  • Schema checks catch shape; they do not replace value assertions on the fields that matter for the case.

Auth flows

SchemeBootstrap
Username/password to JWTLogin request once per file/session, capture token
OAuth2 client credentialsPOST to token endpoint with client id/secret from CI secrets; cache until expires_in
OAuth2 auth code / SSOUse a test-only token issuer or service account; do not script the browser login in API tests
API keysSeparate keys per role in CI secrets; rotate test keys like real ones
Cookie sessionsCapture 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

  1. Run the suite twice back to back against the same service: both pass (no data collisions).
  2. Run one file alone: it passes (no hidden order dependence).
  3. 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.
  4. 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.

esc