api-tester-cli is a Spring Boot + Spring Shell command-line tool for running HTTP API test suites defined in YAML. Test suites can use Thymeleaf expressions to inject command-line values, values from a local .env file, suite-level variables, and per-test variables into requests and assertions.
The project can run as a regular JVM application or as a GraalVM native binary. The JVM build is easiest for development; the native build starts faster and runs without a JVM at runtime.
Full documentation: https://snytkine.github.io/api-tester-cli/test-suite-configuration/
- Executes HTTP test suites described in YAML
- Supports suite-level
rest_clientdefaults such asbase_url,connect_timeout, shared headers, and HTTP Basic Auth - Supports per-request HTTP Basic Auth with automatic precedence handling
- Applies Thymeleaf templating before execution
- Evaluates a broad set of response assertions, including status, JSON, headers, strings, ranges, arrays, and response time
- Emits JSON results in non-interactive mode
- Can show an interactive terminal UI when running in a compatible TTY
- Can generate a self-contained single-page HTML execution report with
--report - Can write debug logs to files when
CLI_LOG_LEVELandCLI_LOG_DIRare set
./mvnw clean package
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jarThis path requires a GraalVM JDK with native-image installed and available on PATH.
./mvnw -Pnative native:compile
./target/api-tester-cliThe native executable starts significantly faster than the JVM jar and does not require a JVM on the target machine.
The main command is run-suite with the alias rs.
# JVM jar
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar run-suite \
--suite ./src/test/resources/test-suite-1.yml \
api_base_url=https://api.restful-api.dev
# Alias form
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar rs \
--suite ./src/test/resources/test-suite-1.yml \
api_base_url=https://api.restful-api.dev
# Native binary
./target/api-tester-cli run-suite \
--suite ./src/test/resources/test-suite-1.yml \
api_base_url=https://api.restful-api.devCLI variables are passed as positional key=value arguments after --suite. They become available in Thymeleaf expressions as [[${cli.key}]].
Example:
variables:
api_base_url: "[[${cli.api_base_url}]]"Important behavior:
--suiteis required and must point to an existing YAML file.--uiforces the interactive terminal UI.--no-uidisables the UI and writes JSON results to stdout.- Relative file references inside the suite, such as body files or JSON schema files, are resolved relative to the suite file's directory.
- Positional variables must not be written as
--key=value; Spring Shell treats--...tokens as options instead of suite variables.
The top-level structure looks like this:
name: "User API smoke suite"
description: "Basic end-to-end checks for the user endpoints."
rest_client:
base_url: "[[${cli.base_url != null ? cli.base_url : 'http://localhost:8080'}]]"
connect_timeout: 30000
headers:
Accept: "application/json"
variables:
auth_token: "[[${env.AUTH_TOKEN}]]"
request_id: "[[${#strings.randomAlphanumeric(12)}]]"
tests:
- name: "Get all users"
description: "Returns a non-empty user list."
variables:
users_path: "/users"
request:
method: "GET"
url: "[[${suite.base_url}]][[${test.users_path}]]"
headers:
Authorization: "Bearer [[${suite.auth_token}]]"
assertions:
- type: "status_code"
expected: 200
- type: "array_is_not_empty"
path: "response.body.json"
- name: "Create user"
request:
method: "POST"
url: "[[${suite.base_url}]]/users"
headers:
Authorization: "Bearer [[${suite.auth_token}]]"
Content-Type: "application/json"
body:
type: "inline"
content: |
{
"name": "Alice"
}
assertions:
- type: "status_code"
expected: 201
- type: "not_null"
path: "response.body.json.id"
- type: "response_time"
max_ms: 1000name: required suite namedescription: optional suite descriptionrest_client: optional suite-wide HTTP client defaultsvariables: optional suite-level variablestests: required list of test cases
rest_client supports:
base_url: prepended to relative request URLsconnect_timeout: timeout in milliseconds; defaults to30000headers: default headers added to every request in the suiteauth: optional HTTP Basic Auth (suite-level default)
Per-test headers override same-named suite-level headers. Per-test authentication and explicit Authorization headers in request headers override suite-level authentication.
Declare suite-level authentication with auth:
rest_client:
base_url: "https://api.example.com"
auth:
type: "basic"
username: "[[${env.API_USER}]]"
password: "[[${env.API_PASSWORD}]]"Or override with per-request authentication:
tests:
- name: "Admin task"
request:
method: "GET"
url: "/admin/users"
auth:
type: "basic"
username: "[[${env.ADMIN_USER}]]"
password: "[[${env.ADMIN_PASSWORD}]]"
assertions:
- type: "status_code"
expected: 200Best Practice: Store usernames and passwords in a .env file or environment variables, then reference them via [[${env.API_USER}]] and [[${env.API_PASSWORD}]] (never hardcode credentials in the YAML).
Precedence (lowest to highest):
- Suite-level
rest_client.auth(applied as default to all requests) - Per-request
request.auth(overrides suite-level) - Explicit
Authorizationheader inrequest.headers(always wins)
Each item in tests supports:
name: required test namedescription: optional explanationskip: optional skip reason; when non-blank, the test is skippedvariables: per-test variables exposed astest.<name>request: required HTTP request definitionassertions: ordered list of assertions
Requests with methods such as POST, PUT, PATCH, and DELETE can include:
body:
type: "inline"
content: |
{"name":"Alice"}or:
body:
type: "file"
content: "request-body.json"The CLI currently exposes four variable namespaces during template resolution:
| Namespace | Example | Source |
|---|---|---|
cli |
[[${cli.base_url}]] |
Positional key=value arguments after --suite |
env |
[[${env.AUTH_TOKEN}]] |
Variables loaded from a .env file in the suite directory |
suite |
[[${suite.auth_token}]] |
Values resolved from the suite-level variables block |
test |
[[${test.users_path}]] |
Values from the current test case's variables block |
The suite is resolved in two passes:
cliandenvare used to resolve the top-levelvariablesblock.- The resolved suite variables are then exposed through
suite.*while the full file is processed again.
That means values like [[${suite.base_url}]] are the supported form for suite-level references in request URLs, headers, and assertions.
The CLI looks for a .env file in the same directory as the suite YAML file and exposes those values through the env namespace.
Example:
AUTH_TOKEN=supersecret
BASE_URL=https://api.example.comExample usage inside a suite:
variables:
auth_token: "[[${env.AUTH_TOKEN}]]"This is the right place for secrets or machine-specific configuration that should not be committed into the suite itself.
Every assertion is declared inside a test case's assertions list. The type field selects the evaluator.
| Type | Purpose | Minimal YAML example |
|---|---|---|
status_code |
Exact HTTP status match | - type: "status_code"\n expected: 200 |
status_in |
HTTP status must match one of several values | - type: "status_in"\n expected: [200, 202] |
response_time |
Response duration must stay below a threshold in ms | - type: "response_time"\n max_ms: 1000 |
json_schema |
Validate JSON against an inline or file-backed schema | - type: "json_schema"\n path: "response.body.json"\n expected:\n type: "file"\n content: "schemas/user.json" |
json_match |
Compare JSON against an expected structure, with optional ignored fields | - type: "json_match"\n path: "response.body.json"\n expected:\n type: "inline"\n content: '{"status":"ok"}' |
string_match |
String equality check | - type: "string_match"\n path: "response.header.content-type"\n expected: "application/json" |
string_contains |
String contains a substring | - type: "string_contains"\n path: "response.body.text"\n expected: "success" |
regex_match |
String must match a regex pattern | - type: "regex_match"\n path: "response.body.text"\n expected: "^[A-Z0-9_-]+$" |
starts_with |
String prefix check | - type: "starts_with"\n path: "response.body.text"\n expected: "Bearer " |
ends_with |
String suffix check | - type: "ends_with"\n path: "response.body.text"\n expected: ".json" |
not_empty |
Value must exist and not be empty | - type: "not_empty"\n path: "response.body.text" |
not_null |
Value must exist and not be null | - type: "not_null"\n path: "response.body.json.id" |
is_null |
Value must be null | - type: "is_null"\n path: "response.body.json.deletedAt" |
has_header |
Response must contain a named header | - type: "has_header"\n name: "x-request-id" |
value_type |
Value must have a given JSON type | - type: "value_type"\n path: "response.body.json.id"\n expected: "number" |
one_of |
Value must equal one item from a list | - type: "one_of"\n path: "response.body.json.status"\n expected: ["pending", "active"] |
assert_true |
Value must resolve to true | - type: "assert_true"\n path: "response.body.json.enabled" |
assert_false |
Value must resolve to false | - type: "assert_false"\n path: "response.body.json.archived" |
greater_than |
Numeric value must be greater than expected | - type: "greater_than"\n path: "response.body.json.total"\n expected: 0 |
greater_than_or_equal |
Numeric value must be at least expected | - type: "greater_than_or_equal"\n path: "response.body.json.total"\n expected: 1 |
less_than |
Numeric value must be less than expected | - type: "less_than"\n path: "response.body.json.total"\n expected: 100 |
less_than_or_equal |
Numeric value must be at most expected | - type: "less_than_or_equal"\n path: "response.body.json.total"\n expected: 100 |
range |
Numeric value must fall within a min/max range | - type: "range"\n path: "response.body.json.score"\n min: 0\n max: 100 |
array_contains |
Array must contain a specific item | - type: "array_contains"\n path: "response.body.json.roles"\n expected: "admin" |
array_contains_all |
Array must contain all listed items | - type: "array_contains_all"\n path: "response.body.json.roles"\n expected: ["read", "write"] |
array_is_empty |
Array must be empty | - type: "array_is_empty"\n path: "response.body.json.errors" |
array_is_not_empty |
Array must not be empty | - type: "array_is_not_empty"\n path: "response.body.json.items" |
array_size |
Array must have an exact length | - type: "array_size"\n path: "response.body.json.items"\n expected: 3 |
array_size_min |
Array length must be at least a minimum | - type: "array_size_min"\n path: "response.body.json.items"\n min: 1 |
array_size_max |
Array length must be at most a maximum | - type: "array_size_max"\n path: "response.body.json.items"\n max: 10 |
Common response paths used by assertions include:
response.statusresponse.timeMsresponse.body.textresponse.body.jsonresponse.body.json.<field>response.header.<header-name>
The CLI bundles a JSON Schema for the test-suite YAML format. Export a local copy with:
# JVM jar
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar export-schema --out ./schemas
# Native binary
./target/api-tester-cli export-schema --out ./schemas
# Using the alias
./target/api-tester-cli es --out ./schemasThe --out option accepts an absolute or relative path to an output directory. The file is
always written as test-suite-schema.json inside that directory. The directory is created
automatically if it does not exist. An existing file is overwritten. On success the command prints
the absolute path of the written file:
Schema written to: /path/to/schemas/test-suite-schema.json
Tip:
esis a short alias forexport-schema— both invoke the same command.
Once you have a local copy of the schema you can wire it to your test-suite YAML files so that your IDE validates the document as you type and provides field-level completions and inline documentation.
The simplest approach — supported by virtually all editors that have yaml-language-server active — is to add a single comment at the very top of each test-suite YAML file:
# yaml-language-server: $schema=./schemas/test-suite-schema.jsonUse the real path (absolute or relative) to the schema file you exported. No IDE-level configuration is needed; the language server picks up the directive automatically when the file is opened.
For IDE-wide mappings that apply without modifying individual files:
- VS Code — install the YAML extension by Red Hat and add a mapping in
.vscode/settings.json:{ "yaml.schemas": { "./schemas/test-suite-schema.json": "**/*-suite*.yml" } } - IntelliJ IDEA / WebStorm — go to Preferences → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings and add the exported file mapped to your YAML glob pattern.
- Other editors — most editors that support the Language Server Protocol can be configured with yaml-language-server; consult your editor's documentation.
Note: Some IDEs require a third-party YAML plugin to enable schema-based validation and autocompletion. If hints do not appear after wiring the schema, check whether a YAML or JSON Schema plugin is installed and enabled.
For a full walkthrough see Schema Support.
Add --report=<directory> to any run-suite invocation to write a self-contained HTML report
after the run completes. Pass the absolute path to a directory; the file name is generated
automatically.
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar run-suite \
--suite ./src/test/resources/test-suite-1.yml \
--report /tmp/reports \
api_base_url=https://api.restful-api.devThe CLI prints the exact path once the file is written:
Report written to /tmp/reports/test-suite_Test_Suite_1_20260606142300.html
test-suite_<suiteName>_<yyyyMMddHHmmss>.html
<suiteName> is the name field from your YAML with all non-alphanumeric characters replaced
by underscores.
- Header — suite name, optional description, and generation timestamp
- Summary cards — passed / failed / skipped / error / total counts
- Per-test cards — one card per test case, showing:
- Result badge and assertion pass/fail counts
- Expandable Request section (method, URL, headers, body)
- Expandable Response section (status code, response time, headers, pretty-printed body)
- Expandable Failed Assertions section (description, expected vs actual, error message)
The report is fully self-contained (CSS embedded, no JavaScript, no CDN dependencies) and opens in any browser without an internet connection.
For full documentation see HTML Execution Report.
File-based logging is controlled by environment variables rather than a command-line flag.
Set both variables before launching the CLI:
CLI_LOG_LEVEL: one ofTRACE,DEBUG,INFO,WARN,ERRORCLI_LOG_DIR: directory where log files should be written
Example:
CLI_LOG_LEVEL=DEBUG CLI_LOG_DIR=./logs \
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar run-suite \
--suite ./src/test/resources/test-suite-1.ymlWhen either variable is missing or invalid, the CLI runs normally without creating a log file.
For more detail, see DEBUG_LOGGING_README.md.
- Spring Boot version:
4.0.6 - Spring Shell version:
4.0.2 - Java version:
25 - Build tool: Maven Wrapper via
./mvnw
Useful repository files:
Before opening a PR:
./mvnw test