Skip to main content
On this pageTesting Through the View

Scene

Testing Through the View

Scene tests features through the rendered view. Where Story sends Messages directly to update, Scene clicks buttons, types into inputs, presses keys, and asserts on the rendered VNode tree. The view function runs on every step, so if it crashes or renders the wrong thing, the test catches it.

Scene operates on the VNode tree directly. No DOM, no JSDOM, no browser. Tests are pure, deterministic, and fast.

Locators

Locators find elements the way users find them: by role, by label, by visible text. Each factory returns a Locator that resolves to a single match; interactions and assertions accept either a Locator or a raw CSS selector string.

import { Scene } from 'foldkit'

Scene.role('button', { name: 'Submit' })
Scene.label('Email')
Scene.text('Welcome back')
Scene.placeholder('Search...')
Scene.altText('Company logo')
Scene.title('Close dialog')
Scene.testId('cart-summary')
Scene.displayValue('alice@example.com')
Scene.selector('.fallback-class')
LocatorFindsExample
Scene.role(role, options?)Elements by ARIA role (explicit or implicit). Options narrow by accessible name and ARIA state.Scene.role('button', { name: 'Save' })
Scene.label(text)Form controls by their aria-label or associated <label> text.Scene.label('Email')
Scene.placeholder(text)Inputs by their placeholder attribute.Scene.placeholder('Search...')
Scene.text(text)Elements by visible text content.Scene.text('Welcome back')
Scene.altText(text)Images and similar elements by their alt attribute.Scene.altText('Profile photo')
Scene.title(text)Elements by their title attribute (tooltip text).Scene.title('Delete')
Scene.testId(id)Elements by data-testid — the escape hatch for tests.Scene.testId('cart-item-3')
Scene.displayValue(value)Form controls by their current value.Scene.displayValue('US')
Scene.selector(css)Elements by CSS selector. Use when no accessible query fits.Scene.selector('.chart-legend')

Scene.role

Scene.role is the most common locator. It accepts a second argument of state options that narrow the match. All options are optional:

import { Scene } from 'foldkit'

// Match by role alone
Scene.role('button')

// Narrow by accessible name (exact match)
Scene.role('button', { name: 'Save' })

// Narrow by accessible name (regex match)
Scene.role('option', { name: /PM/ })

// Narrow by heading level
Scene.role('heading', { level: 2 })

// Narrow by ARIA state
Scene.role('checkbox', { checked: true })
Scene.role('button', { pressed: true, disabled: false })
OptionTypeMatches
namestring | RegExpAccessible name (aria-label, aria-labelledby, label[for], or text content). Strings match exactly; regular expressions match against the full name.
levelnumberHeading level (for role: "heading")
checkedboolean | 'mixed'aria-checked or the checked attribute
selectedbooleanaria-selected
pressedboolean | 'mixed'aria-pressed
expandedbooleanaria-expanded
disabledbooleanaria-disabled or the disabled attribute

Scoping

Scene.within(parent, child) scopes a single locator to a parent element. Scene.inside(parent, ...steps) scopes a whole block of steps — every assertion or interaction inside the block resolves within the parent’s subtree. Use within for one-off scoped queries; use inside when several steps share the same scope. Nested inside calls compose.

import { Scene } from 'foldkit'

// Scope a single locator to a parent element.
Scene.within(Scene.role('region', { name: 'Sidebar' }), Scene.role('link'))

// Scope a block of steps — every assertion and interaction
// resolves within the parent's subtree.
Scene.inside(
  Scene.role('dialog', { name: 'Confirm' }),
  Scene.expect(Scene.role('heading')).toHaveText('Delete item?'),
  Scene.click(Scene.role('button', { name: 'Cancel' })),
)

Multi-Match

For lists and repeated elements, the Scene.all.* factories (Scene.all.role, Scene.all.text, Scene.all.label, and so on — one per single-match factory) return a LocatorAll that resolves to every match. Pick one with Scene.first, Scene.last, or Scene.nth(index), or narrow with Scene.filter:

import { pipe } from 'effect'
import { Scene } from 'foldkit'

// Multi-match locators return every match.
Scene.all.role('row')
Scene.all.text('Delete')
Scene.all.label('Email')

// Pick one element from the set.
Scene.first(Scene.all.role('row'))
Scene.last(Scene.all.role('button', { name: 'Delete' }))
Scene.nth(Scene.all.role('row'), 2)

// Narrow with filter, then pick.
pipe(Scene.all.role('row'), Scene.filter({ hasText: 'Alice' }), Scene.first)

pipe(
  Scene.all.role('row'),
  Scene.filter({ has: Scene.role('button', { name: 'Delete' }) }),
  Scene.first,
)
Filter optionKeeps matches where
hasThe element contains a descendant matching the given Locator
hasNotThe element does not contain a descendant matching the Locator
hasTextThe element’s text content includes the given substring
hasNotTextThe element’s text content does not include the substring

Interactions

Interactions exercise the view by invoking event handlers on matched elements. Each one captures the dispatched Message, feeds it through update, and re-renders. They accept either a Locator or a CSS selector string.

import { Scene } from 'foldkit'

Scene.click(Scene.role('button', { name: 'Log out' }))
Scene.doubleClick(Scene.role('button', { name: 'Expand' }))
Scene.pointerDown(Scene.role('button', { name: 'Toggle' }))
Scene.pointerUp(Scene.role('button', { name: 'Toggle' }))
Scene.hover(Scene.role('menuitem', { name: 'File' }))
Scene.focus(Scene.label('Email'))
Scene.blur(Scene.label('Email'))
Scene.type(Scene.label('Email'), 'alice@example.com')
Scene.change(Scene.label('Country'), 'US')
Scene.submit(Scene.role('form'))
Scene.keydown(Scene.label('Search'), 'Enter')
StepInvokes
Scene.click(target)OnClick (bubbles to ancestors)
Scene.doubleClick(target)OnDoubleClick (bubbles to ancestors)
Scene.pointerDown(target, options?)OnPointerDown with optional { pointerType, button, screenX, screenY } (bubbles to ancestors)
Scene.pointerUp(target, options?)OnPointerUp with optional { pointerType, screenX, screenY } (bubbles to ancestors)
Scene.hover(target)OnMouseEnter (falls back to OnMouseOver)
Scene.focus(target)OnFocus
Scene.blur(target)OnBlur
Scene.type(target, text)OnInput with the given text
Scene.change(target, value)OnChange with the given value — for <select> and similar
Scene.keydown(target, key, modifiers?)OnKeyDown or OnKeyDownPreventDefault with optional { shiftKey, ctrlKey, altKey, metaKey }
Scene.submit(target)OnSubmit

Scene.tap(fn) runs a function for side effects (like ad-hoc assertions on raw VNodes or accumulated Commands) without breaking the step chain.

Assertions

Scene.expect(locator) creates an inline assertion step against a single element. Every matcher has a .not variant that inverts the assertion.

import { Scene } from 'foldkit'

// Single-element assertions
Scene.expect(Scene.role('heading')).toExist()
Scene.expect(Scene.role('heading')).toHaveText('Welcome')
Scene.expect(Scene.role('heading')).toHaveText(/^Welcome/)
Scene.expect(Scene.role('heading')).toContainText('Welcome')
Scene.expect(Scene.role('dialog')).toBeAbsent()
Scene.expect(Scene.role('status')).toBeVisible()
Scene.expect(Scene.role('status')).toBeEmpty()
Scene.expect(Scene.role('region')).toHaveAccessibleName('User session')
Scene.expect(Scene.label('Email')).toHaveValue('alice@example.com')
Scene.expect(Scene.role('button', { name: 'Submit' })).toBeDisabled()
Scene.expect(Scene.role('button')).not.toBeDisabled()

// Multi-match assertions — count-based
Scene.expectAll(Scene.all.role('row')).toHaveCount(3)
Scene.expectAll(Scene.all.role('alert')).toBeEmpty()
MatcherAsserts that the element
.toExist()Is present in the tree
.toBeAbsent()Is not present in the tree
.toBeVisible()Is not hidden via the hidden attribute, aria-hidden, display: none, or visibility: hidden
.toBeEmpty()Has no text content or child elements
.toHaveText(value)Has text content equal to the given string or matching the given regex
.toContainText(value)Has text content including the given substring or matching the regex
.toHaveAccessibleName(name)Has the given accessible name (resolves aria-labelledby, aria-label, label[for], text content)
.toHaveAccessibleDescription(description)Has the given accessible description (resolves aria-describedby)
.toBeDisabled()Has aria-disabled or the disabled attribute
.toBeEnabled()Is not disabled
.toBeChecked()Has aria-checked="true" or the checked attribute
.toHaveValue(value)Has the given current form-control value
.toHaveAttr(name, value)Has the given attribute set to the given value
.toHaveId(id)Has the given id
.toHaveClass(name)Has the given CSS class
.toHaveStyle(name, value)Has the given inline style property

For LocatorAll (from Scene.all.*), use Scene.expectAll(locatorAll) for count-based assertions:

MatcherAsserts that
.toHaveCount(n)The locator matches exactly n elements
.toBeEmpty()The locator matches zero elements

Command Assertions

Scene can also assert on pending Commands. Use these steps to verify that an interaction produced the Commands you expect before resolving them:

StepAsserts that
expectExactCommands(A, B)The pending Commands are exactly A and B (order-independent)
expectHasCommands(A)A is among the pending Commands (subset check)
expectNoCommands()There are no pending Commands

Prefer expectExactCommands as the default. It catches bugs where an interaction produces unexpected Commands. Use expectHasCommands when you only care about a subset of the pending Commands.

import { Scene } from 'foldkit'

// Single Command
Scene.click(Scene.role('button', { name: 'Get Weather' }))
Scene.expectExactCommands(FetchWeather)
Scene.resolve(FetchWeather, SucceededFetchWeather({ weather }))

// Multiple Commands
Scene.click(Scene.role('button', { name: 'Sign In' }))
Scene.expectExactCommands(RequestAuthentication, TrackSignInAttempt)
Scene.resolveAll(
  [RequestAuthentication, SucceededRequestAuthentication({ session })],
  [TrackSignInAttempt, CompletedTrackSignInAttempt()],
)

A Complete Scene

Here’s a Scene test for a weather app. The user types a zip code, clicks Get Weather, sees a loading state, and then the forecast appears:

import { Scene } from 'foldkit'
import { test } from 'vitest'

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

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

    Scene.expectExactCommands(FetchWeather),
    Scene.resolve(
      FetchWeather,
      SucceededFetchWeather({ weather: beverlyHillsWeather }),
    ),
    Scene.inside(
      Scene.role('article'),
      Scene.expect(Scene.text('Beverly Hills, California')).toExist(),
      Scene.expect(Scene.text('72\u00B0F')).toExist(),
      Scene.expect(Scene.text('Clear sky')).toExist(),
    ),
  )
})

Every interaction targets an element the way a user would: by label, by role, by placeholder. Every assertion reads like a sentence. Commands are resolved inline, just like in Story.

Story vs Scene

Story and Scene are complementary. Story tests the state machine: does this sequence of Messages produce the right Model? Scene tests the contract: does this feature work from the user’s perspective?

Use Story for update logic, edge cases, and Command wiring. Use Scene for user flows, view rendering, and accessibility. A well-tested app uses both.

Stay in the update loop.

New releases, patterns, and the occasional deep dive.


Built with Foldkit.

© 2026 Devin Jameson