On this pageTesting Through the View
Scene
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 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')| Locator | Finds | Example |
|---|---|---|
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 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 })| Option | Type | Matches |
|---|---|---|
name | string | RegExp | Accessible name (aria-label, aria-labelledby, label[for], or text content). Strings match exactly; regular expressions match against the full name. |
level | number | Heading level (for role: "heading") |
checked | boolean | 'mixed' | aria-checked or the checked attribute |
selected | boolean | aria-selected |
pressed | boolean | 'mixed' | aria-pressed |
expanded | boolean | aria-expanded |
disabled | boolean | aria-disabled or the disabled attribute |
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' })),
)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 option | Keeps matches where |
|---|---|
has | The element contains a descendant matching the given Locator |
hasNot | The element does not contain a descendant matching the Locator |
hasText | The element’s text content includes the given substring |
hasNotText | The element’s text content does not include the substring |
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')| Step | Invokes |
|---|---|
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.
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()| Matcher | Asserts 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:
| Matcher | Asserts that |
|---|---|
.toHaveCount(n) | The locator matches exactly n elements |
.toBeEmpty() | The locator matches zero elements |
Scene can also assert on pending Commands. Use these steps to verify that an interaction produced the Commands you expect before resolving them:
| Step | Asserts 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()],
)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 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.