api-test is the suite orchestrator for the api-testing stack. It loads a v1
suite manifest, dispatches each case to the matching backend runner
(REST / GraphQL / gRPC / WebSocket), captures results JSON to stdout, and
optionally writes a JUnit XML file plus a Markdown summary.
The dispatch + run loop is implemented by api_testing_core::suite::runner;
JUnit rendering by api_testing_core::suite::junit; results by
api_testing_core::suite::results::{SuiteRunResults, SuiteCaseResult}. See
crates/api-testing-core/README.md for the
shared library surface.
Usage: api-test <command> [args]
Commands:
run Run a suite (default)
summary Render a Markdown summary from results JSON
completion Print shell completion script
Common options (run; see `api-test run --help` for full details):
--suite <name> Resolve suite under tests/api/suites/<name>.suite.json
--suite-file <path> Explicit suite file path
--tag <tag> Filter cases by tag (repeatable; AND semantics)
--only <csv> Run only listed case IDs (comma-separated)
--skip <csv> Skip listed case IDs (comma-separated)
--fail-fast Stop after first failure
--out <path> Write results JSON to a file (stdout still emits JSON)
--junit <path> Write optional JUnit XML to a file
-h, --help Print help
Examples:
api-test --help
api-test --suite smoke --help
api-test run --suite smoke --out results.json
api-test completion zsh
The first positional run is implicit: bare flag invocations
(e.g. api-test --suite smoke) are rewritten to
api-test run --suite smoke before parsing. Explicit run / summary /
completion words are honored as-is.
run(default): execute a suite and write results JSON to stdout. Required: exactly one of--suite <name>or--suite-file <path>. Options:--out <path>,--junit <path>,--allow-writes,--tag <tag>(repeatable; AND semantics),--only <csv>,--skip <csv>,--fail-fast/--continue(mutually exclusive;--continueis the default).summary: render a Markdown summary from a results JSON document (stdin by default, or--in <path>). Options:--out <path>,--slow <n>(default5),--hide-skipped,--max-failed <n>(default50),--max-skipped <n>(default50),--no-github-summary(skip writing to$GITHUB_STEP_SUMMARY).completion: print a shell completion script. Argument:<SHELL>(bashorzsh).
run exit codes (driven by SuiteRunResults::exit_code):
0— every casepassedorskipped.2— at least one casefailed.1— orchestrator-level error (suite not found, schema invalid, results serialization failure, JUnit write failure).
Top-level fields (camelCase):
version(required, must be1).name(optional; falls back to the suite filename).defaults(optional; per-protocol overrides applied to each case).auth(optional; shared login flow that produces a bearer token reused by REST/GraphQL cases — seeapi-testing-coredocs for details).cases(required, ordered list of case objects).
defaults accepts one block per backend (every block is independently
optional):
| Block | Fields |
|---|---|
defaults.rest |
configDir (default setup/rest), url, token |
defaults.graphql |
configDir (default setup/graphql), url, jwt |
defaults.grpc |
configDir (default setup/grpc), url, token |
defaults.websocket |
configDir (default setup/websocket), url, token |
url is either an endpoint preset name resolved against the backend's
endpoints.env or a literal URL/target; token / jwt is a profile name
resolved against the backend's token store. Suite-level defaults.env
and defaults.noHistory apply to every case unless overridden inline.
Each case object selects a backend via the type field. The runner
normalizes ws to websocket before dispatch (see
api_testing_core::suite::runner::context::case_type_normalized), so both
spellings are accepted.
type value |
Backend runner | Required case field | Notes |
|---|---|---|---|
rest |
api-rest (in-process via api_testing_core::rest) |
request (path to *.request.json) |
Optional: env, url, token, configDir, noHistory, allowWrite, cleanup. |
rest-flow (alias rest_flow) |
api-rest login + main request |
loginRequest and request |
Optional: tokenJq (jq filter that extracts the bearer token from the login response; defaults to a permissive accessToken/access_token/token selector). |
graphql |
api-gql (in-process via api_testing_core::graphql) |
op (path to *.graphql) |
Optional: vars (path to JSON variables), jwt, allowErrors, expect.jq (required when allowErrors=true). |
grpc |
api-grpc (in-process via api_testing_core::grpc) |
request (path to *.grpc.json) |
Optional: env, url, token, configDir, noHistory, plus gRPC-only fields handled by the request file (grpcProto, grpcImportPaths, grpcPlaintext). |
websocket (alias ws) |
api-websocket (in-process via api_testing_core::websocket) |
request (path to *.ws.json or *.websocket.json) |
Optional: env, url, token, configDir, noHistory. |
Cases are filtered before dispatch using --tag (AND semantics across
repeats), --only, --skip, and the allowWrite safety gate. A case
that opts into writes (allowWrite: true) only runs when --allow-writes
is set, or when the suite-level
API_TEST_ALLOW_WRITES_ENABLED env var is truthy.
{
"version": 1,
"name": "smoke",
"defaults": {
"rest": {"configDir": "setup/rest"},
"graphql": {"configDir": "setup/graphql", "jwt": "default"},
"grpc": {"configDir": "setup/grpc", "url": "local", "token": "default"},
"websocket": {"configDir": "setup/websocket", "url": "local", "token": "default"}
},
"cases": [
{"id": "rest-health", "type": "rest", "request": "setup/rest/requests/health.request.json"},
{"id": "gql-health", "type": "graphql", "op": "setup/graphql/operations/health.graphql"},
{"id": "grpc-health", "type": "grpc", "request": "setup/grpc/requests/health.grpc.json"},
{"id": "ws-health", "type": "websocket", "request": "setup/websocket/requests/health.ws.json"}
]
}Stdout is exactly one JSON object per run invocation, also written to
--out <path> when supplied:
{
"version": 1,
"suite": "smoke",
"suiteFile": "tests/api/suites/smoke.suite.json",
"runId": "20260131-000000Z",
"startedAt": "2026-01-31T00:00:00Z",
"finishedAt": "2026-01-31T00:00:01Z",
"outputDir": "out/api-test-runner/20260131-000000Z",
"summary": {"total": 4, "passed": 3, "failed": 1, "skipped": 0},
"cases": [
{
"id": "rest-health",
"type": "rest",
"status": "passed",
"durationMs": 12,
"tags": [],
"stdoutFile": "out/api-test-runner/.../rest-health.response.json",
"stderrFile": "out/api-test-runner/.../rest-health.stderr.log"
}
]
}Per-case fields (camelCase, optional fields are omitted when empty):
statusis one ofpassed,failed,skipped.typeis the normalized dispatch value (wscollapses towebsocket).commandcarries the rendered backend command snippet (REST/GraphQL emit the equivalentapi-rest call/api-gql callinvocation; gRPC and WebSocket emit the in-process equivalent).messageis set onfailed/skippedcases (e.g.cleanup_failed,rest_flow_login_failed, the selection-skip reason, or the runner's own diagnostic).assertionsmirrors per-runner expect output (currently surfaced by GraphQL expect/allow-errors flow and gRPC/WebSocket assertions).stdoutFile/stderrFileare repo-relative paths underoutputDir.
The output directory defaults to <repo>/out/api-test-runner/<runId>/
and can be overridden with the API_TEST_OUTPUT_DIR env var (relative
paths resolve against the repo root).
--junit <path> writes a single <testsuite> element per run via
api_testing_core::suite::junit::render_junit_xml. The shape is:
<?xml version="1.0" encoding="utf-8"?>
<testsuite name="smoke" tests="4" failures="1" skipped="0">
<testcase name="rest-health" classname="rest" time="0.012"/>
<testcase name="gql-health" classname="graphql" time="0.034">
<failure message="graphql_runner_failed">command: api-gql call ...
stdoutFile: out/.../gql-health.response.json
stderrFile: out/.../gql-health.stderr.log</failure>
</testcase>
<testcase name="ws-skip" classname="websocket" time="0.000"><skipped message="filtered_by_only"/></testcase>
</testsuite>Notes:
<testsuite>attributes:name(suite name or filename),tests,failures,skipped(taken fromsummary).<testcase>attributes:name= caseid,classname= normalized casetype,time=durationMs / 1000formatted to 3 decimals.<failure>carries the runnermessageand a body listingcommand,stdoutFile,stderrFilewhen present (XML-escaped).<skipped>carries the skip reason inmessage.- Passed cases emit
<testcase ... />with no child element.
| Variable | Effect |
|---|---|
API_TEST_OUTPUT_DIR |
Override the per-run output directory base (default <repo>/out/api-test-runner). Relative values resolve against the repo root. |
API_TEST_PROGRESS |
auto (default), on/1/true/yes, or off/0/false/no. Controls the stderr progress bar. |
API_TEST_ALLOW_WRITES_ENABLED |
Truthy value forces --allow-writes on for every case (still gated by per-case allowWrite: true). |
API_TEST_REST_URL |
Overrides REST endpoint resolution for suite runs. |
API_TEST_GQL_URL |
Overrides GraphQL endpoint resolution for suite runs. |
API_TEST_GRPC_URL |
Overrides gRPC target resolution for suite runs. |
API_TEST_WS_URL |
Overrides WebSocket target resolution for suite runs. |
API_TEST_AUTH_JSON |
Default secret-env name for auth.secretEnv (overridable in the manifest). |
GITHUB_STEP_SUMMARY |
When set and summary --no-github-summary is not passed, the rendered Markdown summary is appended to this file. |
| Capability | Status | Evidence |
|---|---|---|
| suite selection/filtering | unchanged | cargo test -p nils-api-testing-core --test suite_rest_graphql_matrix |
| run/result envelope | unchanged | cargo test -p nils-api-testing-core --test suite_runner_loopback |
| summary/JUnit generation | unchanged | cargo test -p nils-api-testing-core suite::summary suite::junit |
| grpc protocol dispatch | additive grpc | cargo test -p nils-api-testing-core --test suite_runner_grpc_matrix |
| websocket protocol dispatch | additive websocket | cargo test -p nils-api-testing-core --test suite_runner_websocket_matrix |
| suite schema validation | additive | cargo test -p nils-api-test suite_schema |
| env override wiring | additive | cargo test -p nils-api-testing-core suite::runtime_tests |
- Docs index
- Sibling backend crates:
api-rest,api-gql,api-grpc,api-websocket. - Shared library:
api-testing-core.