Cookbook

Testing components

Test a component's logic as plain F#, with no browser, and the component itself in a real browser with Playwright, from the same .NET test project style.

This recipe has no demo: its code is tests. They test the Form validation recipe's sign-up form. This site runs a longer version of the browser test on that demo, in tests/Site.E2E, along with one for every other recipe here.

The logic, without a browser

Keep the decisions in functions that don't touch Lit or the DOM, in a file of their own: the model, update, validate. They're ordinary F#, so they compile for .NET as well as for Fable, and a test runs them in milliseconds:

module Signup

open System.Text.RegularExpressions

type Signup = { Name: string; Email: string }

let validate (form: Signup) =
    [
        if form.Name.Trim() = "" then
            "name", "Enter your name."
        if not (Regex.IsMatch(form.Email.Trim(), @"^[^@\s]+@[^@\s]+\.[^@\s]+$")) then
            "email", "Enter an email address, such as ada@example.com."
    ]
open Expecto
open Signup

let tests =
    testList "validate" [
        test "an empty form has both errors, in order" {
            let errors = validate { Name = ""; Email = "" }
            Expect.equal (List.map fst errors) [ "name"; "email" ] "fields with errors"
        }
        test "an address needs a domain" {
            let errors = validate { Name = "Ada"; Email = "ada@" }
            Expect.equal (List.map fst errors) [ "email" ] "fields with errors"
        }
    ]

The component's file then opens Signup and does only the rendering. The Kanban sample's tests test its Elmish update this way, with Pyxpecto, which runs the same tests under .NET and, compiled by Fable, in Node.

The component, in a browser

What the user sees, the focus, and the accessible names need a browser. Playwright for .NET drives Chromium, Firefox or WebKit from an F# test:

dotnet add package Microsoft.Playwright
dotnet build
pwsh bin/Debug/net10.0/playwright.ps1 install chromium

Serve the built app, as vite preview does, and point the test at it:

open Expecto
open Microsoft.Playwright
open type Microsoft.Playwright.Assertions

let signupForm =
    testTask "the sign-up form shows its errors, focuses the first, then thanks" {
        let! (playwright: IPlaywright) = Playwright.CreateAsync()

        try
            use! (browser: IBrowser) = playwright.Chromium.LaunchAsync()
            let! (page: IPage) = browser.NewPageAsync()
            let! _ = page.GotoAsync "http://localhost:4173/"

            let form = page.Locator "my-signup-form"
            let name = form.GetByLabel "Name"
            let signUp = form.GetByRole(AriaRole.Button, LocatorGetByRoleOptions(Name = "Sign up"))

            do! signUp.ClickAsync()
            do! Expect(name).ToBeFocusedAsync()
            do! Expect(name).ToHaveAccessibleDescriptionAsync "Enter your name."
            do! name.FillAsync "Ada"
            do! form.GetByLabel("Email").FillAsync "ada@example.com"
            do! signUp.ClickAsync()
            do! Expect(form.GetByRole AriaRole.Status).ToHaveTextAsync "Thanks, Ada. Check your inbox."
        finally
            playwright.Dispose()
    }

use! closes the browser when the test ends, whether it passes or fails, and finally stops Playwright's driver. Expecto's testTask takes use and use! only for IAsyncDisposable, which the browser is and Playwright isn't. In a suite, start Playwright and the browser once and share them, as Site.E2E does, and give each test a new page.