Testing processes

  1. Level 1: pure functions
  2. Level 2: the generator, driven directly
  3. Level 3: the spawned process
  4. Views are data
  5. Summary

Because process inputs and outputs are explicit, most behavior can be tested without a UI. This guide presents three levels of testing, from pure functions to the full runtime, with examples from the repository.

Level 1: pure functions

Keep independent calculations in pure functions. Physics steps, filters, and formatters can then use direct input/output tests.

expect(step(1, { x: 1, y: 0 }, initialMario)).toEqual({ ... })
expect(visible({ todos, filter: 'active' })).toHaveLength(1)

Seen in: examples/mario/mario.golden.test.ts (a whole jump arc), examples/todomvc/todos.test.ts (the pure helpers).

A process written with the optional reducer sugar is a pure function at this level already: call it with a state and a message, and pass a spy as the reply of a Call (examples/shared-cart/shared.test.ts). The Proc it compiles to is driven like any other at the next level.

Level 2: the generator, driven directly

Self is an interface implemented by channel(), allowing tests to drive the async generator directly. This level does not require spawn, the runtime, timers, or a DOM:

const self = channel<Msg>()
self.cast({ type: 'add', title: 'milk' })   // script the mailbox up front
self.cast({ type: 'toggle', id: 1 })

const it = todosProc(self, undefined)
const next = async () => {
  const r = await it.next()
  if (r.done) throw new Error('the process ended')
  return r.value
}
expect((await next()).todos).toEqual([{ id: 1, title: 'milk', done: false }])
expect((await next()).todos[0]?.done).toBe(true)

Each message produces a yield, so the test reads as a transcript. A small transcript(proc, msgs) helper can collect these yields for table-based assertions. See examples/todomvc/todos.test.ts.

Two habits make this level go further:

Level 3: the spawned process

Spawn when the test is about runtime semantics: lifecycle, ownership, sharing, granularity.

Views are data

A view function returns a VNode tree made from plain objects: { tag, attrs, children }. Tests can call the view, inspect the tree, and invoke handlers without creating a DOM. A child is typed as Slot, since a slot may also hold a string, a thunk, or a process, so a test narrows the one it expects:

import { cell } from '@nonchalant/core'
import type { Process, VNode } from '@nonchalant/core'
import { input, label } from '@nonchalant/dom/tags'
import { expect } from 'vitest'

function Toggle(done: Process<boolean, boolean>): VNode {
  return label({},
    input({ type: 'checkbox', checked: done, onchange: () => done.cast(!done()) }),
    'done')
}

const done = cell(false)
const box = Toggle(done).children[0] as VNode       // plain data; this slot is a VNode
expect(box.tag).toBe('input')

const onchange = box.attrs['onchange'] as () => void
onchange()                                          // handlers are closures
await new Promise((r) => setTimeout(r, 0))          // the cast reaches the mailbox
expect(done()).toBe(true)                           // the process moved

The handler is a closure over cast, so the assertion is about the state process, not the tree: one macrotask turn lets the mailbox deliver before the process publishes its next value.

When you do want real nodes, happy-dom runs the sink fast, and the DOM-write spies in packages/dom/test/dom.test.ts show how to assert granularity budgets against it.

Summary

advantage why it exists
the mailbox is injectable Self is an interface; channel() implements it
tests are transcripts one yield per message is an assertable sequence
no fake timers time arrives as messages you cast
loading/error states are reads pending / stale / error are the process face
request/response is await call() returns a promise
views test without a DOM trees are plain data; handlers are closures
granularity is countable wakes and DOM writes are exact, so assert exact numbers
partitions are a method call the in-memory transport can split and heal
contracts can be quantified states, patches, and messages are plain data, so fast-check can generate them

This page's source on GitHub