navree
Documentation

Browser testing

Drive a real Chrome from your Rust tests over the Chrome DevTools Protocol: visit pages, fill forms, press buttons and assert on what a user sees, with automatic waiting and a screenshot on every failure.

On this page

Introduction

HTTP tests check your API one request at a time. Some things only show up when a person uses the product: the sign-in form posts to the BFF, the BFF stores the tokens, the deal board loads, a drag between columns moves a deal. navree-browser tests those journeys from Rust, in the same cargo test run as everything else:

Rust
  • It drives Chrome, Chromium or Edge over the Chrome DevTools Protocol, headless by default. No WebDriver server, no Node.js in your test process.
  • Every action and assertion waits for the page, up to a timeout, so tests don't need sleeps.
  • A failing step saves a screenshot, the page's HTML and its console log, and the error says where.
  • It serves your Navree app from the test, on a real port, and starts your front end against it, so a test owns its data from the database up.

Installation

navree-browser is a test-only crate. Add it as a dev-dependency:

Cargo.toml

If your app depends on the navree facade, you can turn on its opt-in Featurebrowser feature in [dev-dependencies] instead and use navree::browser. Cargo only builds dev-dependency features for tests, so your app's binary doesn't change:

Cargo.toml

It needs Chrome, Chromium or Edge at test time. It uses NAVREE_BROWSER_CHROME when set, then looks, in order, for Chrome and Chromium installed on the machine, Chrome for Testing in Navree's cache, Playwright's Chromium (ms-playwright, or PLAYWRIGHT_BROWSERS_PATH), and Microsoft Edge. On a machine without any of them, install a pinned Chrome for Testing build into Navree's cache:

Shell
Text

The cache is ~/.cache/navree on Linux (or $XDG_CACHE_HOME/navree), ~/Library/Caches/navree on macOS and %LOCALAPPDATA%\navree on Windows; NAVREE_CACHE_DIR moves it, for example to a folder your CI caches. navree browser:install --version <version> installs another Chrome for Testing version, --force downloads again, and NAVREE_BROWSER_DOWNLOAD_URL points the download at a mirror. On Linux, Chrome itself needs the usual shared libraries (libnss3, libgbm1, ...), which desktop installs and CI images with Chrome already have.

Put browser tests in a test file of their own, such as tests/browser.rs. They take seconds rather than milliseconds, so you can run them on their own with cargo test --test browser, and skip them with cargo test --lib --test api while you work.

A first test

This test boots the CRM API with its demo accounts, starts the CRM's web front end against it, and signs in as a sales rep:

apps/crm-api/tests/browser.rs

Browser::launch starts headless Chrome, or joins the one already running for the tests of the binary that run at the same time, and gives each test its own browser context: separate cookies, storage and cache, so tests can't see each other's sessions. The context closes when browser and its pages are dropped, and Chrome stops when the last browser of the binary is dropped, so no browser outlives the tests. Run browser tests in a Tokio runtime (#[tokio::test]).

Skipping without a browser

Browser::launch fails with an error that says where it looked when there is no browser on the machine. To skip browser tests there instead, check first:

Rust

Browser::find_chrome() returns the binary launch would start, and BrowserError::is_chrome_not_found() tells this error from the others.

Serving your app

Your API

A browser can't call an in-process TestApp; it needs a port. TestServer::start serves an app's routes on 127.0.0.1 with a free port, until it is dropped:

Rust

The server runs the app's real router (build_router), with every layer, so CORS, cookies, CSRF and rate limits behave as in production. It runs on the test's own runtime, and server.addr() gives its address. Pages Navree renders itself, from views, can be visited directly at server.url(..).

Your front end

Most Navree apps have a separate web front end with a BFF in front of the API. FrontEnd starts it and points it at the test's API:

MethodMeaning
FrontEnd::new(name)A front end, identified by name within the test binary (every serve of that name shares one process, started with the settings of the first)
.command(program, args)How to start it: a program on PATH (on Windows also .cmd shims such as pnpm), run in the test's directory
.url(base)Where it listens; serve waits until this URL answers
.api_env(var)The environment variable that tells it where the API is
.env(var, value)Any other environment variable
.timeout(duration)How long to wait for it to start (default 60 s)
.serve(ctx)Starts it if needed, routes its API calls to this app, and returns a WebSession

The front end process starts on the first serve. It is given the address of a small proxy that navree-browser runs, and each serve starts a TestServer for the calling test's app and points that proxy at it. A WebSession holds the front end until it is dropped, so browser tests that share a front end run one after another, each against its own fresh database, while the rest of your tests keep running in parallel. web.url(path) is a URL of the front end and web.server() the test's TestServer. The proxy forwards HTTP requests, which is what a BFF sends; WebSocket upgrades don't go through it.

The process keeps running while any test of the binary uses it or waits for its turn; when the last WebSession is dropped, it is stopped together with every process it started. If something already answers at the front end's URL before it starts, serve fails rather than test against a stranger, and a front end that exits or never answers fails serve with the end of its output.

Build the front end before the tests run (pnpm --dir apps/crm-web build). Starting a development server works too, but its first page loads compile on demand and can take longer than the default timeouts.

Interacting with pages

Finding elements

Actions and assertions take a target string, resolved in this order:

TargetFinds
"#deal-title", ".board .card", "[name=email]", "css=main h1"A CSS selector: anything starting with #, . or [, or prefixed with css=
"@deal-card"The element with data-testid="deal-card"
"Email"For fields: the input whose label, aria-label, aria-labelledby, placeholder or name is Email. For buttons and links: the one whose text or accessible name is Email. Otherwise any element whose text is Email.

Text and labels match exactly after trimming and collapsing whitespace, case included. Only visible elements match (a file input or a checkbox hidden behind its label can still be attached to or checked). When a target matches several elements, actions use the first visible one. Narrow it with Target, or search inside one element with within:

Rust

Every method that takes a target accepts a &str, a String or a Target. Target::nth(i) picks one match by position. within waits for its target and returns an Element with the same methods as Page, looking inside it; an element is found again each time it is used, so it survives re-renders.

Prefer labels and button text: they are what users see, and a test that finds "Sign in" breaks when the button's text changes, which is when it should. Add data-testid attributes for elements without visible text.

Actions

Rust

Each action waits for its target to be visible, enabled, not covered by another element and not moving, then scrolls it into view and acts like a user: press and click dispatch real mouse events at the element's center, type_into sends key events one character at a time (characters a US keyboard has no key for are inserted as text). A target that never becomes ready fails the action after the timeout, and the error says why: "Save" is disabled, "Save" is covered by <div class="overlay">.

A few actions have more to them:

  • select sets a native <select> by an option's text or value. A custom select (a combobox button, as UI kits build them) is opened with a click, then the option with that name is clicked.
  • check and uncheck work on checkboxes, switches and role="checkbox" elements; when the input itself is hidden, its label is clicked. Unchecking a radio button is an error.
  • type_into on a date, time, color or range input sets its value, with input and change events.
  • drag presses the source (beside any link inside it), moves to the middle of the target's visible part in steps, pauses, and releases. Pointer-based drag and drop (dnd-kit and the like) and native HTML drag and drop both work.
  • alert, confirm and prompt dialogs are accepted, and logged in the console log.

Assertions

Rust

Assertions retry until they pass or the timeout ends: assert_see("Deal saved") passes as soon as the toast appears, and assert_missing("@spinner") as soon as loading finishes. assert_see looks at visible text only; text in hidden elements doesn't count. assert_count counts visible matches, and assert_value on a select accepts the option's value or its text.

assert_no_console_errors is a check, not a wait: it fails if the page has logged a console.error or thrown an uncaught exception since it opened. Failed network requests are written to the console log but don't fail it, since apps often expect some (a 401 before signing in).

Waiting

Because every action and assertion waits, explicit waits are rare. For the cases that need them:

Rust

wait_for_url and wait_for_request match a glob (* matches any characters) against the URL's path, its path and query, or the whole URL. wait_for_request waits until the response has arrived and returns a PageRequest (method, url, status, failure). Requests made by earlier steps count, so call it after the action that makes the request; each request satisfies one wait.

The timeout is 5 seconds per step, set with NAVREE_BROWSER_TIMEOUT_MS, Browser::builder().timeout(..), or per call with page.with_timeout(Duration::from_secs(20)).assert_see("Export ready"). Navigations (visit, back, refresh) wait for the page's load event for up to 30 seconds, or the timeout if it is longer.

Reading the page

Rust

all returns the visible matches now, without waiting for one; element.inner_text() reads an element's own text. script runs a function body in the page, awaits it and deserializes its return value. Reach for it to read state, not to drive the page: a test that clicks through script skips what the user would see. A Cookie has the cookie's name, value, domain, path, expires, http_only, secure and same_site, including HttpOnly cookies scripts can't see.

Screenshots and failures

When an action or an assertion fails, navree-browser saves three files in a folder named after the test, under navree-browser in Cargo's target directory, and puts their paths in the error:

Text

Paths inside the current directory are shown relative to it, others in full. The console log holds the page's console messages, uncaught exceptions, dialogs and failed network requests, which usually explain a blank page faster than the screenshot. Files are numbered in the order a test saves them; a test in a module gets a folder per module (target/navree-browser/deals/a_rep_moves_a_deal/), and each run starts the test's folder afresh. Take screenshots on purpose with page.screenshot("board-after-move").await?, and full-page ones with page.screenshot_full_page(..); both return the file's path.

To watch a test run, show the window and slow it down:

Shell

Phones and other viewports

The default window is 1440 by 900. Test the phone layout with a preset or your own size:

Rust

Viewport::phone() (390 by 844, pixel ratio 3), Viewport::tablet() (820 by 1180, pixel ratio 2) and Viewport::desktop() (1440 by 900) set the size, the device pixel ratio, touch support and the user agent; phones and tablets send Chrome's Android user agent. Viewport::new(width, height) is a desktop viewport of any size, and .with_size(..), .with_scale(..), .with_touch(..) and .with_user_agent(..) adjust any of them. Headless Chrome calls itself HeadlessChrome in its user agent; pages see Chrome, as in production.

A complete journey

Drag a deal to Won and check that the API, not only the page, agrees:

apps/crm-api/tests/browser.rs

The test seeds data through the API with crm.login_as, drives the browser, then checks the result through the API again: the browser proves the journey works, the API proves it stuck. The board updates the card before its request to the API returns, so the test waits for that request before asking the API.

Running in CI

GitHub's Ubuntu runners include Chrome, so browser tests need the front end built and nothing else. Upload the failure files when a test fails:

.github/workflows/browser.yml

On a runner without Chrome, add navree browser:install before the tests, or set NAVREE_BROWSER_CHROME to the browser your image provides. On Linux, Chrome starts with --no-sandbox, since containers and recent Ubuntu runners can't start its sandbox; the tests load your own app's pages.

Reference

Environment variableDefaultMeaning
NAVREE_BROWSER_CHROMEfound on the machineThe Chrome, Chromium or Edge binary
NAVREE_BROWSER_HEADLESStruefalse shows the window
NAVREE_BROWSER_SLOW_MO0Milliseconds to pause after each action
NAVREE_BROWSER_TIMEOUT_MS5000How long each action and assertion waits
NAVREE_BROWSER_ARTIFACTSnavree-browser in Cargo's target directoryWhere screenshots and failure files go
NAVREE_CACHE_DIRthe platform's cache folderWhere navree browser:install puts Chrome for Testing, and where navree-browser looks for it
NAVREE_BROWSER_DOWNLOAD_URLChrome for Testing's storageA mirror for navree browser:install

These are environment variables of the test run, not app configuration, so they use the NAVREE_ prefix rather than APP_. Invalid values fail Browser::launch with an error naming the variable. Browser::builder() sets the same options in code, and each one overrides its variable: .headless(false), .chrome(path), .slow_mo(ms), .timeout(duration), .window(1440, 900), .artifacts(dir), then .launch().

Type
TargetA target with filters: from(str), containing(text), nth(i)
Browserlaunch(), builder(), find_chrome(), visit(url), visit_with(url, viewport), new_page(), version()
PageThe actions, assertions, waits and reads above, screenshot(name), screenshot_full_page(name) and with_timeout(duration); within(target) returns a scoped Element
ElementThe same methods as Page, looking inside the element, and inner_text()
TestServerstart(ctx), url(path), addr()
FrontEnd, WebSessionThe front end and a test's turn with it: url(path), server()
Viewportphone(), tablet(), desktop(), new(width, height), with_size, with_scale, with_touch, with_user_agent
Cookie, PageRequestWhat cookie(name) and wait_for_request(glob) return
BrowserErrorWhat failed, where, and the paths of the failure files: step(), files(), is_chrome_not_found()