teachyou.ai academy
← All posts
Claude CodeAPI testingtest automationpytestPlaywright

API Testing with Claude Code: From curl to Full Test Suites

Pramod Dutta · Jul 8, 2026 · 18 min read

Claude Code API testing works because the agent can actually execute requests: it runs curl, reads the response, and decides what to try next, which is exactly the loop you run by hand when you poke a new endpoint. That makes it a fundamentally different tool from a chat window that writes test code against an API it has never called. This article walks through a complete workflow in four stages: exploratory curl calls, a frozen smoke script, a generated pytest or Playwright suite, and contract tests wired into CI, with the guardrails you need before pointing an agent at anything with credentials.

Everything below assumes you have Claude Code installed (npm install -g @anthropic-ai/claude-code) and an API you are allowed to test. The examples use a fictional orders API on a staging host. Swap in your own base URL, auth scheme, and endpoints; the workflow does not change.

One framing note before the commands: the goal is not "Claude tests my API forever." The goal is that the agent does the expensive exploratory work once, then leaves behind artifacts (notes, scripts, test files) that run deterministically without it. If your process still needs a model in the loop to know whether the API works, you have built a demo, not a test suite.

Why Claude Code API Testing Feels Different

Most API testing setups fail in one of three familiar ways. Postman collections rot in a workspace nobody exports, disconnected from the repo they describe. Hand-written curl one-liners live in shell history and die with the terminal. And tests generated by a chat UI are written against an imagined API: the model guesses field names, guesses status codes, and produces a suite that fails on first contact with the real service.

Claude Code avoids all three because it operates inside your repo with a shell. Ask it to test an endpoint and it does what a careful engineer does:

  • Reads the code that serves the endpoint (routes, handlers, serializers, validation) if the repo contains it.
  • Sends a real request with curl and inspects the actual status code, headers, and body.
  • Compares what it observed against what the code or the OpenAPI spec promised.
  • Writes tests that assert the real contract, then runs them and fixes its own mistakes.

That last step matters most. When a generated test fails, Claude Code does not shrug. It re-runs the request, diffs the response against the assertion, and works out whether the test was wrong or the API is. You review a finished, passing suite plus a short list of genuine discrepancies, which is a much better use of your attention than debugging a pile of speculative code.

The other difference is that everything lands in git. The exploration notes, the smoke script, the test files, the CI workflow: all reviewable, all diffable, all owned by the team rather than by a tool account.

Setup: Context, Credentials, and Guardrails First

Ten minutes of setup makes every later prompt shorter and safer. Do three things before the first request.

First, give the agent standing context in CLAUDE.md at the repo root. Claude Code reads this file automatically at session start, so put the facts you would otherwise repeat in every prompt:

# API testing notes

- Base URL (staging): https://api-staging.example.com
- Production is https://api.example.com. NEVER send requests there.
- Auth: Bearer token in the API_TOKEN env var (staging only)
- OpenAPI spec: ./openapi.yaml (treat as the contract)
- Test stack: pytest + httpx, tests live in tests/api/
- Run the suite with: pytest tests/api -q
- Test data: SKUs starting with TEST- are safe to create and delete

Second, handle credentials properly. Export the token in your shell (or a .env file that is gitignored) and refer to it only as an environment variable. Never paste a token into the conversation: prompts and tool output are stored in session transcripts, and API responses land there too, which is also why you should test against seeded staging data rather than real customer records.

export API_TOKEN="paste-staging-token-here"
export BASE_URL="https://api-staging.example.com"

Third, set permissions so the session flows without approving every curl by hand. In .claude/settings.json (checked in, shared with the team):

{
  "permissions": {
    "allow": [
      "Bash(curl:*)",
      "Bash(jq:*)",
      "Bash(pytest:*)",
      "Bash(npx playwright test:*)"
    ]
  }
}

Allowing all of curl is fine for staging work, but it also allows curl against production. Command allowlists match command prefixes, not URLs, so the reliable way to block a specific host is a PreToolUse hook. This one inspects every proposed Bash command and rejects anything mentioning the production hostname:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "grep -q 'api.example.com' && { echo 'Blocked: that is the production host' >&2; exit 2; } || exit 0"
          }
        ]
      }
    ]
  }
}

The hook receives the tool call as JSON on stdin. Exit code 2 blocks the call and feeds the stderr message back to the model, which then knows why it was stopped and routes around it instead of retrying blindly.

Stage 1: Exploratory Testing with curl

Start every engagement with read-only exploration. The prompt sets scope, forbids writes, and demands a written artifact:

Explore the staging orders API. Start with GET /v1/orders, then
follow what the responses suggest: pagination cursors, order detail
endpoints, filter params. For each endpoint record the status code,
response shape, required auth, and anything surprising. Write your
findings to docs/api-notes.md as you go. Do not send any POST, PUT,
PATCH, or DELETE requests in this session.

Watch what the agent actually runs. The commands look like your own exploration, which is the point:

curl -s "$BASE_URL/v1/orders?limit=2" \
  -H "Authorization: Bearer $API_TOKEN" | jq .

curl -s -o /dev/null -w "%{http_code} in %{time_total}s\n" \
  -H "Authorization: Bearer $API_TOKEN" \
  "$BASE_URL/v1/orders"

curl -s -D - -o /dev/null \
  -H "Authorization: Bearer $API_TOKEN" \
  "$BASE_URL/v1/orders?limit=1"

The first dumps a small response through jq to learn the shape. The second checks status and latency without printing the body. The third dumps response headers, which is where rate limit counters, cache directives, and pagination links hide. Claude Code chains these on its own: if the list response contains next_cursor, it follows the cursor and confirms the pagination actually terminates; if a filter param appears in the spec, it tries a valid value and a garbage value and records both results.

The docs/api-notes.md file is the deliverable of this stage, and it routinely surfaces things the docs do not mention: fields present in responses but absent from the spec, a 200 with an empty body where you would expect a 404, inconsistent error envelopes between endpoints, timestamps in mixed formats. Skim it before moving on, because everything downstream builds on these observations.

Stage 2: Freeze What You Learned into a Smoke Script

Exploration is disposable; the first durable artifact is a smoke script. Ask for it directly:

From docs/api-notes.md, write scripts/smoke.sh: a bash script that
checks the five most important read-only behaviors with curl and
exits non-zero on the first failure. Print PASS/FAIL per check.
Then run it and make it pass.

A good generated script looks like this:

#!/usr/bin/env bash
set -euo pipefail
BASE="${BASE_URL:-https://api-staging.example.com}"

check() {
  local name="$1" expected="$2"; shift 2
  local code
  code=$(curl -s -o /tmp/last-body.json -w '%{http_code}' "$@")
  if [ "$code" != "$expected" ]; then
    echo "FAIL $name: expected $expected, got $code"
    cat /tmp/last-body.json
    exit 1
  fi
  echo "PASS $name ($code)"
}

check "list orders"      200 -H "Authorization: Bearer $API_TOKEN" "$BASE/v1/orders?limit=1"
check "auth is enforced" 401 "$BASE/v1/orders"
check "unknown id -> 404" 404 -H "Authorization: Bearer $API_TOKEN" "$BASE/v1/orders/does-not-exist"
check "bad param -> 422" 422 -H "Authorization: Bearer $API_TOKEN" "$BASE/v1/orders?limit=-1"

Note what makes this worth keeping: exact status assertions rather than "2xx is fine", the failing body printed for triage, and a non-zero exit so anything from cron to CI can consume it. This script is your five-second answer to "is staging up and sane" for the rest of the project's life, and it cost one prompt.

Stage 3: A Real Test Suite with pytest and httpx

Now convert observations into a maintainable suite. The prompt should name the stack, the file layout, and the rule for handling failures:

Using docs/api-notes.md and openapi.yaml, write an API test suite
under tests/api/ with pytest and httpx. Shared client fixture in
conftest.py reading BASE_URL and API_TOKEN from the environment.
Cover: list + pagination, fetch by id, create/fetch/delete roundtrip,
auth enforcement, and validation errors. Any test that creates data
must clean it up. Run the suite. If a test fails because the test is
wrong, fix the test. If it fails because the API contradicts the
spec, leave the test failing and list it in your summary.

The fixture that comes back is straightforward:

# tests/api/conftest.py
import os
import httpx
import pytest

@pytest.fixture(scope="session")
def client():
    with httpx.Client(
        base_url=os.environ.get("BASE_URL", "https://api-staging.example.com"),
        headers={"Authorization": f"Bearer {os.environ['API_TOKEN']}"},
        timeout=10.0,
    ) as c:
        yield c

And the tests assert the contract you actually observed:

# tests/api/test_orders.py
def test_list_orders_respects_limit(client):
    r = client.get("/v1/orders", params={"limit": 2})
    assert r.status_code == 200
    body = r.json()
    assert isinstance(body["data"], list)
    assert len(body["data"]) <= 2
    assert "next_cursor" in body

def test_create_fetch_delete_roundtrip(client):
    created = client.post("/v1/orders", json={"sku": "TEST-001", "quantity": 1})
    assert created.status_code == 201
    order_id = created.json()["id"]

    fetched = client.get(f"/v1/orders/{order_id}")
    assert fetched.status_code == 200
    assert fetched.json()["sku"] == "TEST-001"

    deleted = client.delete(f"/v1/orders/{order_id}")
    assert deleted.status_code in (200, 204)

def test_quantity_zero_is_rejected(client):
    r = client.post("/v1/orders", json={"sku": "TEST-001", "quantity": 0})
    assert r.status_code == 422
    assert r.json()["error"]["field"] == "quantity"

Two review points when the suite lands. First, check that assertions target shapes and invariants, not live values: asserting a specific order count or customer name against shared staging data produces flakes within a week. Second, check cleanup. The roundtrip test above deletes what it created, so the suite is rerunnable and does not slowly fill staging with junk. If your API has no delete, ask for a fixture that tags created records and a teardown that sweeps them.

The failure-handling rule in the prompt is the part most people skip and the part that pays best. Without it, agents happily "fix" a correct test to match a buggy API, and the suite silently blesses the bug. With it, you get a passing suite plus an honest list of places where the API and its spec disagree, which is exactly the report a senior engineer would hand you.

Stage 4: The Same Suite in Playwright, if You Live in TypeScript

Teams already running Playwright for E2E can keep API tests in the same runner. Playwright's request fixture speaks HTTP directly, no browser involved, so these tests run fast and share the reporter, retries, and CI wiring you already have:

// tests/api/orders.spec.ts
import { test, expect } from '@playwright/test';

test.use({
  baseURL: process.env.BASE_URL ?? 'https://api-staging.example.com',
  extraHTTPHeaders: {
    Authorization: `Bearer ${process.env.API_TOKEN}`,
  },
});

test('lists orders and respects limit', async ({ request }) => {
  const res = await request.get('/v1/orders?limit=2');
  expect(res.status()).toBe(200);
  const body = await res.json();
  expect(Array.isArray(body.data)).toBe(true);
  expect(body.data.length).toBeLessThanOrEqual(2);
});

test('rejects requests without auth', async ({ request }) => {
  const res = await request.get('/v1/orders', {
    headers: { Authorization: '' },
  });
  expect(res.status()).toBe(401);
});

Run it with npx playwright test tests/api. When you ask Claude Code for this variant, say so explicitly ("Playwright API tests using the request fixture, no browser") or you may get browser-context boilerplate you do not need. The workflow is otherwise identical: generate, run, self-correct, review.

Pick one stack per repo and stop there. The agent will cheerfully produce pytest, Playwright, supertest, and RestAssured versions of the same suite if you let it, and you will maintain none of them well.

Contract Testing Against the OpenAPI Spec

With a spec in the repo, two moves turn it from documentation into an enforced contract.

The first is a structured diff between spec and reality, which is a task agents are unusually good at because it is pure cross-referencing:

Compare openapi.yaml against the live staging API. For every path
and verb in the spec: does the endpoint exist, do required fields
match, are documented status codes the ones actually returned, and
does the response contain fields the spec does not mention? Produce
a discrepancy table in docs/spec-drift.md, worst first.

Expect real findings on any API older than a few months: response fields added in a hurry and never documented, a 400 where the spec promises 422, enums with undocumented values. Each is either a spec fix or an API fix, and now you have the list.

The second move is property-based fuzzing with Schemathesis, which generates hundreds of spec-derived requests and checks the responses conform:

pip install schemathesis
schemathesis run ./openapi.yaml --url https://api-staging.example.com \
  -H "Authorization: Bearer $API_TOKEN"

That flag style matches Schemathesis 4; older 3.x installs call the base URL flag --base-url. This is a place where the agentic loop quietly shines: tell Claude Code to "install schemathesis and run it against the spec" and it reads the CLI help first, so version drift in flags stops being your problem. When the fuzzer finds a 500, paste the failing request back into the session and let the agent minimize it and locate the offending handler in your code.

Negative Tests: Where the Agent Earns Its Keep

Happy paths are easy and everyone writes them. The value concentrated in negative testing is that it requires patient enumeration, which is precisely what you can delegate:

Read openapi.yaml. For POST /v1/orders, enumerate every failure the
contract implies, then write one pytest per failure mode in
tests/api/test_orders_negative.py. Cover at least: missing auth,
expired token, unknown SKU, quantity 0, quantity above the max,
missing required field, extra unknown field, malformed JSON body,
wrong Content-Type, and a 2 MB payload. Assert the status code AND
the error envelope shape for each. Run the suite and report which
failures the API handles inconsistently.

Two details in that prompt do the heavy lifting. Asserting the error envelope, not just the status, catches the class of bug where an endpoint returns the right code with a stack trace or an empty body, which breaks every client that parses errors. And asking for an inconsistency report surfaces the classic drift where one endpoint returns 400 for a validation failure and its sibling returns 422, which is technically two correct endpoints and one broken API.

Add a probe for injection handling while you are here: SQL fragments and script tags in string filters should come back as clean validation errors or safely escaped data, never as 500s. A 500 on hostile input is not a security hole by itself, but it is always worth a ticket.

Claude Code API Testing in CI

The architecture rule for CI: the deterministic suite gates, the agent triages. Your pipeline should never need a model call to decide pass or fail, because that decision must be fast, cheap, and reproducible. So the generated pytest suite runs as plain pytest:

name: api-tests
on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

jobs:
  api-suite:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install pytest httpx
      - run: pytest tests/api -q
        env:
          API_TOKEN: ${{ secrets.STAGING_API_TOKEN }}
          BASE_URL: https://api-staging.example.com

Then, optionally, bring the agent in only when that job fails, using headless mode. The -p flag runs a single non-interactive prompt, and --allowedTools scopes what it may execute:

      - name: agent-triage
        if: failure()
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          API_TOKEN: ${{ secrets.STAGING_API_TOKEN }}
        run: |
          npm install -g @anthropic-ai/claude-code
          claude -p "pytest tests/api just failed. Re-run it, then for
          each failure re-issue the request with curl and decide:
          flaky test, wrong test, or API regression. Write a triage
          summary to triage.md." \
            --allowedTools "Bash(pytest:*),Bash(curl:*),Read,Write"

Upload triage.md as an artifact or post it to the failure notification, and the human who picks up the alert starts from a diagnosis instead of a red X. For PR-triggered workflows (for example, re-checking spec drift when openapi.yaml changes), the official claude-code-action wraps this same headless mode with GitHub-native plumbing.

Keep the agent version pinned in real pipelines, cap its runtime with the job timeout, and treat its output as advisory. The gate is the suite.

Make It Repeatable with a Slash Command

Once the workflow stabilizes, compress it into a custom slash command so nobody retypes the prompt. Create .claude/commands/api-smoke.md:

Run scripts/smoke.sh against the $ARGUMENTS environment (default:
staging). If any check fails:
1. Re-run the failing curl with -v and capture the full exchange.
2. Compare the response against the matching schema in openapi.yaml.
3. Report: endpoint, expected vs actual, and your verdict on whether
   the API or the check is wrong.
Never send requests to the production base URL.

Now /api-smoke staging is a complete investigation, and because the file is checked in, every teammate and every future session gets the same behavior. The same pattern works for /spec-drift, /negative-sweep, or a /api-triage command that wraps the CI prompt above. Slash commands are how one engineer's good prompt becomes team infrastructure.

Feed durable discoveries back into CLAUDE.md as you go: the flaky endpoint that needs a retry, the auth quirk, the rate limit that throttles bursts above a threshold. Sessions are stateless; the repo is not. Teaching the repo is how the agent gets better at your API over time.

Guardrails Worth Repeating

Agents amplify whatever access you hand them, so the boring rules matter more, not less:

  • Staging only by default, with the production host blocked by a hook, not by hope.
  • Read-only or narrowly scoped tokens for exploration; write scopes only for roundtrip tests that clean up after themselves.
  • Seeded, disposable test data. API responses flow into session transcripts, so real customer PII must never transit a test session.
  • No --dangerously-skip-permissions in any session that has credentials in its environment. The allowlist plus hook setup above gives you the same flow without removing the safety net.
  • Watch for unbounded loops: an agent retrying against a rate-limited endpoint is a small accidental load test. The CLAUDE.md note about rate limits plus sane curl timeouts keeps this contained.

None of these are exotic. They are the same rules you would set for a new contractor with shell access, which is a decent mental model for the whole exercise.

Where This Leaves You

Run the four stages against a real service and the elapsed time is typically a working day, most of it review rather than typing. The artifacts you keep: an api-notes.md that documents observed behavior, a smoke script for instant health checks, a pytest or Playwright suite asserting the real contract, a spec-drift report, negative coverage nobody had the patience to write by hand, and a CI job that gates deterministically with optional agent triage behind it.

The pattern to internalize is explore with the agent, freeze into artifacts, run without the agent. Claude Code API testing is not about replacing your test suite with a model; it is about using a model to build the suite you were never going to find time to write, and then getting out of its way.

FAQ

Can Claude Code test an API that has no OpenAPI spec?

Yes. Point it at the base URL and any docs or handler code you have, and it will explore, infer the contract, and write tests against observed behavior. Ask it to draft an openapi.yaml from its findings while it is there, then treat that draft as a description of what the API does today, not what it should do, and review it with the API's owners.

Is it safe to point Claude Code at production?

Sending writes to production from an agent session is a bad idea, full stop. Read-only exploration against production with a read-only token can be acceptable for debugging a live incident, but the default posture should be staging plus a hook that blocks the production host, so a mistake is impossible rather than merely discouraged.

Which framework should I ask for: pytest, Playwright, supertest, or something else?

Whatever your team already runs, because the maintenance burden lands on humans. The agent is framework-agnostic and will produce idiomatic RestAssured or Karate if that is your world. The only wrong choice is a second framework in a repo that already has one.

How is this different from Postman or Bruno with AI features?

The artifacts. GUI clients keep collections in their own format and their own storage; this workflow leaves plain code and shell scripts in your repo, reviewed in PRs and run by your existing CI. The agent also executes and self-corrects in a loop, rather than generating a request template and leaving verification to you.

Does the workflow cover GraphQL or gRPC?

Yes, with different transport commands. GraphQL is a POST with a JSON body, so curl works unchanged and the negative-test stage maps to malformed queries and depth abuse. For gRPC, tell the agent to use grpcurl against your proto files; the explore, freeze, generate, gate loop is identical.

Do I need Claude Code running inside CI?

No, and mostly you should not. The generated suite is plain pytest or Playwright and runs anywhere. Add headless claude -p only as a failure-triage step or for PR jobs like spec-drift checks, keep it out of the pass/fail decision, and pin its version like any other CI dependency.

How do I stop the generated tests from being flaky against shared staging data?

Assert invariants and shapes, not live values: field types, pagination behavior, error envelopes. Create the data a test depends on inside the test and delete it afterward. Never assert on counts or orderings you do not control, and put those rules in the generation prompt so the suite is born flake-resistant instead of being debugged into it.