githubEdit

flask-vialTests

Challenge: the Cmd track

The 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 =! expectedMenus

The wildcard _ silently ignores whatever commands the update function emitted. This is the approach recommended by the Elmish community and described by Jordan Marrarrow-up-right.

circle-info

The =! operator comes from Unquotearrow-up-right. It means "must equal" and provides clear, expression-based failure messages.

UnitTestSession — making Cmd safe for tests

Even 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:

circle-info

This design combines three patterns from James Shore's Testing Without Mocksarrow-up-right:

  • Nullable — UnitTestSession makes Cmder "nullable": it disables external communication while preserving normal behavior. WithUnitTestSession plays the role of Shore's createNull() factory.

  • Configurable Responses — each test can configure the responses it needs, both via the MockedApi (overriding specific endpoints) and via DelayedMessageHandling (Drop or EmitImmediately).

  • Embedded Stub — RootApiMock.NothingImplemented is a stub of the RootApi record where every method raises NotImplementedException, 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

When 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 single update call in isolation

  • EmitImmediately — dispatches the message synchronously as Cmd.ofMsg; useful when running a full Elmish loop in tests to verify the complete message cascade

Testing strategies

Strategy 1: single update call

The 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

The 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 menu

  • ChangeLang Done Ok — verifies translations are populated, including localized strings

  • ChangeLang Done Error — verifies the existing translations are preserved and the error status is set

  • FillTranslations — verifies that new translations are merged into existing ones

  • PrepareQueryWithTranslations — verifies that the request body lists the pages still missing translations

circle-info

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

Instead 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:

  1. Each step function receives the current model and returns a Msg

  2. update processes the message, returning (model', cmd)

  3. processCmd executes each sub in the Cmd list, collecting dispatched messages

  4. Cascading messages are processed recursively until no more are dispatched

  5. Cmd.ofEffect (used for callbacks like onSaveProduct) executes the effect, captured via saveProductCalls

  6. Cmd.none produces 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

A 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

A 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:

  1. update returns { model with SaveDate = Remote.Loading } + Cmd.addProduct ...

  2. processCmd executes the Cmd — mocked AddProduct API returns fakeData.AddProductResponse — dispatches AddProduct(product, Done(...))

  3. update processes Done(...) — sets SaveDate = Remote.Loaded fullContext.Now (or Remote.LoadError) + Cmd.ofEffect(onSaveProduct(product, error))

  4. processCmd executes Cmd.ofEffect — onSaveProduct is called, captured in saveProductCalls

  5. Cmd.ofEffect does 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 single update call in isolation or when delayed messages are irrelevant

  • EmitImmediately — dispatches the message synchronously as Cmd.ofMsg; useful when running a full cascade to verify the complete message sequence including animations

Other Client tests

AppViewShould — page access resolution

AppViewShould 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 Login

  • A logged-in user accessing Home or Login is redirected to the default product index page

  • Pages 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 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

A 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 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 FsCheckarrow-up-right 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.

circle-info

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 Playwrightarrow-up-right 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