I First Steps

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'
FunctionDoesResolves 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).
  • options may hold bindings, storage — a storage for every entity instead of the declared one — and extensions — extensions added to every component.
  • Variables set in process.env before 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 }}
FunctionResult
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.
utcThe piped milliseconds, or the current time, as an HTTP date.
unixThe piped date as seconds.
get nameThe captured value.
set nameCaptures the piped value and passes it on.
printPrints 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.

MethodPasses 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, bytesThe last response as text, and its body as a Buffer.
capturesThe 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.

Search
Search the documentation