Wallabidi's API is built around two concepts: Queries and Actions. For per-module
reference, see the Wallabidi.Browser,
Wallabidi.Query, and Wallabidi.Element
docs.
Queries and actions
Queries allow us to declaratively describe the elements that we would like to interact with and Actions allow us to use those queries to interact with the DOM.
Let's say that our HTML looks like this:
<ul class="users">
<li class="user">
<span class="user-name">Ada</span>
</li>
<li class="user">
<span class="user-name">Grace</span>
</li>
<li class="user">
<span class="user-name">Alan</span>
</li>
</ul>If we wanted to interact with all of the users then we could write a query like so css(".user", count: 3).
If we only wanted to interact with a specific user then we could write a query like this css(".user-name", count: 1, text: "Ada"). Now we can use those queries with some actions:
session
|> find(css(".user", count: 3))
|> List.first()
|> assert_has(css(".user-name", count: 1, text: "Ada"))There are several queries for common HTML elements defined in the Wallabidi.Query module: css, text_field, button, link, option, radio_button, and more. All actions accept a query. Actions will block until the query is either satisfied or the action times out. Blocking reduces race conditions when elements are added or removed dynamically.
Navigation
We can navigate directly to pages with visit:
visit(session, "/page.html")
visit(session, user_path(Endpoint, :index, 17))It's also possible to click links directly:
click(session, link("Page 1"))Finding
We can find a specific element or list of elements with find:
@user_form css(".user-form")
@name_field text_field("Name")
@email_field text_field("Email")
@save_button button("Save")
find(page, @user_form, fn(form) ->
form
|> fill_in(@name_field, with: "Chris")
|> fill_in(@email_field, with: "c@keathley.io")
|> click(@save_button)
end)Passing a callback to find will return the parent which makes it easy to chain find with other actions:
page
|> find(css(".users"), & assert has?(&1, css(".user", count: 3)))
|> click(link("Next Page"))Without the callback find returns the element. This provides a way to scope all future actions within an element.
page
|> find(css(".user-form"))
|> fill_in(text_field("Name"), with: "Chris")
|> fill_in(text_field("Email"), with: "c@keathley.io")
|> click(button("Save"))Interacting with forms
There are a few ways to interact with form elements on a page:
fill_in(session, text_field("First Name"), with: "Chris")
clear(session, text_field("last_name"))
click(session, option("Some option"))
click(session, radio_button("My Fancy Radio Button"))
click(session, button("Some Button"))If you need to send specific keys to an element, you can do that with send_keys:
send_keys(session, ["Example", "Text", :enter])Assertions
Wallabidi provides custom assertions to make writing tests easier:
assert_has(session, css(".signup-form"))
refute_has(session, css(".alert"))
has?(session, css(".user-edit-modal", visible: false))assert_has and refute_has both take a parent element as their first argument. They return that parent, making it easy to chain them together with other actions.
session
|> assert_has(css(".signup-form"))
|> fill_in(text_field("Email"), with: "c@keathley.io")
|> click(button("Sign up"))
|> refute_has(css(".error"))
|> assert_has(css(".alert", text: "Welcome!"))Testing LiveView optimistic UI
Default interactions auto-await the LiveView patch that follows the
action — your assertions see the post-reconcile DOM. To test
optimistic UI hooks (where the client paints state before the server
reply lands), use Wallabidi.LiveView:
session
|> visit("/counter")
|> Wallabidi.LiveView.with_latency(300, fn s ->
s
|> click(Query.button("Increment"), await: :defer)
|> assert_has(Query.css("#count", text: "1")) # optimistic
|> Wallabidi.LiveView.await_patch()
|> assert_has(Query.css("#count", text: "1")) # reconciled
end)with_latency/3 toggles LiveView's built-in latency simulator
(liveSocket.enableLatencySim) so the in-flight window is wide
enough to land an assert_has in. await: :defer skips the action's
auto-await; Wallabidi.LiveView.await_patch/2 drains the deferred
wait before the post-reconcile assertions.
The :defer opt is supported on click/3, fill_in/3, and
clear/3. On the in-process LV driver, latency helpers are no-ops
and :defer collapses to :auto — there's no round-trip to delay.
Window size
You can set the default window size by passing in the window_size option into Wallabidi.start_session/1.
Wallabidi.start_session(window_size: [width: 1280, height: 720])You can also resize the window and get the current window size during the test.
resize_window(session, 100, 100)
window_size(session)Screenshots
It's possible to take screenshots:
take_screenshot(session)All screenshots are saved to a screenshots directory in the directory that the tests were run in. You can customize this with configuration (see Configuration).
To automatically take screenshots on failure when using the Wallabidi.Feature.feature/3 macro:
# config/test.exs
config :wallabidi, screenshot_on_failure: trueJavaScript logging and errors
Wallabidi captures both JavaScript logs and errors. Any uncaught exceptions in JavaScript will be re-thrown in Elixir. This can be disabled by specifying js_errors: false in your Wallabidi config.
JavaScript logs are written to :stdio by default. This can be changed to any IO device by setting the :js_logger option in your Wallabidi config. For instance if you want to write all JavaScript console logs to a file you could do something like this:
{:ok, file} = File.open("browser_logs.log", [:write])
Application.put_env(:wallabidi, :js_logger, file)Logging can be disabled by setting :js_logger to nil.
Interacting with dialogs
Wallabidi provides several ways to interact with JavaScript dialogs such as window.alert, window.confirm and window.prompt.
- For
window.alertuseaccept_alert/2 - For
window.confirmuseaccept_confirm/2ordismiss_confirm/2 - For
window.promptuseaccept_prompt/2-3ordismiss_prompt/2
All of these take a function as last parameter, which must include the necessary interactions to trigger the dialog. For example:
alert_message = accept_alert session, fn(session) ->
click(session, link("Trigger alert"))
endTo emulate user input for a prompt, accept_prompt takes an optional parameter:
prompt_message = accept_prompt session, [with: "User input"], fn(session) ->
click(session, link("Trigger prompt"))
endsettle
Wait for the page to become idle. Checks two signals: no pending HTTP requests for the idle period, and no LiveView phx-*-loading classes present.
You don't need settle after click, fill_in, or visit — those already wait automatically. Use settle for updates triggered by something other than a direct interaction:
# PubSub broadcast — no browser interaction triggered it
Phoenix.PubSub.broadcast(MyApp.PubSub, "updates", :refresh)
session
|> settle()
|> assert_has(Query.css(".updated"))intercept_request
Mock HTTP responses in the browser:
session
|> intercept_request("/api/users", %{
status: 200,
headers: [%{name: "content-type", value: "application/json"}],
body: ~s({"users": []})
})
|> visit("/page")on_console
Stream browser console output:
session
|> on_console(fn level, message ->
IO.puts("[#{level}] #{message}")
end)