Component testing

makeVoltroTestClient — the frontend test harness. Render a component against mocked useSubscription / useMutation, inject a loading state, a stream error or a failing write, and assert on every recorded call.

Why this exists

A Voltro frontend is a reactive-data app. Its components don't take props and render — they call useSubscription for live data and useMutation / useAction to write. Without a way to render such a component against mocked hooks — and to inject a loading state, a stream error, or a failing write — a component is structurally untestable: there is no seam to hand it data, and nothing to assert against. That is exactly why real apps end up with zero frontend tests.

makeVoltroTestClient closes that gap. It builds a fake api context, hands you a Provider to wrap the component under test, and gives you the controls to drive it.

pnpm --filter @my-app/web add -D @voltro/testing vitest jsdom react-dom

Import from the /client subpath, so backend-only test files never pull React:

import { makeVoltroTestClient } from '@voltro/testing/client'

The surface

const harness = makeVoltroTestClient({
  apiName:       'app',                                  // default 'app'
  subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] },
  mutations:     { 'todos.create': (input) => ({ id: '2' }) },
  actions:       { 'todos.export': async () => ({ url: '/x.csv' }) },
})
Config Meaning
apiName The api name your components pass to the hooks. Default 'app'. Asking for a different one throws a loud, named error rather than rendering nothing.
subscriptions Initial data per rpc tag. A tag that is absent stays in the loading state.
mutations Handlers per tag. Throw (or reject) to exercise the failure path.
actions Handlers per tag, same shape.
Returned Use
Provider Wrap the component under test. Takes children, returns an element.
setSubscription(tag, data) Push new data for a tag — components re-render, exactly like a server delta.
failSubscription(tag, error) Put a tag into the cold-start error state (no snapshot + an error).
resetSubscription(tag) Return a tag to the loading state (no snapshot yet).
calls Every mutation / action invoked so far, in order — { kind, tag, input }.

Absent tag = loading

This is the distinction most worth testing and the easiest to get wrong. A tag with no fixture renders the component's loading branch; a tag whose fixture is [] renders the empty branch. Real apps routinely conflate the two and ship a skeleton that never resolves. The harness makes both states one line apart.

It is deliberately not a renderer

The harness hands you a Provider and nothing else about rendering. It works with react-dom/client + act, with testing-library, or with your own harness, and locks you into none of them. This repo's own tests use react-dom/client directly — there is no @testing-library dependency anywhere in the framework.

A worked example

Two things React needs before any of this runs: a DOM environment (the // @vitest-environment jsdom docblock, per file) and IS_REACT_ACT_ENVIRONMENT = true, without which React refuses to honour act() outside a runner it recognises.

// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { act, createElement, type ReactElement } from 'react'
import { createRoot } from 'react-dom/client'
import { useSubscription, useMutation } from '@voltro/client'
import { makeVoltroTestClient } from '@voltro/testing/client'

// React requires this flag before it will honour act().
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true

// The component under test — ordinary app code, no test seams of its own.
const TodoList = (): ReactElement => {
  const { data, loading, isEmpty } = useSubscription<Array<{ id: string; title: string }>>('app', 'todos.list', {})
  const create = useMutation<{ title: string }, { id: string }>('app', 'todos.create')
  if (loading) return <p>loading</p>
  if (isEmpty) return <p>empty</p>
  return (
    <div>
      <ul>{(data ?? []).map((t) => <li key={t.id}>{t.title}</li>)}</ul>
      <button onClick={() => { void create.mutate({ title: 'new' }, {}) }}>add</button>
    </div>
  )
}

const mount = (harness: ReturnType<typeof makeVoltroTestClient>) => {
  const host = document.createElement('div')
  const root = createRoot(host)
  act(() => { root.render(createElement(harness.Provider, null, createElement(TodoList))) })
  return {
    html: () => host.innerHTML,
    find: <E extends Element>(selector: string): E | null => host.querySelector<E>(selector),
    unmount: () => act(() => { root.unmount() }),
  }
}

describe('TodoList', () => {
  it('renders the loading state when the tag has no data yet', () => {
    const h = makeVoltroTestClient()                       // no fixture for todos.list
    const view = mount(h)
    expect(view.html()).toContain('loading')
    view.unmount()
  })

  it('distinguishes EMPTY from loading', () => {
    const h = makeVoltroTestClient({ subscriptions: { 'todos.list': [] } })
    const view = mount(h)
    expect(view.html()).toContain('empty')
    expect(view.html()).not.toContain('loading')
    view.unmount()
  })

  it('re-renders when a server delta arrives', () => {
    const h = makeVoltroTestClient({ subscriptions: { 'todos.list': [{ id: '1', title: 'first' }] } })
    const view = mount(h)
    expect(view.html()).toContain('first')

    act(() => { h.setSubscription('todos.list', [{ id: '1', title: 'first' }, { id: '2', title: 'second' }]) })
    expect(view.html()).toContain('second')
    view.unmount()
  })

  it('handles a dead stream', () => {
    const h = makeVoltroTestClient({ subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] } })
    const view = mount(h)
    act(() => { h.failSubscription('todos.list', new Error('stream died')) })
    expect(view.html()).toContain('loading')   // no snapshot → the loading branch
    view.unmount()
  })

  it('records the write with its input', async () => {
    const h = makeVoltroTestClient({
      subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] },
      mutations:     { 'todos.create': () => ({ id: '2' }) },
    })
    const view = mount(h)
    await act(async () => { view.find<HTMLButtonElement>('button')?.click() })
    expect(h.calls).toEqual([{ kind: 'mutation', tag: 'todos.create', input: { title: 'new' } }])
    view.unmount()
  })
})

Testing the failure paths

The three failure modes a reactive frontend actually ships broken:

  • The infinite skeleton. failSubscription(tag, error) drops the snapshot and sets an error — the cold-start case where data never arrived at all. If your component only branches on data, this is the test that catches it.
  • The write that rejects. A mutations handler that throws surfaces through the component's own error handling. Pass an onError to mutate and the promise resolves instead of rejecting — assert both that the handler saw the error and that the component still rendered.
  • The wrong api. A component asking for an api the harness wasn't built for throws a named error telling you to pass { apiName }, instead of silently rendering an empty tree.
const seen: unknown[] = []
const h = makeVoltroTestClient({
  subscriptions: { 'todos.list': [{ id: '1', title: 'x' }] },
  mutations:     { 'todos.create': () => { throw new Error('nope') } },
})
// … click the button, with onError: (e) => { seen.push(e) } …
expect(h.calls).toHaveLength(1)      // the write was attempted
expect(seen).toHaveLength(1)         // handled, not an unhandled rejection
expect(view.html()).toContain('x')   // and the component survived

What NOT to do

  • Don't mock @voltro/client yourself. Module-mocking the hooks gives you a component that renders but proves nothing about subscription lifecycle — loading, delta, error. The harness drives the real hooks against a fake cache.
  • Don't reach for e2e to assert a render branch. Booting api + web to check that an empty list shows "empty" is minutes of runtime for something a makeVoltroTestClient test proves in milliseconds. Keep e2e for the flows that genuinely cross the wire.
  • Don't forget the docblock. Without // @vitest-environment jsdom on the file, document doesn't exist and the failure message points at React, not at the missing environment.