Write a Test
The rule “an order is approved only while it is pending” should stay true as the shop grows. A test is a scenario that sends the application the HTTP requests a client would send and checks the responses.
The orders are those of store.orders: Create a Component writes the
component and its approve, and Expose It over HTTP the routes this scenario calls.
# features/orders.feature
Feature: Orders
Scenario: Approving an order
When the request is sent:
"""
POST /store/orders/ HTTP/1.1
content-type: application/yaml
accept: application/yaml
customer: c1
items:
- product: kettle
quantity: 1
total: 4200
"""
Then the response is received:
"""
201 Created
id: ${{ id }}
status: pending
"""
When the request is sent:
"""
PUT /store/orders/${{ id }}/ HTTP/1.1
accept: application/yaml
"""
Then the response is received:
"""
200 OK
status: approved
"""
When the request is sent:
"""
PUT /store/orders/${{ id }}/ HTTP/1.1
accept: application/yaml
"""
Then the response is received:
"""
422 Unprocessable Entity
code: NOT_PENDING
""" A request is written as HTTP text. An expected response lists the lines the response must
contain. ${{ id }} in an expected response captures the value found there; afterwards ${{ id }} stands for that value.
The steps the scenario is written with are in features/steps, where toa create wrote them.
§The steps that send a request
// features/steps/http.ts
import { Before, When, Then } from '@cucumber/cucumber'
import { Agent } from '@toa.io/agent'
// what the steps of one scenario share
export interface World {
agent: Agent
}
Before(function (this: World) {
this.agent = new Agent('http://localhost:8000')
})
When('the request is sent:', async function (this: World, text: string) {
await this.agent.request(text)
})
Then('the response is received:', function (this: World, text: string) {
this.agent.responseIncludes(text)
}) @toa.io/agent is an HTTP client that takes a request as text and remembers the response. Each
scenario gets an agent of its own, so values captured in one scenario are not seen by the next.
§What a scenario runs against
// features/steps/application.ts
import { readdir, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { BeforeAll, AfterAll } from '@cucumber/cucumber'
import * as stage from '@toa.io/userland/stage'
const root = resolve(import.meta.dirname, '../..')
const components = resolve(root, 'components')
BeforeAll({ timeout: 60_000 }, async function () {
const map = await readFile(resolve(root, '.map.json'), 'utf8')
const entries = await readdir(components)
const paths = entries.map((entry) => resolve(components, entry))
stage.map(JSON.parse(map))
// the components run in this process; what serves them is started with `npm run sys`
await stage.compose(paths)
})
AfterAll(async function () {
await stage.shutdown()
}) The stage, from @toa.io/userland, starts the components inside the test process, from the
sources as they are. So a test never runs stale code, and needs no restart after an edit.
It starts the components only. The gateway the requests go to is part of sys, which has to be
running:
$ npm run sys sys is one of the two processes npm start keeps under PM2. The
other, app, runs the same components as the test does. Stop it while you work on tests, or a
request may be answered by its older code:
$ pm2 stop app §Run
$ npm run env
$ npm run features 2 hooks (2 passed)
3 scenarios (3 passed)
15 steps (15 passed) The two other scenarios came with the application. npm run env writes .env, .map.json and
the types again; run it whenever the Context or a component changed. The Before hook is counted
among the steps, and the log of the components is printed along with the results.
A response that does not match fails the step, naming the first line that was not found and showing the response:
AssertionError [ERR_ASSERTION]: Response is missing 'status: approved'
-201 Created
-content-type: application/yaml
-
-id: 01a10df2fbdf71dca772045890985fe7
-status: pending
… §Call an operation in a test
Not everything is reachable over HTTP: an operation may not be exposed, or a scenario may need a
state that takes many requests to reach. stage.remote connects to a component, and invoke calls an operation of it the way toa call does:
// features/steps/operations.ts
import assert from 'node:assert/strict'
import { When, Then } from '@cucumber/cucumber'
import { remote } from '@toa.io/userland/stage'
import { load } from 'js-yaml'
import type { CodedError, Request } from '@toa.io/core/types'
import type { World } from './http.ts'
When('`{word}` is called with:', async function (this: World, endpoint: string, yaml: string) {
const [namespace, name, operation] = endpoint.split('.')
const component = await remote(`${namespace}.${name}`)
const request = load(this.agent.captures.substitute(yaml)) as Request
this.reply = await component.invoke(operation, request)
})
Then('the reply is:', function (this: World, yaml: string) {
assert.partialDeepStrictEqual(this.reply, load(yaml))
})
Then('the reply is the error `{word}`', function (this: World, code: string) {
assert.ok(this.reply instanceof Error)
assert.equal((this.reply as CodedError).code, code)
}) The steps keep the reply on the scenario’s World, which gets one more property:
// features/steps/http.ts
export interface World {
agent: Agent
reply?: unknown
} js-yaml is one more development dependency:
$ npm install -D js-yaml @types/js-yaml captures.substitute puts captured values into the
request, so both kinds of steps work in one scenario:
Scenario: Approving an order twice
When the request is sent:
"""
POST /store/orders/ HTTP/1.1
content-type: application/yaml
accept: application/yaml
customer: c1
items: []
total: 4200
"""
Then the response is received:
"""
201 Created
id: ${{ id }}
"""
When `store.orders.approve` is called with:
"""
query:
id: ${{ id }}
"""
Then the reply is:
"""
status: approved
"""
When `store.orders.approve` is called with:
"""
query:
id: ${{ id }}
"""
Then the reply is the error `NOT_PENDING` invoke resolves to the operation’s output, or to an Error with a code when the operation
returned an error. An exception — a request that does not fit, for one — rejects, and fails the
step.
Something that happens after a reply, such as the work of a receiver, may not be done when the next step runs. Repeat the request until the expected response arrives or a deadline passes; do not sleep for a fixed time.
§The stage
import * as stage from '@toa.io/userland/stage' | Function | Does | Resolves to |
|---|---|---|
compose(paths, options?), composition(paths, options?) | Starts the components at paths as one composition. | nothing |
component(path, options?) | Starts one component on its own. | the component |
remote(id) | Connects to a running component by <namespace>.<name>; a bare name is taken as default.<name>. | the component, as a caller sees it |
serve(reference) | Starts an extension’s service: exposition, configuration, introspection, or a package name. | the service |
workload(paths, options?, services?) | Starts a composition with the listed services as one process, as toa compose --service does. | the workload |
map(contracts) | States the component map for what is started next. Not a promise. | nothing |
manifest(path) | Reads a component’s manifest as the runtime understands it. | the manifest |
halt(), restore() | Makes everything composed go quiet, and work again; see halt. | nothing |
shutdown() | Disconnects everything the stage started and forgets the map. | nothing |
- A component and a remote both have
invoke(operation, request). optionsmay holdbindings,storage— a storage for every entity instead of the declared one — andextensions— extensions added to every component.- Variables set in
process.envbefore the first stage call are used, and win over.env. - A test process that does not call
shutdown()keeps its connections and does not exit.
§The agent
import { Agent, Captures } from '@toa.io/agent'
const agent = new Agent('http://localhost:8000') A request is the request line, headers, an empty line and the body. host is taken from the
agent’s origin and content-length is computed, unless the text states them.
Captures. In a request, ${{ name }} is replaced by the captured value. In an expected
response, a ${{ name }} not captured yet matches a value without spaces or quotes and captures
it; one already captured must be equal. Write exactly one space inside the braces. A name is up to
32 characters and may contain dots: ${{ order.id }}.
Pipelines. #{{ … }} in a request is replaced by the result of functions chained with |:
POST /accounts/ HTTP/1.1
content-type: application/yaml
email: #{{ email @shop.example | set customer.email }} | Function | Result |
|---|---|
id [length] | A new identifier: 32 hexadecimal characters, or the first length. |
email [@domain] | A random address, at @agent.test by default. |
password [length] | A random password, 16 characters by default. |
basic [name] | Basic credentials from the captures name.username and name.password, random where absent. |
now [shift] | The current time in milliseconds, shifted: now -1d, now +1h, now 500. Units: ms, s, m, h, d. |
utc | The piped milliseconds, or the current time, as an HTTP date. |
unix | The piped date as seconds. |
get name | The captured value. |
set name | Captures the piped value and passes it on. |
print | Prints the piped value and passes it on. |
Functions of your own are passed to the captures: new Agent(origin, new Captures({ name: fn })),
where fn(value, ...args) receives the piped value and returns a string.
Matching. The response is compared as text: the status line (201 Created), the headers in
lower case, an empty line, the body.
| Method | Passes when |
|---|---|
responseIncludes(text) | Every non-empty line of text is found in the response, in any order. |
responseIncludesInOrder(text) | The same, and each line is found after the previous one. |
responseExcludes(text) | No line of text is found. |
A line is found where the response has it at the start of a line or after a space, ignoring case.
Nothing is checked after its end, so total: 42 is found in total: 4200, and properties you do
not list are not compared. Indentation relative to the first line counts.
| Member | |
|---|---|
request(text) | Sends the request and keeps the response. |
response, bytes | The last response as text, and its body as a Buffer. |
captures | The captured values, a Map, with substitute(text). |
stream(head, readable) | Sends a request whose body is a stream, to upload a file. |
parts(text) | Sends the request and resolves to the parts of a multipart response as they arrive; abort() closes what is open. |
Set TOA_AGENT_PROTOCOL=h2c when the gateway serves HTTP/2.