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://cmdrest.com/docs
- Executes HTTP test suites described in YAML
- Supports one default
rest-clientor multiple namedrest-clientswith per-request selection — each withbase-url,connect-timeout, shared headers, HTTP Basic Auth, optional redirect following, and custom SSL/TLS (self-signed certs, custom truststore, and mutual-TLS client certificates) - 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
- Supports lifecycle hooks — local scripts or outbound web calls fired at suite-execution phases (before/after all, before/after each test, and around report generation); script hooks are gated behind
--allow-scripts/APITESTER_ALLOW_SCRIPTS=true(see Lifecycle Hooks) - 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(browser-side JSON formatting via optional inline JS) - Can write debug logs to files when
CLI_LOG_LEVELandCLI_LOG_DIRare set - Checks GitHub in the background on startup for a newer release and surfaces an upgrade message in the HTML report and terminal UI (see Upgrade Notifications); never blocks startup or a run and can be disabled via
apitester.version-check.enabled=false
./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.
# Simplest form — run test-suite.yml in the current directory
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar run-suite
# Explicit suite path (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 (no --suite — uses test-suite.yml in current directory)
./target/api-tester-cli run-suiteCLI variables are passed as positional key=value arguments after all named options. They become available in Thymeleaf expressions as [[${cli.key}]].
Example:
variables:
api_base_url: "[[${cli.api_base_url}]]"Important behavior:
--suiteis optional. When omitted, the CLI looks fortest-suite.ymlin the current working directory and uses it automatically. If that file is not found either, the command exits with an error.--tag=smokeruns only tests taggedsmoke. Prefix with!to invert:--tag="!slow"runs all tests except those taggedslow(tests with no tags are always included under a negated filter).--uiforces the interactive terminal UI.--no-uidisables the UI and writes JSON results to stdout.--env-file=<path>loads environment variables from an explicit file (which need not be named.env), letting you run the same suite against different environments (e.g.--env-file=/path/to/staging.env). See The.envFile for the full resolution order.- 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/rest-clients: HTTP client(s) — exactly one is required (see below)variables: optional suite-level variablestests: required list of test cases
Every suite must declare its HTTP client(s) using exactly one of two mutually exclusive keys. Declaring neither — or both — is a validation error.
rest-client (singular) — shorthand for a single default client:
rest-client:
base-url: "https://api.example.com"
connect-timeout: 30000
headers:
Accept: "application/json"rest-clients (plural) — multiple named clients, each with an id. A request selects
one via its own rest-client property; requests without one use the client whose id is
default:
rest-clients:
- id: default
base-url: "https://api.example.com"
- id: payments
base-url: "https://payments.example.com"
connect-timeout: 10000
tests:
- name: "Pay invoice"
request:
rest-client: payments # selects the 'payments' client
method: "POST"
url: "/invoices/pay"
assertions:
- type: "status_code"
expected: 200Each client supports:
id: client id (required when more than one client is configured; implicitlydefaultfor a single unnamed client)base-url: prepended to relative request URLsconnect-timeout: timeout in milliseconds; defaults to30000headers: default headers added to every request using the clientauth: optional HTTP Basic Auth (client-level default)ssl: optional custom SSL/TLS settings — skip certificate validation, a custom truststore, and/or a client keystore for mutual TLS (see below)follow-redirects: whether to follow HTTP 3xx responses; defaults totrue(see below)proxy: optional HTTP proxy to route this client's requests through, orfalseto opt out of an environment proxy (see below)
Per-test headers override same-named client-level headers. Per-test authentication and explicit Authorization headers in request headers override client-level authentication. The per-request rest-client selector is ignored (a warning is logged) when the singular rest-client form is used.
By default a rest-client transparently follows HTTP redirect (3xx) responses, so a test that
requests a redirecting URL sees the final response from the redirect target. To test the redirect
itself — its status code and its Location header — set follow-redirects: false:
rest-client:
base-url: "https://api.example.com"
follow-redirects: false
tests:
- name: "legacy path redirects permanently"
request:
method: "GET"
url: "/old-path"
assertions:
- type: "status_code"
expected: 301
- type: "string_contains"
path: "response.headers.location"
expected: "/new-path"This option exists only on the rest-client, not on individual tests. If some tests in a suite
need redirects followed and others do not, declare a second rest-client that shares the same
configuration but adds follow-redirects: false, then select it from the tests that need it:
rest-clients:
- id: "default"
base-url: "https://api.example.com"
- id: "no-redirect"
base-url: "https://api.example.com"
follow-redirects: false
tests:
- name: "follows the redirect"
request:
method: "GET"
url: "/old-path"
assertions:
- type: "status_code"
expected: 200
- name: "inspects the redirect itself"
request:
method: "GET"
url: "/old-path"
rest-client: "no-redirect"
assertions:
- type: "status_code"
expected: 301Declare client-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):
- Client-level
rest-client.auth(applied as default to all requests using the client) - Per-request
request.auth(overrides client-level) - Explicit
Authorizationheader inrequest.headers(always wins)
Each client may declare an ssl block to test HTTPS endpoints that use self-signed
certificates, a private certificate authority, or that require a client certificate
(mutual TLS). All certificate/key file paths may be absolute or relative to the
test-suite file's directory.
rest-client:
base-url: "https://api.example.com"
ssl:
# Trust a self-signed server certificate or a private CA:
truststore:
certificate: "certs/ca.pem"
# Present a client certificate for mutual TLS (mTLS):
keystore:
certificate: "certs/client.pem"
private-key: "certs/client.key" # PKCS#8 PEM (.key)
password: "[[${env.KEYSTORE_PASSWORD}]]" # only for an encrypted keyssl properties:
skip-certificate-validation: whentrue, disable certificate and hostname verification (allows self-signed certificates). When set,truststoreandkeystoreare ignored. Defaults tofalse. Use only against trusted, non-production endpoints.truststore.certificate: path to a PEM certificate to trust (in addition to the JVM's default trust anchors).keystore.certificate: path to the PEM client certificate presented during the TLS handshake.keystore.private-key: optional path to the client's PKCS#8 private key (-----BEGIN PRIVATE KEY-----or, when encrypted,-----BEGIN ENCRYPTED PRIVATE KEY-----). Required for the client to actually authenticate via mTLS. Legacy PKCS#1 keys (-----BEGIN RSA PRIVATE KEY-----) are rejected — convert them withopenssl pkcs8 -topk8 -in key.pem -out key-pkcs8.pem.keystore.password: passphrase that decrypts an encrypted private key. Only allowed whenprivate-keyis set.
Best Practice: never commit passwords. Keep keystore.password in a .env file or
environment variable and reference it with [[${env.KEYSTORE_PASSWORD}]] so the suite
file can be shared or committed to git safely.
Invalid SSL configuration (missing/unreadable files, a password without a private key, a wrong key password, or an unsupported key format) fails the run up front, before any test executes.
Requests can optionally go through an HTTP proxy — typically a corporate egress proxy. This is entirely optional: with nothing configured, requests connect directly as before.
rest-client:
base-url: "https://api.example.com"
proxy:
url: "http://proxy.mycompany.com:8080"
username: "[[${env.PROXY_USER}]]"
password: "[[${env.PROXY_PASSWORD}]]"proxy properties:
url: proxy URL ashttp://host[:port]; the port defaults to80.username/password: optional credentials for proxies that require authentication.passwordis only allowed alongside ausername.
Best Practice: never write proxy credentials into a test-suite file. Keep them in a
.env file or environment variable and reference them with [[${env.PROXY_USER}]] so the
suite can be committed to git safely.
The HTTP_PROXY and HTTPS_PROXY environment variables are honoured automatically for any
rest-client that does not declare a proxy key — no opt-in flag is needed. They may
carry credentials inline (http://user:pass@proxy:8080), and HTTP_PROXY applies to
http:// targets while HTTPS_PROXY applies to https:// targets. A rest-client that
declares its own proxy object ignores them, unless PROXY_USE_ENV=true is set, which
flips the precedence so the environment replaces the YAML block in its entirety,
credentials included.
To keep one client off the proxy — an internal service that must be reached directly while
external calls are proxied — set proxy: false. This is absolute: neither HTTP_PROXY,
HTTPS_PROXY nor PROXY_USE_ENV can override it.
rest-clients:
- id: "external"
base-url: "https://api.partner.com" # inherits HTTP_PROXY / HTTPS_PROXY
- id: "internal"
base-url: "https://svc.internal.local"
proxy: false # always connects directlyNote there is deliberately no proxy TLS option. The underlying JDK HTTP client connects
to the proxy in plaintext and tunnels the endpoint's own TLS through it with CONNECT, so
there is no proxy certificate to validate. Certificate validation for the API endpoint is
governed by the ssl block above and is unaffected by proxying. For the same reason a
https:// proxy URL is rejected up front.
Only Basic proxy authentication is supported; NTLM and Negotiate/Kerberos proxies are not, as the JDK HTTP client implements no other scheme. A proxy demanding one of those fails with a message naming the scheme.
Invalid proxy configuration (a missing or malformed url, an https:// URL, a password
without a username, proxy: true, or an unparseable HTTP_PROXY value) fails the run up
front, before any test executes. At run time, proxy-specific failures — an unreachable
proxy, a rejected credential, a refused tunnel — are reported as such rather than as a
connection error against the endpoint, which was never contacted.
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: optional ordered list of assertions; may be omitted or empty, in which case the test is verified solely by the implicitbase_server_responseassertionsaved-session: optional list of response-value captures stored into the suite-widesessionnamespace (see Chaining tests)depends-on: optional list of other test names that must run before this testtransient: optional boolean; whentruethe test runs only as another test's dependency, never standalone
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 exposes five 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 |
session |
[[${session.recordId}]] |
Values captured from earlier tests' responses via saved-session |
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 session namespace is different: like test, it is resolved only during test execution (when building a test's request URL, headers, and body — including bodies loaded from a type: file), never during the two suite-level passes above. It starts empty and is populated as tests run.
A test can capture values from its own response into the suite-wide session namespace, and other tests can depend on it so it runs first and its captured values are available.
tests:
- name: "CreateRecord"
transient: true
request:
method: "POST"
url: "[[${suite.base_url}]]/records"
body:
type: "inline"
content: '{"label":"widget"}'
saved-session:
- name: "recordId" # stored under session.recordId
path: "response.body.json.$.id"
type: "integer" # optional: string | integer | double | boolean
required: true # fail the test if nothing is extracted (and no default)
- name: "etag"
path: "response.headers.etag"
assertions:
- type: "status_code"
expected: 201
- name: "GetRecord"
depends-on: ["CreateRecord"]
request:
method: "GET"
url: "[[${suite.base_url}]]/records/[[${session.recordId}]]"
assertions:
- type: "status_code"
expected: 200Each saved-session entry has:
name— key under which the value is stored (used later as[[${session.<name>}]]). Name uniqueness across tests is the user's responsibility; duplicates are overwritten last-write-wins.path— aresponse.*expression (the same language used by assertions), e.g.response.statusCode,response.headers.<h>,response.body.text, orresponse.body.json.$.<jsonpath>.type— optional coercion tostring,integer,double, orboolean. A value that cannot be converted fails the test.default— optional fallback used when the path extracts nothing; when set, the capture always resolves.required— optional (defaultfalse); whentrueand nodefaultis set, a missing/unextractable value fails the test.
Captured values must be primitives (string, integer, double, boolean). Extracting an object or array is an error — you cannot store a JSON object/array in session.
Captures happen only after all of a test's assertions pass. saved-session values are extracted and written to the session namespace only once the test succeeds. If any assertion fails, the test is marked failed and none of its saved-session values are stored — so a dependent test never runs against values captured from a failed parent (it is marked failed via the depends-on propagation described below).
depends-on lists other test names that must run before this test. Dependencies run in the listed order, resolved transitively (A → B → C runs C, then B, then A). A depends-on that names an unknown test, or that forms a cycle, is reported as a validation error before any test runs.
A depended-on test runs at most once per suite run. If several tests depend on the same test, it executes a single time and its result — including its captured session values — is reused by every dependent (its request is not re-sent). If a dependency ends up failed or errored, each test that depends on it is automatically marked failed and its own request is not sent. Because such a test sends no request and evaluates no assertions of its own, it is reported as a distinct kind of failure: the terminal shows a single Error row reading This test depends on a failed parent test "<name>". (no assertion/expected/actual rows), and the HTML report shows a Failed parent test expandable block with the same message instead of Request, Response, or Failed Assertions sections.
A transient: true test runs only when another test depends on it — never as a standalone test. This is useful for setup steps (like CreateRecord above) that only exist to feed values into dependents. Transient tests also do not fire before-each or after-each hooks — those hooks fire for the dependent (non-transient) test that triggered them.
The CLI loads environment variables from a .env-style 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.
The file that supplies the env namespace is resolved in the following order:
--env-file=<path>supplied — that exact file is used. It need not be named.env, so you can keep per-environment files (e.g.dev.env,staging.env,prod.env) and swap them per invocation. If the path does not point to an existing regular file, the command fails with an error and aborts — this is the only case where a missing env file is treated as an error, because you asked for a specific file.- No
--env-file— the CLI looks for a.envfile in the current working directory. - No
.envin the current working directory — the CLI falls back to a.envfile in the directory containing the suite YAML file (the historical default).
Cases 2 and 3 fail silently: if no .env is found anywhere the run proceeds with only the process/system environment variables. System environment variables always take precedence over same-named entries in the env file.
Example — run the same suite against staging:
rs --suite=/path/to/suite.yml --env-file=/path/to/staging.envEvery assertion is declared inside a test case's assertions list. The type field selects the evaluator. The list is optional — a test that omits it (or declares assertions: []) still runs and is still verified by the implicit assertion below, which is handy for a test that exists only as a depends-on parent or one kept in the suite to be fired manually.
Every test case additionally carries one assertion you never declare: base_server_response. It asserts only that the request was dispatched and that some HTTP response came back before the rest-client's timeout elapsed — the status code, headers and body are irrelevant, so a 500 passes it just as a 200 does.
It fails only when no response is obtained at all (connection refused, unknown host, TLS handshake failure, connection timeout). The test is then reported as failed — not errored — with this single failure, because none of the declared assertions could be evaluated:
| Assertion | Expected | Actual |
|---|---|---|
base_server_response |
service must respond within default timeout of 30 seconds |
no response received: Connection refused |
The timeout in the expected text is the dispatching rest-client's connect-timeout in seconds (30 unless the suite overrides it). Since this assertion is always evaluated, reported assertion counts are one higher than the number declared in the YAML.
| 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.
Print the application version with the version command:
# JVM jar
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar version
# Native binary
./target/api-tester-cli versionOutput:
Api Tester CLI version 0.2.1
The version is embedded at build time from pom.xml, so it is accurate for both the JVM jar and
the GraalVM native binary. The same version appears in the footer of generated HTML reports.
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 file:///tmp/reports/test-suite_Test_Suite_1_20260606142300.html
When the interactive terminal UI is active, the path is emitted as an
OSC 8 hyperlink, so terminals
that support them render it as a clickable link — Cmd-click on macOS, Ctrl-click elsewhere — which
opens the report in your default browser. Terminals without OSC 8 support (notably macOS
Terminal.app) simply show the plain file:// URI, which remains copy-pasteable.
The escape sequences are only emitted when output goes to an interactive terminal. With --no-ui,
in CI, or when stdout is piped or redirected, the report path is printed as a plain absolute path so
that captured output is never corrupted.
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)
- Expandable Error section for a test whose result is
ERROR(e.g. an HTTP I/O failure such as a connection refused because the target service is not running), showing the captured error text. An error is not an assertion outcome, so it is never listed under Failed Assertions
The report is fully self-contained (all CSS embedded, no CDN dependencies) and opens in any
browser without an internet connection. By default a small inline JavaScript formatter is
included to pretty-print JSON bodies in the browser; set REPORT_NO_JS=true to disable it.
Set REPORT_NO_MINIFY=true to skip HTML minification. Both flags can be set in the OS
environment or in the suite's .env file.
For full documentation, including a behaviour matrix for these options, see HTML Execution Report.
File-based logging is controlled by two variables rather than a command-line flag. Provide both
through either the OS environment or a .env file (including one passed with --env-file):
CLI_LOG_LEVEL: one ofTRACE,DEBUG,INFO,WARN,ERRORCLI_LOG_DIR: directory where log files should be written
Example (exported environment):
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.ymlExample (via a .env file, no export needed):
printf 'CLI_LOG_LEVEL=DEBUG\nCLI_LOG_DIR=./logs\n' > .env
java -jar target/api-tester-cli-0.0.1-SNAPSHOT.jar run-suite \
--suite ./src/test/resources/test-suite-1.ymlExported OS variables are honoured from startup; .env/--env-file values are honoured when the
run-suite command loads them (the OS environment wins if a variable is set in both). When 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