BDD conventions¶
This is how every .feature file in Thomas is written. See
Resources for the four resource-only
construction types (Directory/File/URL/OCI Artifact), and
Helm, Kubernetes, and
REST for the per-tool step catalogs this
convention underlies.
Define, then act¶
Define an object with a Given step — construction and validation only,
never a mutating command — then use a separate When step for the
operation that actually runs something real:
Given Helm Repo "<BitnamiHelmRepo>" named "bitnami" at "https://charts.bitnami.com/bitnami"
When I add Helm Repo known as "<BitnamiHelmRepo>"
Then the command exited with 0
Resource Substitution¶
A table cell written as <SomeResource> (starts with <, ends with >)
is real substitution against a previously-registered resource — not a
Cucumber naming convention. Referencing one that was never defined
fails loudly (e.g. No Resource registered as "<Resource>" for the four
Resource types below), it never silently falls back to treating the
text as a literal value. Resource substitution works in table
PROPERTY/VALUE cells, request paths, and captured response values
alike.
Most types resolve to a plain string (a path, URL, ref). Two types
resolve to the real object instead, because a string would have
already lost structure the type needs later: HelmRelease.chart
resolves to the real Chart (its CLI args depend on the chart's source
kind), and RestEndpoint.service resolves to the real Service (its
base URL needs the object's real name/namespace).
Given Directory "<NginxChartDirectory>" at "./charts/test-nginx"
And Helm Chart "<NginxHelmChart>" in "<NginxChartDirectory>"
<NginxChartDirectory> resolves to the real registered Directory's
path before the Chart is constructed — a plain string, per the first
resolution kind above. See Resources for
the per-type construction reference (Directory/File/URL/OCI
Artifact).
Table shapes¶
Six different header vocabularies, each for a different purpose — don't mix them up:
Construction¶
Used by Given <Type> known as "<Alias>": — fields to construct an
object from. For every type with a small, fixed field set (every
resource-only type, Chart, Repository, Release, HTTP/HTTPS Endpoint), a
oneline step also exists and is what almost every real scenario uses
today — the table form is still the real base shape underneath:
Given Helm Release "<NginxRelease>" of "<NginxHelmChart>" named "thomas-nginx-release" in "thomas-helm-test"
Given Helm Release known as "<NginxRelease>":
| PROPERTY | VALUE |
| chart | <NginxHelmChart> |
| name | thomas-nginx-release |
| namespace | thomas-helm-test |
The table form stays the necessary one for any type with an
open-ended field set — Deployment/Service/Pod/ConfigMap/
ReplicaSet/Secret's label selectors — which deliberately has no
oneline form for the same reason it has no closed field list (see
Kubernetes: Discovery).
Negative-path tests (passing an unknown/missing field, which a fixed-arity oneline structurally can't express) no longer need a live table either — a real, open-ended Payload accumulator builds the same field set line by line instead, then hands it to the exact same real construction function the table form uses:
Given "<BadDirectoryPayload>" field "xxx" is "./charts"
When I attempt to define Directory known as "<BadDirectory>" using "<BadDirectoryPayload>"
Then it should have failed with 'Directory has no field "xxx"'
When I attempt to define Directory known as "<BadDirectory>":
| PROPERTY | VALUE |
| xxx | ./charts |
Then it should have failed with 'Directory has no field "xxx"'
Given {string} field {string} is {string} accumulates into a named
payload (same real "table is sugar over a plain-fields core" shape as
the Request Building accumulator below, just for
a 2-column PROPERTY|VALUE payload instead of a 3-column request row);
When I attempt to define <Type> known as {string} using {string}
(one real registration per type, in that type's own step file) reads
the accumulated fields and calls the exact same real xFromFields
function the table form already uses — not a second construction
implementation. One real type (Secret) has a genuine bounded retry
inside its real construction (a cert-manager async-issuance race) —
the payload form goes through that identical retry too, since it calls
the same real function, just fed differently.
Command Flags¶
Used by When I <verb> ... with: — CLI flags → argv. Blank
OPTION = positional arg. VALUE of True/False = a boolean
flag present with no value, or omitted entirely.
When I upgrade Helm Release known as "<NginxRelease>" with:
| OPTION | VALUE |
| --install | True |
| --atomic | True |
| --create-namespace | True |
Structured Data Assertions¶
Used by ... has: / the command result data has: — assertions
against parsed structured data (JMESPath KEY, see JMESPath
Queries below).
Then the command result data has:
| KEY | CONDITION | VALUE |
| replicaCount | equals | 1 |
Raw Output Assertions¶
Used by the command exited with {int}: — assertions against a
command's raw STDOUT/STDERR text.
Then the command exited with 0:
| SOURCE | CONDITION | VALUE |
| STDOUT | contains | Successfully packaged chart |
Polling Conditions¶
Used by When I poll ... until: — same condition-checking, plus a
polarity: OUTCOME is pass (must hold to succeed) or fail
(holding means stop and fail immediately).
When I poll Pod known as "<NginxPod>" every "3s" for up to "30s" until:
| KEY | CONDITION | VALUE | OUTCOME |
| status.phase | equals | Running | pass |
| status.containerStatuses[0].state.waiting.reason | equals | CrashLoopBackOff | fail |
Request Building¶
Used by When I send a {httpMethod} request to Endpoint ... with:
— builds a real HTTP/HTTPS request, see REST.
When I send a POST request to Endpoint known as "<NotesApi>" path "/notes/" with:
| TYPE | KEY | VALUE |
| FIELD | title | Groceries |
| FIELD | body | Milk, eggs |
A with header {string} {string}/with query {string} {string}/
with basic auth {string} {string} oneline covers the single-row
case. For 2+ rows, accumulate the request across several lines
instead of a table — same real predicate-accumulation shape as
progressive object discovery, just
for a request instead of a k8s selector:
Given "<NewNote>" has FIELD "title" "Groceries"
And "<NewNote>" has FIELD "body" "Milk, eggs"
When I send "<NewNote>" as POST to Endpoint known as "<NotesApi>" path "/notes/"
{word} is the row's real TYPE (HEADER/QUERY/FIELD/BODY/
FORM/FILE/BASIC_AUTH) — one generic registration, not one per
type. A BODY row's blank KEY table cell becomes an empty-string
"" argument here, not a special case.
Exit Code and Output¶
Then the command exited with {int}
Then the command exited with {int}:
Then the command succeeds # plain-English alias for "exited with 0" specifically
Type-agnostic — reads only World.lastCommandResult, so every domain
reuses it unchanged.
Then the command exited with 0:
| SOURCE | CONDITION | VALUE |
| STDOUT | contains | Successfully packaged chart |
Structured Result Data¶
Then the command result data has:
JMESPath assertions against parsed STDOUT — see JMESPath
Queries below.
Then the command result data has:
| KEY | CONDITION | VALUE |
| replicaCount | equals | 1 |
Its oneline sibling (the command result has {string} {condition}
{word}, Table shapes above) has two further siblings:
a {string} (quoted) value position for one containing a space —
Then the command result has "body" is "Milk, eggs" — and a
value-less form for exists/undefined specifically (the only two
conditions that ignore their value): Then the command result has
"username" exists. Using a value-less form with any other condition
fails loudly rather than silently comparing against "".
Failure Messages¶
Then it should have failed with {string}
Then it should have failed with either:
Both are type-agnostic, reading only World.lastError — set by any
I attempt to ... step. The either: form takes a single-column
| MESSAGE | table and passes if the real error includes any one
candidate — useful when a failure can genuinely land on more than one
real message across ticks:
Then it should have failed with either:
| MESSAGE |
| ErrImagePull |
| ImagePullBackOff |
(see Kubernetes: poll a Pod)
JMESPath Queries¶
Assertion tables resolve KEY with
JMESPath against the parsed data. Parsing
always goes through yaml.load on the raw text — JSON is valid YAML,
so the exact same code path handles both helm ... -o yaml output and
HTTP JSON response bodies with zero special-casing.
Flat Key Lookup¶
Then the command result data has:
| KEY | CONDITION | VALUE |
| apiVersion | equals | v2 |
Array Projection¶
Useful against list-shaped output like helm repo list -o yaml or
helm list -o yaml:
Then the command result data has:
| KEY | CONDITION | VALUE |
| [*].name | equals | bitnami |
Quoted Identifiers¶
metadata.labels."app.kubernetes.io/version" — the quotes are needed
because JMESPath's bare-identifier syntax doesn't allow . and /
inside a key name, and Kubernetes labels are full of both:
Then the command result data has:
| KEY | CONDITION | VALUE |
| metadata.labels."app.kubernetes.io/version" | equals | 1.27 |
Existential vs. Universal Matching¶
When KEY resolves to an array, a condition passes if any element
matches — except not_equals, which is universal (passes only if
no element matches, the natural reading of a negated condition):
# passes if "thomas-remove-example" is NOT in the list, at all
Then the command result data has:
| KEY | CONDITION | VALUE |
| [*].name | not_equals | thomas-remove-example |
Conditions¶
Equals¶
equals (aliases: =, ==, is) Exact match.
| KEY | CONDITION | VALUE |
| apiVersion | equals | v2 |
Contains¶
contains Substring/element match.
| KEY | CONDITION | VALUE |
| description | contains | nginx |
Case-Insensitive Contains¶
icontains Case-insensitive contains.
| KEY | CONDITION | VALUE |
| description | icontains | NGINX |
Undefined¶
undefined The field is genuinely absent — not null, not false, not "", actually missing from the parsed data.
| KEY | CONDITION | VALUE |
| password_hash | undefined | |
Exists¶
exists- The literal inverse of
undefined— the field is genuinely present.| KEY | CONDITION | VALUE | | username | exists | |
Not Equals¶
not_equals (aliases: !=, is not) Only really useful against an array — "this value isn't in the
list" (see existential vs. universal
matching above).
| KEY | CONDITION | VALUE |
| [*].name | not_equals | thomas-remove-example |
Never use not_equals with a blank VALUE to mean "this field has a
real value yet" (e.g. polling for a Pod's status.podIP to become
set) — a field that doesn't exist yet is undefined, and
String(undefined) is the literal text "undefined", which
"undefined" !== "" reports as already satisfied on the very first
tick, before the field is ever really set. Confirmed live: a poll
written this way passed immediately, and the value used moments later
turned out to be empty. Use exists instead — it checks
actual !== undefined directly, not a string comparison.
Greater Than¶
gt (alias: >) Numeric comparison — both sides parsed with Number(), so a
non-numeric value on either side fails loudly with a clear reason
rather than silently comparing NaNs.
| KEY | CONDITION | VALUE |
| spec.replicas | gt | 0 |
Greater Or Equal¶
gte (alias: >=) Same numeric comparison as Greater Than, inclusive.
| KEY | CONDITION | VALUE |
| status.readyReplicas | gte | 1 |
Less Than¶
lt (alias: <)Same numeric comparison as Greater Than, reversed.
| KEY | CONDITION | VALUE |
| status.readyReplicas | lt | 10 |
Less Or Equal¶
lte (alias: <=) Same numeric comparison as Greater Than, reversed
and inclusive.
| KEY | CONDITION | VALUE |
| status.readyReplicas | lte | 10 |
The symbol forms (> / >= / < / <= / = / == / !=) and the
plain-English is / is not are all real aliases, not a second
vocabulary — normalized to their word form before checking, so they
work anywhere a condition appears, table cells and the oneline forms
alike:
Then the command result has "status.readyReplicas" gte 1
Then Deployment "<X>" has "status.readyReplicas" >= 1
Then the command result has "info.status" is deployed
Object-flavored assertions¶
A Then step named after the object instead of "the command result" —
still only ever reads the same captured command output as the generic
forms above, populated by whatever real When ran immediately before
it:
When I get Deployment "<X>" as JSON
Then Deployment "<X>" has "status.readyReplicas" >= 1
When I get status of Helm Release "<X>" as YAML
Then Helm Release "<X>" is "deployed"
The real value beyond readability: both first check the alias is a genuinely registered object of that kind before comparing anything, catching a copy-paste mismatch a generic assertion wouldn't.
Progressive object discovery¶
Kubernetes objects (Deployment/Service/Pod/ConfigMap/
ReplicaSet/Secret) can also be discovered a line at a time instead
of via one PROPERTY|VALUE table — the same real, one-time kubectl
get underneath, just deferred until the object is actually used, since
with this style the full selector isn't known until the last line runs:
Given Deployment "<X>"
And "<X>" namespace is "dev"
And "<X>" label "app.kubernetes.io/instance" is "nginx-release"
Dynamic value capture¶
A server-generated value (a created resource's real id) is only known
after a response comes back:
When I send a POST request to Endpoint known as "<Api>" path "/notes/" with:
| TYPE | KEY | VALUE |
| FIELD | title | Hi |
Given the value at "id" from the last response is known as "<NoteId>"
When I send a GET request to Endpoint known as "<Api>" path "/notes/<NoteId>"
Given the value at "response_types_supported[0]" from the last response is known as "<GrantType>"
Indexing works the same as in Array Projection
above — the underlying query() function is the exact same real
JMESPath call in both cases, only the data source (a captured HTTP
response body vs. the command result data has:'s parsed
STDOUT) differs. A genuinely nested path (user.profile.id) or a
quoted identifier works identically too —
this project's REST fixture's JSON responses just don't happen to
nest that deeply today; the real proof against nested data is the
metadata.labels."..." example above, parsed by the same code path.
This is a deliberately separate, narrower mechanism from alias
resolution above — it resolves <...> embedded anywhere in a
string (a path, or a header value like Bearer <Token>), not just when
the whole cell is one alias reference. Side by side:
# Alias resolution - whole cell, resolved when the object is
# constructed, from a name registered by an earlier Given step.
And Helm Chart "<NginxHelmChart>" in "<NginxChartDirectory>"
# Captured-value substitution - embedded anywhere in a string, resolved
# when the request/path is built, from a value only known after a real
# response came back (or captured some other way - see below).
When I send a GET request to Endpoint known as "<NotesApi>" path "/notes/<NoteId>"
Two more ways to populate a captured value, beyond an HTTP response:
# A real process.env read, with a real ${VAR1:-${VAR2:-DEFAULT}} fallback
# - never a shell. The usual real consumer is a project's own
# namespace/naming convention (e.g. one derived from a CI/workspace
# owner env var).
Given the value of environment variable "CODER_WORKSPACE_OWNER_NAME", or "USER", or "local" is known as "<Owner>"
# Composes a new captured value from a literal that may itself embed
# other captured values - the general "value with substitution"
# primitive, applied directly instead of only to an HTTP request.
Given the value "devcontainer-builder-<Owner>-default" is known as "<Namespace>"
Construction tables and progressive-selector values get a soft
version of this substitution: a <...> cell that matches a captured
value is replaced; a <...> cell that doesn't (the common case — a
Directory/Service/HelmChart alias meant for the resolution above) is
left untouched for that normal resolution to handle. This is what lets
a captured value stand in for a namespace field:
Given Helm Release known as "<Release>":
| PROPERTY | VALUE |
| chart | <SomeChart> |
| name | my-release |
| namespace | <Namespace> | # <- the captured value from above
Given Deployment known as "<SomeDeployment>":
| PROPERTY | VALUE |
| namespace | <Namespace> | # <- same captured value
| app.kubernetes.io/instance | my-release |
Unlike the strict substitution used for HTTP requests/literal-value
composition above (which throws on an unresolved <...>), this soft
form never throws — an unmatched <...> cell is not an error here,
since it might still be a perfectly valid alias reference for
resolveResource/discover.ts to resolve next. See REST: capturing a
dynamic value
for more.
Keep scenarios self-contained¶
Every scenario that mutates real state (repo add/remove, Helm Release install/uninstall) redeclares its own preamble and cleans up after itself — no cross-scenario or cross-file shared state, and no assuming another scenario ran first.