On this pageTesting the Update Loop
Story
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.
Story tests the state machine. You send Messages through update, resolve Commands inline, and assert on the Model. The entire test is one Story.story call. No mocking libraries, no fake timers, no setup or teardown.
Import the Story namespace: import { Story } from 'foldkit'. It exports eleven functions: story, with, message, resolve, resolveAll, model, expectHasCommands, expectExactCommands, expectNoCommands, expectOutMessage, and expectNoOutMessage.
import { Story } from 'foldkit'
// Set the initial Model.
Story.with(model)
// Send a Message. Commands stay pending.
Story.message(ClickedSubmit())
// Resolve one Command with its result.
Story.resolve(FetchWeather, SucceededFetchWeather({ data }))
// Resolve many Commands at once.
Story.resolveAll([
[FocusInput, CompletedFocusInput()],
[ScrollToTop, CompletedScroll()],
])
// Assert on the Model.
Story.model(model => {
expect(model.count).toBe(0)
})
// Assert these Commands were produced.
Story.expectHasCommands(FetchWeather)
// Assert exactly these Commands were produced.
Story.expectExactCommands(FetchWeather, SaveBoard)
// Assert no Commands were produced.
Story.expectNoCommands()
// Assert on the OutMessage.
Story.expectOutMessage(SucceededLogin({ session }))
// Run the test story. Throws on unresolved Commands.
Story.story(
update,
Story.with(model),
Story.message(ClickedSubmit()),
Story.expectHasCommands(FetchData),
Story.resolve(FetchData, SucceededFetch({ data })),
Story.model(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 { Story } from 'foldkit'
import { expect, test } from 'vitest'
test('delayed reset: count resets after the delay fires', () => {
Story.story(
update,
Story.with({ count: 5 }),
Story.message(ClickedResetAfterDelay()),
Story.expectHasCommands(DelayReset),
Story.resolve(DelayReset, DelayedReset()),
Story.model(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. Story.resolve and Story.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 { Story } from 'foldkit'
import { expect, test } from 'vitest'
test('weather search: success then failure', () => {
Story.story(
update,
Story.with(model),
Story.message(UpdatedZipCodeInput({ value: '90210' })),
Story.model(model => {
expect(model.zipCode).toBe('90210')
}),
Story.message(SubmittedWeatherForm()),
Story.expectHasCommands(FetchWeather),
Story.resolve(
FetchWeather,
SucceededFetchWeather({ weather: beverlyHillsWeather }),
),
Story.model(model => {
expect(model.weather._tag).toBe('WeatherSuccess')
expect(model.weather.data.temperature).toBe(72)
}),
Story.message(UpdatedZipCodeInput({ value: '00000' })),
Story.model(model => {
expect(model.zipCode).toBe('00000')
}),
Story.message(SubmittedWeatherForm()),
Story.expectHasCommands(FetchWeather),
Story.resolve(FetchWeather, FailedFetchWeather({ error: 'Not found' })),
Story.model(model => {
expect(model.weather._tag).toBe('WeatherFailure')
}),
)
})Every Story.message is a user action: “the user submitted the form.” Every Story.resolve or Story.resolveAll is world-building: “the weather API succeeded.” Every Story.model is a scene check: “the weather is showing.”
Unresolved Commands
Story.message throws if there are pending Commands from a previous step — resolve all Commands before sending the next Message. Story.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.