REST (HTTP/HTTPS)¶
Real fetch() calls against a real deployed app's real in-cluster
Service DNS name — no kubectl port-forward needed. These steps are the
same shape as curl -X <method> <url> -H ... -d ..., just as a real
Gherkin table instead of flags. See BDD
conventions for the underlying pattern
and Kubernetes: Service for how the Service an
Endpoint wraps is discovered.
HTTP/HTTPS Endpoint¶
Define an HTTP Endpoint¶
Given HTTP Endpoint "<Api>" on "<ApiService>" port "8000"
Given HTTP Endpoint known as "<Api>":
| PROPERTY | VALUE |
| service | <ApiService> |
| port | 8000 |
Registers a real endpoint: service resolves to the actual Service
object (its base URL needs the object's real name/namespace), port is
the target port. RestEndpoint.baseUrl becomes
http://<service>.<namespace>.svc.cluster.local:<port>. Construction is
pure — no live check; the first real validation is the paired
When I send a .... Negative-path tests (an unregistered service
alias reference) use the same Payload
accumulator shape as
every other constructed type:
Given "<BadEndpointPayload>" field "service" is "<UndefinedRestService>"
And "<BadEndpointPayload>" field "port" is "8000"
When I attempt to define HTTP Endpoint known as "<BadEndpoint>" using "<BadEndpointPayload>"
Then it should have failed with 'No Service registered as "<UndefinedRestService>"'
Given Service known as "<NotesService>":
| PROPERTY | VALUE |
| namespace | thomas-helm-test |
| app.kubernetes.io/instance | thomas-notes-release |
And HTTP Endpoint "<NotesApi>" on "<NotesService>" port "8000"
When I send a POST request to Endpoint known as "<NotesApi>" path "/notes/" with:
| TYPE | KEY | VALUE |
| FIELD | title | Groceries |
Then the response status is 201
A Service discovered from a Helm Release, wrapped as an
HTTP Endpoint, then used for a real request.
Define an HTTPS Endpoint¶
Given HTTPS Endpoint "<TlsRestApi>" on "<TlsRestApiService>" port "8443" trusting "<TlsRestApiSecret>"
Given HTTPS Endpoint known as "<TlsRestApi>":
| PROPERTY | VALUE |
| service | <TlsRestApiService> |
| port | 8443 |
| trust | <TlsRestApiSecret> |
Same service/port fields, plus a required trust — a registered
Secret whose real data["tls.crt"] becomes the
one certificate this endpoint's real per-request undici Agent trusts.
Verification stays real: it is never disabled (no
rejectUnauthorized: false anywhere in this project), so a request
against a genuinely wrong/expired/mismatched certificate throws for
real rather than silently succeeding — see BDD conventions: Define,
then act for why
construction itself still does no live check. Negative-path coverage
(a missing trust field) is in features/rest/health-full.feature:
When I attempt to define HTTPS Endpoint known as "<TrustlessEndpoint>":
| PROPERTY | VALUE |
| service | <TrustlessService> |
| port | 8443 |
Then it should have failed with 'An HTTPS Endpoint requires a "trust" field naming a registered Secret to verify against'
In practice (features/rest/health-{short,full}.feature):
Given Service known as "<TlsRestApiService>":
| PROPERTY | VALUE |
| namespace | thomas-helm-test |
| app.kubernetes.io/instance | thomas-rest-api-tls |
# Real cert-manager async issuance - same bounded-retry Secret discovery
# as [Kubernetes: TLS](KUBECTL.md#tls-certificates).
Given Secret known as "<TlsRestApiSecret>":
| PROPERTY | VALUE |
| namespace | thomas-helm-test |
| app.kubernetes.io/instance | thomas-rest-api-tls |
And HTTPS Endpoint "<TlsRestApi>" on "<TlsRestApiService>" port "8443" trusting "<TlsRestApiSecret>"
When I send a GET request to Endpoint known as "<TlsRestApi>" path "/health/live"
Then the response status is 200
A self-signed certificate is its own CA, so the same tls.crt the
server presents is exactly what a client needs to trust it — no separate
ca.crt field, no new Secret shape; this reuses the Secret kind
already built for Kubernetes: TLS directly.
Define an HTTP Endpoint on a Pod¶
Given HTTP Endpoint "<Api>" on Pod known as "<ApiPod>" port "8000"
Given HTTP Endpoint known as "<Api>":
| PROPERTY | VALUE |
| pod | <ApiPod> |
| port | 8000 |
The Service-bypassing sibling of the service field above: pod
resolves to a real Pod object, and RestEndpoint's constructor does a
real, live kubectl get pod ... -o jsonpath={.status.podIP} lookup to
build http://<real-ip>:<port> — the only way to reach a Pod that a
Service's own readiness gate would otherwise never route to. service
and pod are mutually exclusive; exactly one is required. If the Pod
might not have a real IP yet, poll for it first (status.podIP via the
same generic poll-resource-field mechanism used for
status.readyReplicas) — the constructor throws a specific error rather
than silently building a broken http://:PORT URL.
In practice (features/rest/health-{short,full}.feature's
"Flipping readiness" scenario) — proving a fixture's liveness endpoint
keeps responding even after its own readiness probe has failed and the
Service has stopped routing to it:
Given Pod known as "<ReadinessPod>":
| PROPERTY | VALUE |
| namespace | thomas-helm-test |
| app.kubernetes.io/instance | thomas-rest-api-readiness |
When I poll Pod known as "<ReadinessPod>" every "3s" for up to "30s" until:
| KEY | CONDITION | VALUE | OUTCOME |
| status.containerStatuses[0].ready | equals | false | pass |
And HTTP Endpoint "<ReadinessPodApi>" on Pod known as "<ReadinessPod>" port "8000"
When I send a GET request to Endpoint known as "<ReadinessPodApi>" path "/health/live"
Then the response status is 200
Sending Requests¶
Send a Request¶
When I send a {httpMethod} request to Endpoint known as "<Alias>" path "<path>"
Sends a bare request (no headers/query/body). {httpMethod} accepts
exactly GET, POST, PUT, PATCH, or DELETE — the real method set
this project's REST fixture (charts/test-rest-api/) exposes and every
scenario exercises. Writing anything else (e.g. TRACE) fails to match
any step at all, rather than being sent and failing at the HTTP layer.
When I send a GET request to Endpoint known as "<RestApi>" path "/health/live"
Then the response status is 200
Polling Until a Request Succeeds¶
When I poll Endpoint known as "<Alias>" path "<path>" every "<interval>" for up to "<timeout>" until the {httpMethod} request succeeds
A real IP existing (a Pod-target Endpoint,
right after its Pod comes into existence) doesn't mean the process behind
it is listening on that port yet — confirmed live: a bare When I send
a ... against a fresh Pod-target Endpoint can fail outright
(TypeError: fetch failed) for a real, brief window. This retries the
real request itself until it connects — "succeeds" means a real
connection was made, not that the response was 2xx (a real 503 still
needs a real, successful connection first, and is a perfectly valid thing
to assert on afterward):
Given Pod "<AppPod>"
And "<AppPod>" namespace is "dev"
And "<AppPod>" label "app.kubernetes.io/instance" is "my-release"
When I wait for Pod known as "<AppPod>" every "2s" for up to "30s"
When I poll Pod known as "<AppPod>" every "2s" for up to "30s" until:
| KEY | CONDITION | VALUE | OUTCOME |
| status.podIP | exists | | pass |
And HTTP Endpoint "<AppApi>" on Pod known as "<AppPod>" port "8080"
When I poll Endpoint known as "<AppApi>" path "/health/live" every "2s" for up to "30s" until the GET request succeeds
Then the response status is 200
Send a Request with Headers, Query, or Body¶
When I send a {httpMethod} request to Endpoint known as "<Alias>" path "<path>" with:
Same, plus a TYPE|KEY|VALUE table to build headers/query/body. Both
this and the bare form have I attempt to send a ... siblings for
genuine network-failure negative tests. path and every table VALUE
cell go through embedded <...>-substitution against
captured values first.
BODY/FIELD rows/FORM+FILE rows are three mutually exclusive ways
to build one request body — mixing more than one throws.
A second, additive short form exists specifically for FIELD rows: a
real Payload accumulator
built up across several Given/And lines, then sent as one unit —
HEADER/QUERY/BASIC_AUTH/BODY/FORM/FILE rows still need the
table form shown above, since they're not all flat key: value pairs:
Given "<NewGroceryNote>" has FIELD "title" "Groceries"
And "<NewGroceryNote>" has FIELD "body" "Milk, eggs"
When I send "<NewGroceryNote>" as POST to Endpoint known as "<NotesApi>" path "/notes/"
Then the response status is 201
When I send a POST request to Endpoint known as "<NotesApi>" path "/notes/" with:
| TYPE | KEY | VALUE |
| FIELD | title | Groceries |
| FIELD | body | Milk, eggs |
Then the response status is 201
HEADER¶
A request header.
A single header also has a oneline with header {string} {string}
sibling — the table form is what a 2+ header request, or one combined
with other row types, needs instead:
When I send a GET request to Endpoint known as "<AuthApi>" path "/auth/api-key/whoami" with header "X-API-Key" "<ApiKey>"
Then the response status is 200
When I send a GET request to Endpoint known as "<AuthApi>" path "/auth/api-key/whoami" with:
| TYPE | KEY | VALUE |
| HEADER | X-API-Key | <ApiKey> |
Then the response status is 200
QUERY¶
A query-string parameter.
A single query parameter also has a oneline with query {string}
{string} sibling; 2+ parameters (as in the OAuth /oauth/authorize
example below) need the table form:
When I send a PUT request to Endpoint known as "<ReadinessApi>" path "/_test/ready" with query "ready" "false"
Then the response status is 200
When I send a PUT request to Endpoint known as "<ReadinessApi>" path "/_test/ready" with:
| TYPE | KEY | VALUE |
| QUERY | ready | false |
Then the response status is 200
When I send a GET request to Endpoint known as "<AuthApi>" path "/oauth/authorize" with:
| TYPE | KEY | VALUE |
| QUERY | client_id | thomas-client |
| QUERY | username | phase3-user |
| QUERY | password | secret |
Then the response status is 302
FIELD¶
One field of a JSON body, assembled field-by-field.
Multiple FIELD rows also have the request
accumulator short form
shown above. A PATCH with only some fields is a genuine partial
update, unlike PUT's full replace — this real example only touches
title, and proves body survived untouched:
Given "<GroceryNoteTitlePatch>" has FIELD "title" "Groceries v3"
When I send "<GroceryNoteTitlePatch>" as PATCH to Endpoint known as "<NotesApi>" path "/notes/<NoteId>"
Then the response status is 200
Then the command result has "title" is "Groceries v3"
Then the command result has "body" is "Milk, eggs, bread"
When I send a PATCH request to Endpoint known as "<NotesApi>" path "/notes/<NoteId>" with:
| TYPE | KEY | VALUE |
| FIELD | title | Groceries v3 |
Then the response status is 200
Then the command result data has:
| KEY | CONDITION | VALUE |
| title | equals | Groceries v3 |
| body | equals | Milk, eggs, bread |
BASIC_AUTH¶
HTTP Basic auth, the curl -u equivalent.
KEY/VALUE are the real, plain username/password — base64-encoded
into a real Authorization: Basic ... header for you, the same way
curl's -u user:pass does. No hand-encoding (and no hardcoded base64
string sitting in a .feature file) needed. A single BASIC_AUTH row
also has a oneline with basic auth {string} {string} sibling:
When I send a GET request to Endpoint known as "<AuthApi>" path "/auth/basic/whoami" with basic auth "phase3-user" "secret"
Then the response status is 200
When I send a GET request to Endpoint known as "<AuthApi>" path "/auth/basic/whoami" with:
| TYPE | KEY | VALUE |
| BASIC_AUTH | phase3-user | secret |
Then the response status is 200
BODY¶
Escape hatch: the whole cell is a literal string body.
For a body too nested for flat FIELD rows, or a non-JSON content type:
When I send a POST request to Endpoint known as "<AuthApi>" path "/auth/token" with:
| TYPE | KEY | VALUE |
| HEADER | Content-Type | application/x-www-form-urlencoded |
| BODY | | username=phase3-user&password=secret |
FORM / FILE¶
A real multipart body.
FORM is a plain multipart text field; FILE's VALUE is a path to a
real fixture on disk, read and uploaded under its own basename:
When I send a POST request to Endpoint known as "<FilesApi>" path "/files/" with:
| TYPE | KEY | VALUE |
| FILE | file | features/fixtures/hello.txt |
Then the response status is 201
Why a FormData body never sets Content-Type explicitly
A FormData body (any FORM/FILE row) gets no explicit
Content-Type header — fetch() sets the correct
multipart/form-data; boundary=... value itself. Setting it
manually omits the boundary and breaks parsing server-side.
Asserting on the Response¶
Response Status¶
Then the response status is {int}
Asserts the HTTP status code. A : variant takes SOURCE|CONDITION|VALUE
rows against the raw body (SOURCE currently only BODY).
Then the response status is 201
Then the response status is 404:
| SOURCE | CONDITION | VALUE |
| BODY | contains | note not found |
Response Headers¶
Then the response headers has:
Then the response header {string} {condition} {word} # oneline sibling, one header at a time
KEY|CONDITION|VALUE against response headers — KEYs must be
lowercase (fetch()'s Headers normalizes to lowercase).
Then the response header "content-type" contains application/json
Then the response headers has:
| KEY | CONDITION | VALUE |
| content-type | contains | application/json |
Response Body (Byte-Exact)¶
Then the response body equals the real bytes of "<path>"
Byte-exact comparison against a real file on disk — never hardcode a download's expected content as a string when you can compare against the real fixture that produced it.
Then the response body equals the real bytes of "features/fixtures/hello.txt"
Response as Command Result Data¶
Then the command result data has:
Then the command result has {string} {condition} {word} # oneline sibling, one condition at a time
The response is also written into the shared command-result slot (status → exit code, body → stdout as UTF-8 text), so this generic, type-agnostic step (JMESPath against the JSON body) keeps working unchanged for assertions on the response JSON:
Then the command result has "title" is Groceries
Then the command result has "body" is "Milk, eggs"
Then the command result data has:
| KEY | CONDITION | VALUE |
| title | equals | Groceries |
| body | equals | Milk, eggs |
Don't mix up the two boolean conventions
Compare a JSON boolean's lowercase string form (true/false) in
these tables — the True/False convention is for OPTION|VALUE
CLI flags only, a different table with a different convention. This
exact mix-up has already caused one real, confusing test failure.
Capturing a dynamic value for later use¶
A server-generated value (a created resource's real id) is only known
after a response comes back. Several capture forms, all writing into
the same World.capturedValues store — this page covers the
response-derived ones; Given the value of environment variable
{string}, or {string}, or {string} is known as {string} and Given the
value at {string} from the command result is known as {string} (a real
k8s object field, e.g. a Service's ClusterIP) live in common.step.ts
alongside them. So does the file-content form below:
Given the value of environment variable {string} or {string} is known as {string}
A simpler sibling of the two-level-fallback form above, for the
single-real-env-var case — no second candidate variable, just one real
process.env read with a default (e.g. cucumber-js's own
CUCUMBER_WORKER_ID, set only under --parallel, "0" otherwise). Note
the missing comma before or — that's what keeps it textually
unambiguous against the two-level form, not just a shorter arg list.
Given the content of file at {string} is known as {string}
A real file's content, trimmed (like every other real "one line of
text" capture in this suite) — the third source alongside a command
result and an environment variable. Motivating case: a real
ssh-keygen-produced public key needs to become a Helm --set-string
value; a raw --set-file of the same path instead reads the file's own
trailing newline into the value verbatim, which breaks a chart that
interpolates it directly into a pod template's own YAML block scalar
(confirmed live: a real helm template YAML parse error) — trimming
here avoids that entirely.
Given the STDOUT of the last command is known as {string}
The raw, un-parsed sibling of the value at {string} from the command
result is known as {string} — that one needs real structured (JSON/YAML)
output to query with JMESPath; this one is for a command whose real
output is already exactly the one plain line a scenario needs (e.g. a
real ssh-keyscan's current host-key line — genuinely unpredictable
ahead of time, generated fresh by the fixture's own ssh-keygen -A at
container startup).
Capture a Value from the Response Body¶
Given the value at {string} from the last response is known as {string}
The first argument is a real JMESPath expression against the parsed JSON body — a flat key is the common case, but dotted paths and quoted keys with special characters work exactly as they do in assertion tables, with zero special-casing:
When I send a POST request to Endpoint known as "<NotesApi>" path "/notes/" with:
| TYPE | KEY | VALUE |
| FIELD | title | Groceries |
Given the value at "id" from the last response is known as "<NoteId>"
When I send a GET request to Endpoint known as "<NotesApi>" path "/notes/<NoteId>"
When I send a GET request to Endpoint known as "<AuthApi>" path "/.well-known/openid-configuration"
Then the response status is 200
Given the value at "response_types_supported[0]" from the last response is known as "<GrantType>"
Array indexing works the same way it does in assertion tables — see Array Projection.
Nested JSON and quoted keys work identically — no REST fixture response happens to nest one
A genuinely dotted path ("user.profile.id") or a key containing
.// needing quotes works exactly the same way here — this
project's REST fixture's JSON responses just don't happen to nest
that deeply today. The same query() function (real
JMESPath, not a custom parser) already
proves both against real nested Kubernetes/Helm YAML output in BDD
conventions: JMESPath
Queries — JSON is
valid YAML, so the exact same code path handles a response body with
zero special-casing.
Capture a Response Header¶
Given the value of response header {string} from the last response is known as {string}
Stores a response header's raw value:
When I send a GET request to Endpoint known as "<AuthApi>" path "/oauth/authorize" with:
| TYPE | KEY | VALUE |
| QUERY | client_id | thomas-client |
| QUERY | redirect_uri | https://client.example/callback |
| QUERY | response_type | code |
| QUERY | username | phase3-user |
| QUERY | password | secret |
Then the response status is 302
Given the value of response header "location" from the last response is known as "<RedirectLocation>"
Capture a Query Parameter from a Header¶
Given the query parameter {string} from response header {string} is known as {string}
Extracts one query parameter out of a header's URL value — used when the value you need is embedded in a redirect URL, not the header itself:
When I send a GET request to Endpoint known as "<AuthApi>" path "/oauth/authorize" with:
| TYPE | KEY | VALUE |
| QUERY | client_id | thomas-client |
| QUERY | username | phase3-user |
| QUERY | password | secret |
Then the response status is 302
Given the query parameter "code" from response header "location" is known as "<AuthCode>"
Comparing Two Captured Values¶
Then the value known as {string} {condition} {string}
The one place a captured value is compared rather than substituted into
something being built — kept deliberately separate from the response/
command SOURCE|CONDITION|VALUE assertion tables below, which compare
against a literal on purpose (see the warning box below). The right-hand
{string} goes through the same captured-value substitution every
construction step already uses, so it can itself be a composed template —
useful when the expected value is dynamic too (e.g. built from a
captured registry URL plus a real, content-derived git sha):
Given the value at "id" from the last response is known as "<NoteId>"
And the value at "title" from the last response is known as "<NoteTitle>"
Then the value known as "<NoteTitle>" equals "Groceries"
Given the value "Note <NoteId>" is known as "<NoteLabel>"
Then the value known as "<NoteLabel>" contains "<NoteId>"
Captured-value substitution does not run in assertion tables
This substitution only applies inside the HTTP request table (and the
right-hand side of the value known as ... {condition} ... above) —
it does not run inside KEY|CONDITION|VALUE assertion tables.
Writing | id | equals | <NoteId> | as an assertion compares
against the literal string "<NoteId>" and can never pass; use
the value known as {string} {condition} {string} instead if you
need to prove a fetched value matches something captured earlier.