Testing

May 15, 2026 · View on GitHub

Traditional approaches for testing web applications can be infuriating. With tools like selenium or puppeteer, there's an entire headless browser running in the background, and not only do you need to find a way to reconfigure the app or library for testing, slow test execution and race condition-related bugs are a constant companion.

Bonsai web apps compute a declarative Vdom.Node.t, which serves as the source of truth for how the web UI should look at any given time.

The Bonsai_web_test library provides tools to compute and test the Vdom.Node.t generated by any local_ graph -> Bonsai.t, so we can use ppx_expect_test and all the other OCaml testing tools we know and love.

Limitations of Virtual_dom Expect Testing

It's important to note that a Vdom.Node.t is a description of what the DOM should look like, not the actual DOM. The HTML you see in expect test output is roughly what your DOM will look like, but expect tests have some limitations:

  • Expect tests run in Node, not in a browser. As a result, any APIs not provided by Node have to be mocked out.
  • Events dispatched by Handle.click_on, etc. do not propogate.
  • The implementations of Vdom hooks and widgets do not run in tests. This includes global listeners, which are implemented via hooks.
  • Some attributes, such as @key for vdom keys and @on_* for event listeners, will not be in the real DOM.

That being said, expect tests are still very useful for testing:

  • The Bonsai code in your web apps, which accounts for most of the complexity
  • The structure of your app's DOM, including most tags and attributes
  • Interactions with your app's DOM

Getting Ready For Testing

Testing a program built using Js_of_ocaml involves a few changes to your normal workflow.

(library (
  (name my_ui_test)
  (js_of_ocaml ())                   ; Test library must be marked with js_of_ocaml
  (libraries (core my_ui))
  (inline_tests (                    ; Native tests must be disabled
    (native     dont_build_dont_run)
    (javascript build_and_run)))))

Your jenga start-file also needs to specify the javascript-runtest alias for the project.

(alias ((name build) (deps (
  (alias %{root}/app/my-app/test/javascript-runtest)
  ; ... your other build targets here...
))))

Basics of testing: printing Vdom

Our main tool for expect-testing Bonsai apps is Bonsai_web_test.Handle. It wraps a local_ graph -> 'a Bonsai.t, and provides APIs for:

  • Evaluating it (Handle.recompute_view) and printing the result (Handle.show).
  • Injecting some value of type incoming, which the Result_spec.t will convert into a unit Effect.t and run (Handle.do_actions).
  • Simulating interactions with the DOM that trigger event listeners: e.g. Handle.click_on, Handle.input_text, etc.

We need to tell our handle how to print our 'a view, and how to convert an incoming value into an Effect.t. This is contained in a first-class module param, of type Result_spec.t. Result_spec.vdom is a helper for generating Vdom.Node.t Result_spec.ts.

Let's write a basic handle testing some constant vdom:

module Handle = Bonsai_web_test.Handle
module Result_spec = Bonsai_web_test.Result_spec

let hello_world = Vdom.Node.span [ Vdom.Node.text "hello world" ]

let%expect_test "it shows hello world" =
  let handle = Handle.create (Result_spec.vdom Fn.id) (fun _ -> return hello_world) in
  Handle.show handle;
  [%expect {| <span> hello world </span> |}]
;;

Some Bonsai.ts return a Vdom.Node.t and something else. Result_spec.vdom's first argument is a function to pull out the Vdom.Node.t part.

We'll make our own Result_spec.ts for things other than Vdom.Node.t later.

show vs recompute_view vs recompute_view_until_stable

Handle.recompute_view will run "one frame" of the Bonsai runtime loop. Handle.show runs Handle.recompute_view, and then prints the computed view.

There's also a Handle.recompute_view_until_stable that will re-run Handle.recompute_view while there are pending lifecycle events or on_changes. This is typically an antipattern, because if it takes more than one recompute_view to update your view, that's likely caused by state synchronization, which is preferable to avoid.

Handlers in vdom tests

You might notice that event handlers are weirdly formatted in vdom expect test output:

let%expect_test "handlers in tests" =
  let clickable_div =
    Vdom.Node.div
      ~attrs:[ Vdom.Attr.on_click (fun _ -> some_effect) ]
      [ Vdom.Node.text "You can click me!" ]
  in
  let handle =
    Handle.create (Result_spec.vdom Fn.id) (fun _ -> Bonsai.return clickable_div)
  in
  Handle.show handle;
  [%expect {| <div @on_click> You can click me! </div> |}]
;;

Why @on_click, when HTML elements have an onclick attribute?

Vdom.Node.on_* attaches event listeners programmatically; it doesn't use a "real" event handler attribute because they don't work with CSP, among other reasons.

This distinction matters because some accessibility tools like Vimium look for onclick attributes to identify clickable elements. Unfortunately, they can't do the same with programmatic event listeners. In general, clickable elements should use the <button /> or <a /> HTML tags, or an applicable role attribute, e.g. role="button".

Formatting as @on_click makes it clear that it's not a real attribute, while still indicating the presence of a handler.

Vdom Linter

Whenever you call Handle.show on a Handle.t created with Result_spec.vdom, your virtual_dom will be linted for possible errors. For instance, having sibling nodes with the same vdom key will crash your app, so we'll error if we ever seen this in tests:

let%expect_test "linter error on duplicate keys" =
  let vdom_with_duplicate_keys =
    Vdom.Node.div
      [ Vdom.Node.div ~key:"a" []; Vdom.Node.div ~key:"b" []; Vdom.Node.div ~key:"a" [] ]
  in
  let handle =
    Handle.create
      (Result_spec.vdom ~lint_expected_failures:[ Siblings_have_same_vdom_key ] Fn.id)
      (fun _ -> Bonsai.return vdom_with_duplicate_keys)
  in
  Handle.show handle;
  [%expect
    {|
    <div>
      <div @key=a> </div>
      <div @key=b> </div>
      <div @key=a> </div>
    </div>

    Linting Failures:

    <div>
      <div @key=a></div> <- [ERRORS]: Siblings have same vdom key
      ...
      <div @key=a></div> <- [ERRORS]: Siblings have same vdom key
    </div>

    [Fatal] Siblings have same vdom key (failure expected)
    ------------------------------------------------------
    Sibling vdom nodes MUST NOT have the same key. This will crash your web app at runtime.
    |}]
;;

You can also call Handle.lint_vdom to run the linter on-demand, which is useful when using a custom Result_spec.t.

Testing dynamic inputs

What if we want to test a 'a Bonsai.t -> local_ Bonsai.graph -> 'b Bonsai.t?

let hello_user (name : string Bonsai.t) (local_ _graph) : Vdom.Node.t Bonsai.t =
  let%arr name in
  Vdom.Node.span [ Vdom.Node.textf "hello %s" name ]
;;

We can use Bonsai.Var.t to get a mutable handle on a Bonsai.t:

let%expect_test "shows hello to a user" =
  let user_var = Bonsai.Expert.Var.create "Bob" in
  let user = Bonsai.Expert.Var.value user_var in
  let handle = Handle.create (Result_spec.vdom Fn.id) (hello_user user) in
  Handle.show handle;
  [%expect {| <span> hello Bob </span> |}];
  Bonsai.Expert.Var.set user_var "Alice";
  [%expect {| |}];
  Handle.show handle;
  [%expect {| <span> hello Alice </span> |}]
;;

Note that just setting the Var.t doesn't change our view; we need to run a cycle of the Bonsai runtime via Handle.show or Handle.recompute_view.

Testing with diffs

If we only want to see what changed between two versions of the view, we can use Handle.show_diff:

let%expect_test "shows hello to a user" =
  let user_var = Bonsai.Expert.Var.create "Bob" in
  let user = Bonsai.Expert.Var.value user_var in
  let handle = Handle.create (Result_spec.vdom Fn.id) (hello_user user) in
  Handle.show handle;
  [%expect {| <span> hello Bob </span> |}];
  Bonsai.Expert.Var.set user_var "Alice";
  Handle.show_diff handle;
  [%expect
    {|
    -|<span> hello Bob </span>
    +|<span> hello Alice </span>
    |}]
;;

While the diff in this instance isn't particularly illuminating, when testing UI components that produce hundreds of lines of output, it can be much easier to only review the diff.

Testing interactivity

Most useful web UIs store internal state, which can be updated in response to user interactions.

Here, we actually use the hello_user we defined previously, but the string Bonsai.t comes from internal state instead of being passed in by the caller:

let hello_textbox ?test_selector (local_ graph) : Vdom.Node.t Bonsai.t =
  let state, set = Bonsai.state "" graph in
  let%arr message = hello_user state graph
  and set in
  Vdom.Node.div
    [ Vdom.Node.input
        ~attrs:
          [ Vdom.Attr.on_input (fun _ text -> set text)
          ; Test_selector.attr_of_opt test_selector
          ]
        ()
    ; message
    ]
;;

This is fully self-contained: its interior state is only changeable by typing into the <input> text-box.

Event listeners added via Vdom.Attr.* are testable with Bonsai! We can use Handle.input_text to simulate interacting with the <input /> DOM element:

open Bonsai_web_test

let input_selector = Test_selector.make ()

let%expect_test "shows hello to a specified user" =
  let handle =
    Handle.create (Result_spec.vdom Fn.id) (hello_textbox ~test_selector:input_selector)
  in
  Handle.show handle;
  [%expect
    {|
    <div>
      <input @on_input/>
      <span> hello  </span>
    </div>
    |}];
  Handle.input_text
    handle
    ~get_vdom:Fn.id
    ~selector:(test_selector input_selector)
    ~text:"Bob";
  Handle.show_diff handle;
  [%expect
    {|
      <div>
        <input @on_input/>
    -|  <span> hello  </span>
    +|  <span> hello Bob </span>
      </div>
    |}];
  Handle.input_text
    handle
    ~get_vdom:Fn.id
    ~selector:(test_selector input_selector)
    ~text:"Alice";
  Handle.show_diff handle;
  [%expect
    {|
      <div>
        <input @on_input/>
    -|  <span> hello Bob </span>
    +|  <span> hello Alice </span>
      </div>
    |}]
;;

Note that rather than using a CSS selector string, we use an opaque Test_selector.t, which we then turn into a CSS selector string via Bonsai_web_test.test_selector.

You should always prefer Test_selector.t over plain selector strings, as they are much less brittle. There's also a Test_selector.Keyed module, which allows generating a stable test selector for each value of some 'a.

open Bonsai_web_test

let keyed_selector = Test_selector.Keyed.create (module Int) |> Test_selector.Keyed.get

let%expect_test "shows hello to a specified user" =
  let handle =
    Handle.create (Result_spec.vdom Fn.id) (fun _ ->
      let button i =
        {%html.jsx|
          <button
            on_click=%{fun _ -> Effect.print_s [%message "Clicked!" (i : int)]}
            %{keyed_selector i |> Test_selector.attr}
          >
            #{" Button "}%{i#Int}
          </button>
        |}
      in
      return {%html.jsx|<div>%{button 1}%{button 2}%{button 3}%{button 4}</div>|})
  in
  Handle.show handle;
  [%expect
    {|
    <div>
      <button @on_click>  Button  1 </button>
      <button @on_click>  Button  2 </button>
      <button @on_click>  Button  3 </button>
      <button @on_click>  Button  4 </button>
    </div>
    |}];
  Handle.click_on handle ~get_vdom:Fn.id ~selector:(keyed_selector 3 |> test_selector);
  [%expect {| (Clicked! (i 3)) |}]
;;
<aside>

Just like with Result_spec.vdom, Handle.input_text takes a function to extract the Vdom.Node.t from a 'a Bonsai.t via ~get_vdom.

</aside>

Testing state transitions directly

Many Bonsai functions expose a 'a -> unit Effect.t that can be used to set some internal state, or apply an action to a state machine. An example is the second part of Bonsai.state's output:

('model Bonsai.t * ('model -> unit Effect.t) Bonsai.t)

Testing Bonsai.state or anything that exposes an injection function will usually require a custom Result_spec.t and a new Handle function.

We can use val Handle.do_actions : Handle.t -> incoming list -> unit to inject actions:

module State_view_spec = struct
  type t = string * (string -> unit Effect.t)
  type incoming = string

  let view : t -> string = fun (view, _) -> view
  let incoming : t -> incoming -> unit Effect.t = fun (_, incoming) -> incoming
end

let%expect_test "test Bonsai.state" =
  let state_single_bonsai (local_ graph)
    : (string * (string -> unit Vdom.Effect.t)) Bonsai.t
    =
    let state, inject = Bonsai.state "hello" graph in
    Bonsai.both state inject
  in
  let handle = Handle.create (module State_view_spec) state_single_bonsai in
  Handle.show handle;
  [%expect {| hello |}];
  Handle.do_actions handle [ "world" ];
  Handle.show handle;
  [%expect {| world |}]
;;

Bonsai_web_test only supports creating handles for a single 'a Bonsai.t, so we need to combine the 2 outputs of Bonsai.state.

Instead of using the Result_spec.vdom helper function like before, we need to define our view-spec module that caters specifically to the type t returned by state. We also define a type incoming, which represents "input events" we can inject.

Mocking time in tests

In the how-to on time, we wrote a UI that depends on time:

let current_time (local_ graph) =
  let%arr now = Bonsai.Clock.Expert.now graph in
  Vdom.Node.text (Time_ns.to_string_utc now)
;;

We can use Handle.advance_clock_by to mock time in tests:

let%expect_test "test clock" =
  let handle = Handle.create (Result_spec.vdom Fn.id) Time_examples.current_time in
  Handle.show handle;
  [%expect {| 1970-01-01 00:00:00.000000000Z |}];
  Handle.advance_clock_by handle (Time_ns.Span.of_sec 2.0);
  Handle.show handle;
  [%expect {| 1970-01-01 00:00:02.000000000Z |}]
;;

Avoid Async Testing

Bonsai support running Async tests, by opening Bonsai_web_test_async at the top of your test file. This is necessary for testing RPCs, which must be asynchronous. Other than that, we highly recommend using a synchronous test handle, if possible.

Our expect test architecture assumes that all inline tests can run synchronously. Native tests running async can be made synchronous by flushing the async queue, but Bonsai_web_test tests run in Node, where we don't own the async implementation.

We currently use a package called deasync, which hooks into libuv to allow synchronously advancing the event loop. It's a bit problematic:

  • The behavior is unstable, and not officially supported
  • It's extremely slow
  • The deasync'ed Node event loop has different semantics from the browser event loop

In practice, we haven't ran into major correctness issues, but async Bonsai tests will be several magnitudes slower than the synchronous equivalent.