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
@keyfor 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 theResult_spec.twill convert into aunit Effect.tand 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.