Wallaby Documentation

repository·main·Indexed 23 days ago

https://github.com/elixir-wallaby/wallaby

An Elixir library for end-to-end web testing that simulates realistic user interactions using real browsers. Wallaby supports concurrent testing, multiple user sessions, and integrates with Chrome and Selenium. It provides a declarative API using Queries and Actions to interact with the DOM, along with specialized support for Phoenix, Ecto, and LiveView applications.

Tokens
12.2K
Snippets
30
Records
76
Agent score
82%

What's inside Wallaby

  1. How Queries and Actions work together

    main

    Wallaby's API is built on two core concepts: Queries and Actions.

    • Queries are declarative descriptions of elements you want to interact with (e.g., css(".user", count: 3) or text_field("Name")).
    • Actions use these queries to perform operations on the DOM (e.g., click/2, fill_in/3).

    Key Behavior: Actions will block until the query is satisfied or the action times out. This built-in blocking helps reduce race conditions when elements are added or removed dynamically by asynchronous JavaScript.

    Example of combining queries and actions:

    session
    |> find(css(".user", count: 3))
    |> List.first
    |> assert_has(css(".user-name", count: 1, text: "Ada"))
  2. Configure LiveView for Wallaby testing

    main

    To test Phoenix LiveView, you must ensure the Ecto sandbox is allowed within the LiveView process. You can do this by using the on_mount lifecycle hook in your router to call a helper function that retrieves the user_agent from the socket and calls Phoenix.Ecto.SQL.Sandbox.allow/2.

    # In your router
    live_session :default, on_mount: MyApp.Hooks.AllowEctoSandbox do
      # ...
    end
    
    # In your hooks module
    defmodule MyApp.Hooks.AllowEctoSandbox do
      import Phoenix.LiveView
      import Phoenix.Component
    
      def on_mount(:default, _params, _session, socket) do
        allow_ecto_sandbox(socket)
        {:cont, socket}
      end
    
      defp allow_ecto_sandbox(socket) do
        %{assigns: %{phoenix_ecto_sandbox: metadata}} = 
          assign_new(socket, :phoenix_ecto_sandbox, fn ->
            if connected?(socket), do: get_connect_info(socket, :user_agent)
          end)
    
        Phoenix.Ecto.SQL.Sandbox.allow(metadata, Application.get_env(:your_app, :sandbox))
      end
    end
  3. Configure Wallaby for Phoenix and Ecto

    main

    When testing Phoenix applications, several configuration steps are required to ensure the browser and the test process can share the same database state:

    1. Enable Phoenix Server: Set server: true for your Endpoint in config/test.exs.
    2. Set Base URL: In test/test_helper.exs, set the :base_url so Wallaby can resolve relative paths.
    3. Ecto Sandbox: Add Phoenix.Ecto.SQL.Sandbox as a plug at the top of your Endpoint in endpoint.ex. This allows the database connection to be shared between the test process and the browser process.
    4. User Agent: Ensure user_agent is passed in connect_info in your LiveView socket configuration to wire up the session correctly.
    5. OTP App: If using Wallaby.Feature with Ecto, configure config :wallaby, otp_app: :your_app.
    # config/test.exs
    config :your_app, YourAppWeb.Endpoint, server: true
    config :your_app, :sandbox, Ecto.Adapters.SQL.Sandbox
    
    # test/test_helper.exs
    Application.put_env(:wallaby, :base_url, YourAppWeb.Endpoint.url)
    
    # lib/your_app_web/endpoint.ex
    defmodule YourAppWeb.Endpoint do
      use Phoenix.Endpoint, otp_app: :your_app
    
      if Application.compile_env(:your_app, :sandbox, false) do
        plug Phoenix.Ecto.SQL.Sandbox
      end
      # ...
    end
  4. Enable WebAuthn Virtual Authenticator (Chrome only)

    main

    To test Passkeys in Chrome, you must enable the WebAuthn Virtual Authenticator via Chromedriver. This allows Chrome to automatically present a virtual Passkey when create() or get() APIs are called.

    {:ok, _result} =
      Wallaby.HTTPClient.request(:post, "#{session.url}/chromium/send_command_and_get_result", %{
        cmd: "WebAuthn.enable",
        params: %
      })
    
    {:ok, result} =
      Wallaby.HTTPClient.request(:post, "#{session.url}/chromium/send_command_and_get_result", %{
        cmd: "WebAuthn.addVirtualAuthenticator",
        params: %{
          options: %{
            protocol: "ctap2",
            transport: "internal",
            hasResidentKey: true,
            hasUserVerification: true,
            isUserVerified: true,
            automaticPresenceSimulation: true
          }
        }
      })
  5. Install Wallaby

    main

    Add wallaby to your mix.exs dependencies. It is recommended to set runtime: false and restrict it to the :test environment.

    After adding the dependency, you must configure a driver (Chrome is the default) and ensure the corresponding browser driver (like chromedriver or selenium) is installed on your system. Finally, ensure the :wallaby application is started in your test_helper.exs.

    # mix.exs
    def deps do
      [
        {:wallaby, "~> 0.30", runtime: false, only: :test}
      ]
    end
    
    # config/config.exs
    # Chrome (default)
    config :wallaby, driver: Wallaby.Chrome
    
    # Selenium
    config :wallaby, driver: Wallaby.Selenium
    
    # test/test_helper.exs
    {:ok, _} = Application.ensure_all_started(:wallaby)
  6. Use the Wallaby Query DSL to locate elements

    main

    Wallaby provides a Domain Specific Language (DSL) for locating DOM elements using CSS, XPath, or specialized semantic finders. You can create queries using direct selectors or high-level functions that decouple your tests from presentation details like CSS classes.

    Basic Selectors

    • Query.css(".selector"): Locate elements via CSS.
    • Query.xpath(".//xpath"): Locate elements via XPath.

    Semantic Form Finders

    These finders allow you to locate form elements by their id, name, placeholder, or the text of their associated <label>:

    • Query.text_field(selector)
    • Query.checkbox(selector)
    • Query.radio_button(selector)
    • Query.select(selector)
    • Query.file_field(selector)
    • Query.button(selector)
    • Query.link(selector)
    • Query.option(selector)
    Query.css(".some-css")
    Query.xpath(".//input")
    Query.text_field("My Name")
  7. Understand query validation and error types

    main

    When executing queries via Wallaby.Browser, the engine performs several validations (visibility, text matching, selection state, and count). If a query fails to find elements matching your criteria, it may return specific error tuples:

    • {:error, {:not_found, elements}}: Returned when the number of elements found does not match the expected count or the at index is out of bounds.
    • {:error, :stale_reference}: Returned when a StaleReferenceError occurs during execution (common in dynamic web pages where elements are removed/replaced).

    Additionally, specific HTML validation errors can occur if configured:

    • :button_with_bad_type: When a button query finds exactly one button but it has an invalid type.
    • :label_with_no_for: When a label query is used but the label lacks a for attribute.
    • :label_does_not_find_field: When a label has a for attribute that does not match any element ID on the page.
  8. Configure JavaScript logging and errors

    main

    Wallaby captures JavaScript logs and errors. Uncaught JS exceptions are re-thrown as Elixir errors.

    • Disable JS errors: Set js_errors: false in the Wallaby config.
    • Configure JS logging: Use the :js_logger option to specify an IO device. By default, logs are written to :stdio.

    To log to a file:

    {:ok, file} = File.open("browser_logs.log", [:write])
    Application.put_env(:wallaby, :js_logger, file)

    To disable logging entirely, set :js_logger to nil.

  9. Upload local files using send_keys/2

    main

    The Selenium driver extends send_keys/2 to support file uploads. If the list of keys passed to send_keys contains a path to a local file, Wallaby will automatically:

    1. Zip the local file.
    2. Base64 encode it.
    3. Upload it to the Selenium server via the /file endpoint.
    4. Set the remote file path as the input's value.

    This allows you to interact with <input type="file"> elements by passing the local file path in the keys list.

  10. Interact with elements using Wallaby.Element

    main

    The Wallaby.Element module provides functions to interact with elements found on a page. These functions are typically used within a find/2 block to perform actions on a specific element.

    Important: Retrying and Stale Elements

    Unlike the Browser module, actions in Wallaby.Element do not retry if an element becomes stale. If an element is stale, a Wallaby.StaleReferenceError will be raised immediately.

    page
    |> find(Query.css(".some-element"), fn(element) -> Element.click(element) end)
  11. Configure hackney options for timeouts

    main

    Wallaby uses hackney for HTTP requests. You can control request and receive timeouts by providing :hackney_options in your configuration.

    config :wallaby,
      hackney_options: [timeout: :infinity, recv_timeout: :infinity]
    
    # Or override a specific value
    config :wallaby,
      hackney_options: [timeout: 5_000]
    config :wallaby,
      hackney_options: [timeout: :infinity, recv_timeout: :infinity]
    
    # Overriding a value
    config :wallaby,
      hackney_options: [timeout: 5_000]
  12. How multiple sessions work for concurrent testing

    main

    Because each session runs in its own isolated browser, you can create multiple sessions within a single test to simulate interactions between different users or entities. This is useful for testing real-time features like chat or shared state updates.

    @message_field Query.text_field("Share Message")
    @share_button Query.button("Share")
    @message_list Query.css(".messages")
    
    test "That multiple sessions work" do
        {:ok, user1} = Wallaby.start_session()
        user1
        |> visit("/page.html")
        |> fill_in(@message_field, with: "Hello there!")
        |> click(@share_button)
    
        {:ok, user2} = Wallaby.start_session()
        user2
        |> visit("/page.html")
        |> fill_in(@message_field, with: "Hello yourself")
        |> click(@share_button)
    
        assert user1 |> find(@message_list) |> List.last |> text == "Hello yourself"
        assert user2 |> find(@message_list) |> List.first |> text == "Hello there"
    end