Tests
Challenge: the Cmd track
Cmd trackThe update function returns a Model * Cmd<Msg> pair. The Model part is easy to assert on — it is a plain immutable record. The Cmd<Msg> part is harder: an Elmish Cmd is an opaque list of side-effect functions, not designed for equality comparisons. In practice, most unit tests focus on the model and discard the command.
let newModel, _ =
defaultModel
|> update (Msg.ChangeLang(lang, Start))
newModel |> LangStatus.allOfModel =! expectedMenusThe wildcard _ silently ignores whatever commands the update function emitted. This is the approach recommended by the Elmish community and described by Jordan Marr.
The =! operator comes from Unquote. It means "must equal" and provides clear, expression-based failure messages.
UnitTestSession — making Cmd safe for tests
UnitTestSession — making Cmd safe for testsEven though we discard the Cmd return value, the update function still builds it. If the Cmd construction involves a real Fable Remoting proxy (Server.api), it will fail at runtime in a .NET test host because the proxy only works in a browser context.
Shopfoo solves this with UnitTestSession, defined in Shared/Remoting.fs:
[<RequireQualifiedAccess>]
type DelayedMessageHandling =
| Drop
| EmitImmediately
type UnitTestSession = {
DelayedMessageHandling: DelayedMessageHandling
MockedApi: RootApi
Now: DateTime option
}When FullContext.UnitTestSession is Some, the Cmder.ofApiRequest method uses the provided MockedApi instead of Server.api. The Now field allows tests to inject a deterministic date instead of DateTime.Now. In production, UnitTestSession is always None and fullContext.Now falls back to DateTime.Now:
This design combines three patterns from James Shore's Testing Without Mocks:
Nullable —
UnitTestSessionmakesCmder"nullable": it disables external communication while preserving normal behavior.WithUnitTestSessionplays the role of Shore'screateNull()factory.Configurable Responses — each test can configure the responses it needs, both via the
MockedApi(overriding specific endpoints) and viaDelayedMessageHandling(DroporEmitImmediately).Embedded Stub —
RootApiMock.NothingImplementedis a stub of theRootApirecord where every method raisesNotImplementedException, serving as the default that tests specialize.
Strictly speaking, MockedApi should be named StubbedApi: a stub provides preconfigured responses (which is what MockedApi does), whereas a mock verifies interactions (calls made, arguments received). The current naming follows a common but imprecise convention in the industry.
Test setup helper
In Shopfoo.Client.Tests, a FullContext extension method makes it easy to enable the test session:
RootApiMock.NothingImplemented provides a RootApi where every method throws NotImplementedException. This ensures that if a test accidentally triggers a real API call, it fails immediately with a clear error.
The typical test model is set up with DelayedMessageHandling.Drop, which discards any delayed messages (like Cmd.ofMsgDelayed) rather than trying to schedule them through the JavaScript runtime:
How Cmder adapts in test mode
Cmder adapts in test modeWhen UnitTestSession is Some, Cmder adjusts two behaviors:
API calls — The mocked API is used instead of the real Fable Remoting proxy, and Cmd.OfAsyncWith.either Async.StartImmediate replaces Cmd.OfAsync.either so that async operations execute synchronously:
Delayed messages — ofMsgDelayed (used for UI animations, polling, etc.) is controlled by DelayedMessageHandling:
Drop— silences delayed messages; useful when testing a singleupdatecall in isolationEmitImmediately— dispatches the message synchronously asCmd.ofMsg; useful when running a full Elmish loop in tests to verify the complete message cascade
Testing strategies
Strategy 1: single update call
update callThe simplest approach calls update directly, discards the Cmd, and asserts on the model. This is sufficient when the message handling has no cascading effects worth verifying.
AppShould — testing the root update
AppShould — testing the root updateThe AppShould test class exercises the App.update function, which handles messages like ChangeLang, FillTranslations, Login, and Logout.
Each test follows the same pattern: build an initial model, apply one message through update, and assert on the resulting model.
Notable test coverage includes:
ChangeLang Start— verifies the loading indicator is set on the correct language menuChangeLang Done Ok— verifies translations are populated, including localized stringsChangeLang Done Error— verifies the existing translations are preserved and the error status is setFillTranslations— verifies that new translations are merged into existing onesPrepareQueryWithTranslations— verifies that the request body lists the pages still missing translations
The update function is internal (not private) to allow the test project Shopfoo.Client.Tests to call it via InternalsVisibleTo.
Strategy 2: full message cascade with mocked API
Some scenarios require verifying the entire message cascade — the initial message triggers a Cmd, which produces another message, which triggers another Cmd, and so on. Strategy 1 discards the Cmd and only asserts on the model after a single update call. Strategy 2 executes the full cascade, including API calls via mocked endpoints, and asserts on the final state and side effects (callbacks).
The examples below are drawn from CatalogInfoShould.fs, which tests the CatalogInfo form component — the form that adds or edits a product's catalog information.
Extracting message construction helpers
In the view, messages are typically constructed inline inside dispatch(...) calls. To make them testable, we can extract them into a Msg companion module:
The view then delegates to these helpers:
Fake data and mocked API endpoints
The test starts from RootApiMock.NothingImplemented and selectively overrides the endpoints needed by the scenario.
FakeData is a record that holds the data injected into the UnitTestSession of FullContext to adapt it to the current test. Using a record serves two purposes: it is easy to thread through mockedApi, fullContext, and runScenario as a single argument, and FsCheck can generate random values for it automatically — covering both the happy path and error cases without separate test methods.
FakeData.AddProductDate computes the expected Remote<DateTime> for assertions — Remote.Loaded now on success, Remote.LoadError on failure. This lets the same test verify both the happy path and error cases without separate test methods.
The Scenario module — generic Elmish loop simulator
Scenario module — generic Elmish loop simulatorInstead of running a real Elmish Program, we simulate the loop manually. This is simpler and avoids browser-dependent code (Cmd.navigatePath, etc.).
Each scenario step is a 'model -> 'msg function (not a plain 'msg), because Msg.* helpers need the current product from the model — mirroring how the view always has the current product in scope.
The loop simulation is extracted into a generic, page-agnostic Scenario module:
How it works:
Each
stepfunction receives the current model and returns aMsgupdateprocesses the message, returning(model', cmd)processCmdexecutes each sub in theCmdlist, collecting dispatched messagesCascading messages are processed recursively until no more are dispatched
Cmd.ofEffect(used for callbacks likeonSaveProduct) executes the effect, captured viasaveProductCallsCmd.noneproduces no dispatched messages, ending the cascade
Why this works synchronously: In test mode, Cmder.ofApiRequest uses Cmd.OfAsyncWith.either Async.StartImmediate instead of Cmd.OfAsync.either. This makes the async API call execute synchronously when the sub is invoked, so dispatched.Add receives the response message immediately.
The Step module and runScenario helper
Step module and runScenario helperA test class for an Elmish page can optionally define step helpers that know how to extract domain objects from the model. Grouping them in a Step module with [<RequireQualifiedAccess>] gives clean qualified access (Step.changeProduct, Step.changeBook, etc.) and makes scenarios easier to write and read:
runScenario wires FakeData into Scenario.run and captures onSaveProduct callbacks:
Complete test: CatalogInfoShould
CatalogInfoShouldA private shared helper asserts on the product, save date, and onSaveProduct callback in a single tuple comparison. Each test method then reads as a pure scenario — expectations out, data and steps in:
The same pattern supports bazaar products — Step.changeBazaar extracts the BazaarProduct from the model, mirroring how Step.changeBook extracts the Book:
The [<FsCheckProperty(MaxTest = 5)>] attribute generates 5 random FakeData values per test, covering different dates and both Ok() and Error(...) API responses. The fakeData.AddProductDate member computes the expected SaveDate for each case, so the same scenario handles both the happy path and error cases.
The Step.addProduct step triggers the following cascade:
updatereturns{ model with SaveDate = Remote.Loading }+Cmd.addProduct ...processCmdexecutes the Cmd — mockedAddProductAPI returnsfakeData.AddProductResponse— dispatchesAddProduct(product, Done(...))updateprocessesDone(...)— setsSaveDate = Remote.Loaded fullContext.Now(orRemote.LoadError) +Cmd.ofEffect(onSaveProduct(product, error))processCmdexecutesCmd.ofEffect—onSaveProductis called, captured insaveProductCallsCmd.ofEffectdoes not dispatch any message — cascade ends
Controlling delayed messages
Tests can switch DelayedMessageHandling to verify different stages of an animated sequence:
Drop— silences delayed messages; useful when testing a singleupdatecall in isolation or when delayed messages are irrelevantEmitImmediately— dispatches the message synchronously asCmd.ofMsg; useful when running a full cascade to verify the complete message sequence including animations
Other Client tests
AppViewShould — page access resolution
AppViewShould — page access resolutionAppViewShould tests the resolvePageAccess function — the logic that decides which page to display based on the current Page and the User. It covers scenarios like:
An anonymous user accessing a protected page is redirected to
LoginA logged-in user accessing
HomeorLoginis redirected to the default product index pagePages requiring specific features (e.g.
Admin,Catalog) produce the expected access check
These tests are pure functions of (Page, User) -> (Page, Feat option), with no Elmish loop involved.
AppTranslationsShould — translation caching
AppTranslationsShould — translation cachingAppTranslationsShould validates the AppTranslations type, which manages incremental translation loading. Tests verify that pages can be filled individually, that populating one page does not affect others, and that switching language replaces all cached strings cleanly.
RoutingTests — URL roundtrip
RoutingTests — URL roundtripA single FsCheck property-based test generates random Page values via custom arbitraries (SanitizedPage), converts them to URL segments, and parses them back. This gives strong confidence that the Page -> URL -> Page roundtrip is lossless.
FiltersShould — filtering and sorting with FsCheck
FiltersShould — filtering and sorting with FsCheckFiltersShould is the most comprehensive test class. It validates the Filters.apply function, which is a pure domain function on the client side — no Elmish, no Cmd, no update. It takes a list of products, a Filters record, and returns matching rows with search highlighting.
All tests use FsCheck property-based testing via a custom [<ShopfooFsCheckProperty>] attribute.
Filtering:
Filter bazaar products by category
Filter books by author (randomly chosen from available authors)
Filter books by tag (randomly chosen from available tags)
Search — with case-sensitive and case-insensitive variants:
Search by description, title, subtitle, or author name
Verify that matching rows contain the search term in the expected column
Verify that case-sensitive search fails when the case is changed
Sorting:
Sort by product number (index), SKU, title
Sort bazaar products by category (with title as tiebreaker)
Sort books by authors or tags (with title as tiebreaker)
Each sort test verifies both ascending and descending directions
The test helpers (buildSearchConfig, verifySearchSuccess, performSortBy) keep individual test methods concise while the property-based approach provides broad coverage with minimal hand-written examples.
Conclusion
The Elmish architecture makes the logic in views straightforward to test: the update function is a pure function from (Msg, Model) to (Model, Cmd), and pure client-side functions like Filters.apply or resolvePageAccess can be tested in complete isolation. Combined with UnitTestSession, even Cmd-producing code can run safely in a .NET test host — either by discarding commands (Strategy 1) or by executing the full Elmish loop with a mocked API (Strategy 2).
All these tests run fast, are deterministic, and exercise the application logic without a browser.
Going further: browser-level testing
To test the graphical interface itself — DOM rendering, CSS transitions, component interactions — heavier end-to-end tests are needed. Tools like Playwright can drive a real browser, with test scripts written in F#. These tests are more costly to set up and more fragile to maintain (they depend on the DOM structure), but they validate the application as the user actually experiences it.
Shopfoo could benefit from this approach to test cross-component interactions that are difficult to cover with unit tests alone: opening and closing Drawers, displaying and dismissing Toasts, or verifying that navigation between pages triggers the expected visual transitions.
Last updated