On this pageTests That Read Like Stories
Testing
The Elm Architecture makes testing straightforward. The update function is pure. Given a Model and a Message, it always returns the same result. No DOM, no HTTP calls, no timers. Just a function that takes data and returns data.
Foldkit ships foldkit/test. It simulates the update loop: you send Messages, resolve Commands inline, and assert on the Model. The entire test is one Test.story call. No mocking libraries, no fake timers, no setup or teardown.
Import the Test namespace: import { Test } from 'foldkit'. It exports six functions: story, with, message, resolve, resolveAll, and tap.
import { Test } from 'foldkit'
// Set the initial Model.
Test.with(model)
// Send a Message. Commands stay pending.
Test.message(ClickedSubmit())
// Resolve one Command with its result.
Test.resolve(FetchWeather, SucceededFetchWeather({ data }))
// Resolve many Commands at once.
Test.resolveAll([
[FocusInput, CompletedFocusInput()],
[ScrollToTop, CompletedScroll()],
])
// Assert without breaking the chain.
Test.tap(({ model, message, commands }) => {
expect(model.count).toBe(0)
})
// Run the test story. Throws on unresolved Commands.
Test.story(
update,
Test.with(model),
Test.message(ClickedSubmit()),
Test.resolve(FetchData, SucceededFetch({ data })),
Test.tap(({ model }) => {
expect(model.status).toBe('loaded')
}),
)Here’s a test for the delayed reset from the Commands page. When the user clicks reset, a one-second delay fires, then the count resets to zero:
import { Test } from 'foldkit'
import { expect, test } from 'vitest'
test('delayed reset: count resets after the delay fires', () => {
Test.story(
update,
Test.with({ count: 5 }),
Test.message(ClickedResetAfterDelay()),
Test.tap(({ commands }) => {
expect(commands[0]?.name).toBe(DelayReset.name)
}),
Test.resolve(DelayReset, DelayedReset()),
Test.tap(({ model }) => {
expect(model.count).toBe(0)
}),
)
})The test reads as a story. Start from a Model with count 5. Send ClickedResetAfterDelay(). Verify that update returned a DelayReset Command. Resolve it with DelayedReset(). Verify the count is 0. Every step is visible. The simulation called update, resolved the Command with the Message you provided, fed that Message back through update, and arrived at the final state.
Real apps have multi-step user stories. Test.resolve and Test.resolveAll let you resolve Commands inline at any point in the story. This keeps the resolution next to the step that produced the Command, so the test reads chronologically:
import { Test } from 'foldkit'
import { expect, test } from 'vitest'
test('weather search: success then failure', () => {
Test.story(
update,
Test.with(model),
Test.message(UpdatedZipCodeInput({ value: '90210' })),
Test.tap(({ model }) => {
expect(model.zipCode).toBe('90210')
}),
Test.message(SubmittedWeatherForm()),
Test.tap(({ commands }) => {
expect(commands[0]?.name).toBe(FetchWeather.name)
}),
Test.resolve(
FetchWeather,
SucceededFetchWeather({ weather: beverlyHillsWeather }),
),
Test.tap(({ model }) => {
expect(model.weather._tag).toBe('WeatherSuccess')
expect(model.weather.data.temperature).toBe(72)
}),
Test.message(UpdatedZipCodeInput({ value: '00000' })),
Test.tap(({ model }) => {
expect(model.zipCode).toBe('00000')
}),
Test.message(SubmittedWeatherForm()),
Test.tap(({ commands }) => {
expect(commands[0]?.name).toBe(FetchWeather.name)
}),
Test.resolve(FetchWeather, FailedFetchWeather({ error: 'Not found' })),
Test.tap(({ model }) => {
expect(model.weather._tag).toBe('WeatherFailure')
}),
)
})Every Test.message is a user action: “the user submitted the form.” Every Test.resolve or Test.resolveAll is world-building: “the weather API succeeded.” Every Test.tap is a scene check: “the weather is showing.”
Unresolved Commands
Test.message throws if there are pending Commands from a previous step — resolve all Commands before sending the next Message. Test.story throws at the end if any Commands remain unresolved. Every Command your update function produces must be accounted for.
The simulation tests the state machine. Messages go in, Model changes come out, Commands are resolved declaratively. It does not run the actual Effects inside Commands.
To test that a Command’s Effect works correctly (for example, that an HTTP request parses the response right), test it separately with Effect.provide and a mock service layer:
import { HttpClient, HttpClientResponse } from '@effect/platform'
import { Effect, Layer, Match as M, String } from 'effect'
import { expect, test } from 'vitest'
test('fetchWeather returns SucceededFetchWeather on success', async () => {
const mockClient = HttpClient.make(request =>
Effect.sync(() => {
const responseData = M.value(request.url).pipe(
M.when(String.includes('geocoding'), () => ({
results: [
{
name: 'Beverly Hills',
latitude: 34.07362,
longitude: -118.40036,
admin1: 'California',
},
],
})),
M.when(String.includes('forecast'), () => ({
current: {
time: '2026-03-10T01:30',
interval: 900,
temperature_2m: 72.4,
relative_humidity_2m: 45,
wind_speed_10m: 9.8,
weather_code: 0,
},
})),
M.orElse(url => {
throw new Error(`Unexpected request URL: ${url}`)
}),
)
return HttpClientResponse.fromWeb(
request,
new Response(JSON.stringify(responseData), { status: 200 }),
)
}),
)
const message = await fetchWeather('90210').effect.pipe(
Effect.provide(Layer.succeed(HttpClient.HttpClient, mockClient)),
Effect.runPromise,
)
expect(message._tag).toBe('SucceededFetchWeather')
})Two levels, clean separation. The simulation proves the state machine wires correctly. Effect.provide proves the side effect works. If the state machine sends the right Command, and the Command does the right thing, the program works.