Rust tests

Write tests with #[ferritest]; generate a harness with ferridriver_test::main!(); run with cargo test. A complete runnable project lives at examples/rust-e2e in the repository.

Project layout

my-project/
├── Cargo.toml
├── ferridriver.toml         # optional, auto-discovered
└── tests/
    ├── harness.rs           # main!() — one per test binary
    ├── homepage.rs
    └── login.rs

Harness

// tests/harness.rs
mod homepage;
mod login;
ferridriver_test::main!();
# Cargo.toml
[[test]]
name = "e2e"
path = "tests/harness.rs"
harness = false

[dev-dependencies]
ferridriver-test = "0.5"

Tests and fixture parameters

#[ferritest] functions declare what they need as parameters: Arc<T> parameters resolve the fixture named after the parameter, and a TestContext parameter gives dynamic access (ctx.get::<T>("name")).

// tests/homepage.rs
use ferridriver_test::prelude::*;

#[ferritest]
async fn loads_homepage(page: Arc<Page>) {
    page.goto("https://example.com").await?;
    expect(&page).to_have_title("Example Domain").await?;
}

#[ferritest(retries = 2, tag = "smoke")]
async fn login_flow(page: Arc<Page>, request: Arc<HttpClient>) {
    request.post("/api/seed").await?;
    page.goto("https://app.example.com/login").await?;
    page.locator("#email").fill("user@example.com").await?;
    page.locator("button[type=submit]").click().await?;
    expect(&page).to_have_url("/dashboard").await?;
}

Built-in fixtures: page: Arc<Page> (fresh per test), context: Arc<BrowserContext>, browser: Arc<Browser> (worker-shared), request: Arc<HttpClient> (worker-shared, picks up baseURL), test_info: Arc<TestInfo>.

The generated wrapper returns Result<(), TestFailure> and propagates ? — the body can use ? freely on anything that converts into TestFailure. Plain assert! / assert_eq! / panics are caught and reported as that test's failure, with the panic's backtrace attached.

Driving the browser

Every option-taking operation is a deferred action: await it directly for defaults, or chain option setters first — no None arguments, no option structs at call sites.

page.goto(url).wait_until(LoadState::NetworkIdle).timeout(Duration::from_secs(10)).await?;
page.get_by_role("button").name("Save").click().await?;
locator.click().button(MouseButton::Right).position((8.0, 8.0)).await?;
let png = page.screenshot().full_page(true).await?;

// Typed evaluate: serde on both sides.
let count: u32 = page.eval("() => document.images.length").await?;

// Event waits arm before the action runs — no subscribe race.
let (download, ()) = page.expect_download(|| page.click("#export")).await?;

Custom fixtures

#[fixture] registers a scoped, dependency-injected fixture; teardown is declared on the returned value:

#[fixture(scope = "worker")]
async fn db(_ctx: TestContext) -> ferridriver_test::Result<Fixture<DbHandle>> {
    let db = DbHandle::connect().await?;
    Ok(Fixture::new(db).on_teardown(|db| async move { db.drop_schema().await; }))
}

#[ferritest]
async fn lists_users(page: Arc<Page>, db: Arc<DbHandle>) { /* ... */ }

Run

cargo test --test e2e
cargo test --test e2e -- --backend webkit -j 1
cargo test --test e2e -- -g smoke --retries 2
ferridriver test --headless --grep smoke     # forwards flags as FERRITEST_* env vars

Tests run headed by default; pass --headless to opt into headless mode. Under cargo-nextest a harness = false binary appears as a single test; filter inside the harness with --grep / FERRITEST_GREP instead.

#[ferritest] attributes

retries = N             # per-test retry count
timeout = "30s"         # per-test timeout — "500ms", "30s", "5m", ...
tag     = "smoke"       # tag for --tag filtering (repeatable)

skip                    # unconditional skip
skip    = "firefox"     # conditional — browser name, platform name,
                        #   env var name, "ci", or "!" prefix
skip    = "firefox | flaky on Firefox"  # condition | reason
slow                    # 3x default timeout
slow    = "ci"          # conditional slow
fixme                   # known broken (skipped, reported separately)
fixme   = "webkit"      # conditional fixme
fail                    # expected failure (pass iff body fails)
fail    = "linux"       # conditional expected failure
only                    # isolate one test (--forbid-only catches strays in CI)
info    = "JIRA-123"    # arbitrary metadata

Context overrides use structured keys (Playwright's test.use):

viewport = "390x844"    locale = "de-DE"       color_scheme = "dark"
timezone_id = "..."     user_agent = "..."     device_scale_factor = 2.0
is_mobile  has_touch  offline  java_script_enabled = false  bypass_csp
accept_downloads  ignore_https_errors
use_options = r#"{...}"#   # raw-JSON escape hatch for the rest

Parameterized tests

#[ferritest_each(data = [
    ("https://example.com",   "Example Domain"),
    ("https://rust-lang.org", "Rust Programming Language"),
], names = ["example.com", "rust-lang.org"])]
async fn title_check(page: Arc<Page>, url: &str, title: &str) {
    page.goto(url).await?;
    expect(&page).to_contain_title(title).await?;
}

Generates one test per row — named after the names entry, or the row values when names is omitted. Fixture parameters come first; the remaining parameters receive the tuple elements. Every #[ferritest] attribute is accepted and applies to all rows.