Skip to main content
On this pageStory and Scene

Testing

Story and Scene

Foldkit tests at two boundaries. Story calls update directly. Scene enters through the rendered view. Neither test runs a browser or executes the Effects inside Commands, so both stay deterministic and fast.

StoryScene
Enters throughA MessageAn interaction or lifecycle result
ObservesModel changes, Commands, and OutMessagesRendered output, Commands, Mounts, and OutMessages
Best suited toUpdate logic, edge cases, and Command wiringUser flows, view behavior, and accessibility

Use both. Story proves the state machine behaves correctly. Scene proves that a person can reach that behavior through the view.

Name each file for the boundary it tests:

  • story.test.ts drives update.

  • scene.test.ts drives the rendered view.

  • When one folder has several tests of the same kind, prefix the subject: login.story.test.ts.

  • Keep root-level Scene tests for flows that cross pages. Colocate page and Submodel tests with the code they exercise.

The names stay accurate whether update and view live together or in separate files. See Project Organization for the full layout.

Story

story starts from a Model, sends Messages through update, and keeps Commands as data until the test supplies their result Messages. See the Story page for the full API.

Story can test a root update or a child update in isolation. The update function is the contract at either level.

import { Command, given, message, model, story } from 'foldkit/story'
import { expect, test } from 'vitest'

test('delayed reset: count resets after the delay fires', () => {
  story(
    update,
    given({ count: 5 }),
    message(ClickedResetAfterDelay()),
    Command.expectExact(DelayReset),
    Command.resolve(DelayReset, CompletedDelayReset()),
    model(model => {
      expect(model.count).toBe(0)
    }),
  )
})

Scene

scene renders the view after every step. Locators find elements by role, label, placeholder, and visible text. Interactions invoke the view's event handlers, while cause-named steps supply Subscription, ManagedResource, and CustomElement results. Scene also tracks pending Commands and Mounts. See the Scene page for the full API.

Scene can also start at the root or at a child Submodel. withViewInputs adapts a Submodel view that needs ViewInputs, and expectOutMessage checks a child's OutMessage directly.

Choose the level by ownership. Test a Submodel's rendering, interactions, Commands, and OutMessages at the Submodel. Test parent folding, lifted Commands, route changes, and parent-computed ViewInputs at the root. Those behaviors cross the boundary and cannot be observed from the child.

import {
  Command,
  click,
  expect,
  given,
  inside,
  label,
  role,
  scene,
  text,
  type,
} from 'foldkit/scene'
import { test } from 'vitest'

test('type a zip code, click get weather, see the forecast', () => {
  scene(
    { update, view },
    given(model),

    type(label('Zip code'), '90210'),
    click(role('button', { name: 'Get Weather' })),
    expect(role('button', { name: 'Loading...' })).toExist(),

    // Instance form: locks in the zipCode the runtime captured.
    Command.expectExact(FetchWeather({ zipCode: '90210' })),
    Command.resolve(
      FetchWeather,
      SucceededFetchWeather({ weather: beverlyHillsWeather }),
    ),
    inside(
      role('article'),
      expect(text('Beverly Hills, California')).toExist(),
      expect(text('72\u00B0F')).toExist(),
      expect(text('Clear sky')).toExist(),
    ),
  )
})

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson