| name | api-test-runner |
|---|---|
| description | Run CI-friendly API test suites (REST + GraphQL) from a single manifest, using the bundled api-test CLI and emitting JSON (+ optional JUnit) results. Use when the user asks to reduce CI boilerplate and provide a simple, composable suite runner for other tools (pytest/node/LLM) to call. |
Prereqs:
api-test,api-rest, andapi-gqlavailable onPATH(install viabrew install nils-cli).jqrecommended for ad-hoc assertions/formatting (optional).
Inputs:
- Suite selection:
--suite <name>or--suite-file <path>. - Optional filters/flags:
--tag,--allow-writes,--out,--junit. - Optional auth secret env (when configured in suite):
API_TEST_AUTH_JSON(or customsecretEnv).
Outputs:
- JSON results always emitted to stdout.
- Optional files:
--out <path>(results JSON) and--junit <path>(JUnit XML). - Per-run logs/artifacts under
out/api-test-runner/<runId>/when executed.
Exit codes:
0: all selected cases passed2: one or more cases failed1: invalid inputs / schema / missing files
Failure modes:
- Missing/invalid suite or case files (JSON schema errors, missing request/op files).
- Auth missing/invalid (401/403) or write-capable case blocked by safety defaults.
- External CLI commands only:
api-test runapi-test summary
- This skill intentionally has no repo-local
scripts/entrypoint.
Run a suite of API checks in CI (and locally) via a single manifest file, reusing existing callers:
- REST:
api-rest - GraphQL:
api-gql
The runner:
- Executes selected cases deterministically
- Produces machine-readable JSON results (and optional JUnit XML)
- Applies safe defaults (no secret leakage; guardrails for write-capable cases)
Bootstrap a minimal setup/api/ (when your repo already has setup/rest and/or setup/graphql):
mkdir -p setup
cp -R "$AGENT_HOME/skills/tools/testing/api-test-runner/assets/scaffold/setup/api" setup/Bootstrap a runnable local-fixture smoke suite (includes setup/api, setup/rest, setup/graphql, plus a tiny REST + GraphQL fixture):
cp -R "$AGENT_HOME/skills/tools/testing/api-test-runner/assets/scaffold/setup" .Start the local fixture (REST + GraphQL; required by smoke-demo):
python3 setup/fixtures/httpbin/server.py --port 43127Run it in a separate terminal (or background it with &) so the suite can connect.
Run a canonical suite:
api-test run --suite smoke-demo --out out/api-test-runner/results.jsonEmit JUnit for CI reporters:
api-test run --suite smoke-demo --junit out/api-test-runner/junit.xmlGenerate a human-friendly summary (CI logs + $GITHUB_STEP_SUMMARY), based on the results JSON:
api-test summary \
--in out/api-test-runner/results.json \
--out out/api-test-runner/summary.md \
--slow 5Hide skipped cases (optional):
api-test summary \
--in out/api-test-runner/results.json \
--hide-skippedCanonical locations searched by --suite:
tests/api/suites/*.suite.json- (fallback)
setup/api/suites/*.suite.json(used by the bundled template)
Runner entrypoints:
--suite <name>→ resolves totests/api/suites/<name>.suite.json(fallback:setup/api/suites/...); override the search dir withAPI_TEST_SUITES_DIR--suite-file <path>→ explicit suite file path (use when the suite manifest is not in a canonical suites dir)
Notes:
- REST cases point at
*.request.json(same inputs used byapi-rest call). - GraphQL cases point at
*.graphql+ variables*.json(same inputs used byapi-gql call). - Suite manifest location is independent from REST/GraphQL
configDir(those can live undertests/rest,tests/graphql, etc). - GraphQL write safety: if an operation file contains a
mutationdefinition, the runner treats it as write-capable and requiresallowWrite=trueon the case. - GraphQL default validation: when
allowErrors=falseandexpect.jqis omitted, the runner requires.datato be a non-null object. - Auth safety:
.auth.*.credentialsJqmust yield exactly one object (multiple matches fail fast).
{
"version": 1,
"name": "smoke",
"defaults": {
"env": "staging",
"noHistory": true,
"rest": { "configDir": "setup/rest", "url": "", "token": "" },
"graphql": { "configDir": "setup/graphql", "url": "", "jwt": "" }
},
"cases": [
{
"id": "rest.health",
"type": "rest",
"tags": ["smoke"],
"env": "",
"noHistory": true,
"allowWrite": false,
"configDir": "",
"url": "",
"token": "",
"request": "setup/rest/requests/health.request.json"
},
{
"id": "rest.auth.login_then_me",
"type": "rest-flow",
"tags": ["smoke"],
"env": "",
"noHistory": true,
"allowWrite": true,
"configDir": "",
"url": "",
"loginRequest": "setup/rest/requests/login.request.json",
"tokenJq": ".accessToken",
"request": "setup/rest/requests/me.request.json"
},
{
"id": "graphql.countries",
"type": "graphql",
"tags": ["smoke"],
"env": "",
"noHistory": true,
"allowWrite": false,
"allowErrors": false,
"configDir": "",
"url": "",
"jwt": "",
"op": "setup/graphql/operations/countries.graphql",
"vars": "setup/graphql/operations/countries.variables.json",
"expect": { "jq": "(.errors? | length // 0) == 0" }
}
]
}Notes:
defaults.*are optional; per-case fields overridedefaults.defaults.noHistory(and casenoHistory) map to--no-historyon underlyingapi-rest call/api-gql call.defaults.rest.configDir/defaults.graphql.configDirdefault tosetup/rest/setup/graphql.- For REST and GraphQL endpoint selection, prefer
urlfor CI determinism (avoids env preset drift). rest-flowrunsloginRequestfirst, extracts a token (viatokenJq), then runsrequestwithACCESS_TOKEN=<token>(token is not printed in command snippets).
For write cases that create persistent resources (DB rows, uploaded files, etc), attach a cleanup block so the runner removes test
artifacts after the case runs.
cleanupcan be an object (single step) or an array of steps.- Each cleanup step must include
type:restorgraphql. - Cleanup steps run only when writes are allowed:
API_TEST_ALLOW_WRITES_ENABLED=true(or--allow-writes) or when the effectiveenvislocal.
REST cleanup step fields:
method(default:DELETE)pathTemplate(required; supports{{var}})vars(optional):{ "var": "<jq expr>" }evaluated against the main response JSONexpectStatus(orexpect.status) optional; defaults to204forDELETE, otherwise200
GraphQL cleanup step fields:
op(required)- Variables (pick one):
varsJq: jq expression producing the variables object (evaluated against the main response JSON)varsTemplate+vars(object mapping placeholders):varsTemplateis a JSON file that may contain{{var}}placeholdersvars: a variables JSON file path (static)
allowErrors/expect.jq: same semantics as normal GraphQL cases (whenallowErrors=true,expect.jqis required)
GraphQL create + delete example (cleanup uses the create response):
{
"id": "graphql.admin.createThing",
"type": "graphql",
"tags": ["write"],
"allowWrite": true,
"jwt": "admin",
"op": "setup/graphql/operations/things.create.graphql",
"vars": "setup/graphql/operations/things.create.variables.json",
"expect": { "jq": ".data.createThing.thing.id != null" },
"cleanup": {
"type": "graphql",
"op": "setup/graphql/operations/things.delete.graphql",
"varsJq": "{ input: { id: .data.createThing.thing.id } }",
"expect": { "jq": ".data.deleteThing.success == true" }
}
}REST cleanup example (delete by key extracted from the response):
{
"id": "rest.files.images.upload",
"type": "rest",
"tags": ["write"],
"allowWrite": true,
"token": "admin",
"request": "setup/rest/requests/files.images.upload.request.json",
"cleanup": {
"type": "rest",
"method": "DELETE",
"pathTemplate": "/files/images/{{key}}",
"vars": { "key": ".key" },
"expectStatus": 204
}
}Note: api-rest also supports per-request cleanup blocks; case-level cleanup is an alternative that can cover both REST + GraphQL in one
suite.
If your JWT expires, prefer logging in at runtime in CI using a suite-level auth block + a single JSON GitHub Secret.
How it works:
- You provide credentials via a JSON env var (default:
API_TEST_AUTH_JSON). - The runner logs in once per referenced profile (cached for the run) using either a REST or GraphQL login provider.
- For cases that specify
token(REST) orjwt(GraphQL), the runner injectsACCESS_TOKENfor that case and does not rely ontokens(.local).env/jwts(.local).env.
Recommended secret schema (example):
{
"profiles": {
"admin": { "username": "admin@example.com", "password": "..." },
"member": { "username": "member@example.com", "password": "..." }
}
}Suite example (REST provider):
{
"version": 1,
"name": "auth-smoke",
"auth": {
"provider": "rest",
"secretEnv": "API_TEST_AUTH_JSON",
"required": true,
"rest": {
"loginRequestTemplate": "setup/rest/requests/login.request.json",
"credentialsJq": ".profiles[$profile] | select(.) | { username, password }",
"tokenJq": ".accessToken"
}
},
"defaults": {
"noHistory": true,
"rest": { "url": "https://<host>", "token": "member" },
"graphql": { "url": "https://<host>/graphql", "jwt": "member" }
},
"cases": [
{ "id": "rest.me.member", "type": "rest", "token": "member", "request": "setup/rest/requests/me.request.json" },
{ "id": "graphql.me.admin", "type": "graphql", "jwt": "admin", "op": "setup/graphql/operations/me.graphql" }
]
}Suite example (GraphQL provider):
{
"version": 1,
"name": "auth-smoke",
"auth": {
"provider": "graphql",
"secretEnv": "API_TEST_AUTH_JSON",
"required": true,
"graphql": {
"loginOp": "setup/graphql/operations/login.ci.graphql",
"loginVarsTemplate": "setup/graphql/operations/login.ci.variables.json",
"credentialsJq": ".profiles[$profile] | select(.) | { email, password }",
"tokenJq": ".. | objects | (.accessToken? // .access_token? // .token? // .jwt? // empty) | select(type==\"string\" and length>0) | ."
}
}
}Defaults:
- Fail fast: if
authis configured but the secret env var is missing/empty, the runner exits1with a clear error. - Optional override: set
auth.required=falseto disable suite auth when the secret is missing (useful for forks / local runs).
- REST: assertions live in the request file:
expect.status(required whenexpectis present)expect.jq(optional; evaluated withjq -eagainst the JSON response)
- GraphQL:
- Default: the runner enforces
.errorsis empty, plus optional per-caseexpect.jq. allowErrors: true: skip the default no-errors check and rely onexpect.jq(required) to assert the expected error(s), e.g..errors[0].extensions.code == "PHONE_VERIFICATION_INCOMPLETE".
- Default: the runner enforces
Core:
--suite <name>/--suite-file <path>--out <path>(optional; JSON results file)--junit <path>(optional; JUnit XML)
Selection:
--only <id1,id2,...>--skip <id1,id2,...>--tag <tag>(repeatable; all tags must match)
Control:
--fail-fast(stop after first failure)--continue(continue after failures; default)
Generic shell (write JSON + JUnit as CI artifacts):
api-test run \
--suite smoke \
--out out/api-test-runner/results.json \
--junit out/api-test-runner/junit.xmlGitHub Actions (runs the bundled smoke suite; local REST + GraphQL fixture):
- Example workflow file:
.github/workflows/api-test-runner.yml
The bundled smoke suite expects a local fixture on http://127.0.0.1:43127:
python3 setup/fixtures/httpbin/server.py --port 43127If you want to run your own suite in CI, replace the bootstrap step with your repo’s committed setup/api/.
Notes:
- Keep
out/api-test-runner/results.jsonas the primary machine-readable artifact. - Only upload per-case response files as artifacts if they are known to be non-sensitive.
For large suites, you can split a single suite into multiple CI jobs by tagging cases and running the runner in a matrix.
Suite tagging pattern (make shard tags mutually exclusive):
{
"cases": [
{ "id": "graphql.health", "type": "graphql", "tags": ["staging", "shard:0"], "op": "..." },
{ "id": "graphql.notifications", "type": "graphql", "tags": ["staging", "shard:1"], "op": "..." }
]
}Workflow example:
strategy:
fail-fast: false
matrix:
shard: ["0", "1"]
steps:
- name: Run suite shard
env:
AGENT_HOME: ${{ github.workspace }}
API_TEST_AUTH_JSON: ${{ secrets.API_TEST_AUTH_JSON }}
run: |
api-test run \
--suite my-suite \
--tag staging \
--tag "shard:${{ matrix.shard }}" \
--out "out/api-test-runner/results.shard-${{ matrix.shard }}.json" \
--junit "out/api-test-runner/junit.shard-${{ matrix.shard }}.xml"Notes:
--tagis repeatable and uses AND semantics (a case must include all tag filters to run).- Make shard tags mutually exclusive to avoid duplicate coverage across jobs.
- Use per-shard output filenames to avoid artifact collisions.
Write-capable cases are denied by default.
- REST write detection: HTTP methods other than
GET/HEAD/OPTIONS. - GraphQL write detection: operation type
mutation(best-effort).
Allow writes only when:
- The case has
allowWrite: true, AND - Either:
- effective
envislocal, OR - the runner is invoked with
--allow-writes(orAPI_TEST_ALLOW_WRITES_ENABLED=true).
- effective
- JSON results are always emitted to stdout (single JSON object). A one-line summary is emitted to stderr.
- Use
--out <path>to also write the JSON results to a file. - Use
--junit <path>to write a JUnit XML report.
Top-level fields:
version: integersuite: suite namesuiteFile: path to suite file (relative to repo root)runId: timestamp idstartedAt/finishedAt: UTC timestampsoutputDir: output directory (relative to repo root)summary:{ total, passed, failed, skipped }cases[]: per-case status objects
Per-case fields:
id,type,status(passed|failed|skipped),durationMstags(array)command(replayable snippet; no secrets)message(reason for fail/skip; stable-ish tokens)assertions(GraphQL only; includesdefaultNoErrorsand optionaljq)stdoutFile/stderrFile(paths underout/api-test-runner/<runId>/when executed)
Exit codes:
0: all selected cases passed2: one or more cases failed1: invalid inputs / schema / missing files
- Guide:
skills/tools/testing/api-test-runner/references/API_TEST_RUNNER_GUIDE.md