ferridriver-test 0.3.0

E2E test runner for ferridriver. Playwright-compatible API, parallel workers, auto-retrying expect, fixtures, snapshots.
Documentation

ferridriver-test

crates.io docs.rs License

Playwright-compatible test runner for Rust. Parallel workers, DAG-resolved fixtures, hooks, retries with flaky detection, auto-retrying assertions, text and pixel-diff snapshots, Playwright-compatible traces, and a wide set of reporters.

Quick start

use ferridriver_test::prelude::*;

#[ferritest]
async fn loads_homepage(ctx: TestContext) {
    let page = ctx.page().await?;
    page.goto("https://example.com", None).await?;
    expect(&page).to_have_title("Example Domain").await?;
}

#[ferritest(retries = 2, tag = "smoke", timeout = "30s")]
async fn login_flow(ctx: TestContext) {
    let page = ctx.page().await?;
    page.goto("https://app.example.com/login", None).await?;
    page.locator("#email").fill("user@example.com").await?;
    page.locator("button[type=submit]").click().await?;
    expect(&page).to_have_url("/dashboard").await?;
}

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.2"
cargo test --test e2e
cargo test --test e2e -- --headed --backend webkit -j 1

#[ferritest] attributes

retries = N              # per-test retry count
timeout = "30s"          # per-test timeout — duration string ("500ms", "30s", "5m")
tag = "smoke"            # tag for --tag filtering (repeatable)
skip                     # unconditional skip
skip = "firefox"         # conditional skip; condition is browser name, platform name,
                         # env var, "ci", or a "!" negation
skip = "firefox | flaky" # condition | reason
slow                     # 3x default timeout
slow = "ci"              # conditional slow
fixme                    # known broken (skipped, but reported separately from skip)
fixme = "webkit"         # conditional fixme
fail                     # expect failure: pass iff the body fails
fail = "linux"           # conditional expect-failure
only                     # isolate one test (--forbid-only catches strays in CI)
info = "JIRA-123"        # arbitrary metadata, attached to the test result
use_options = r#"{ ... }"# JSON overrides for launch / context options

Parameterized tests

#[ferritest_each(data = [
    ("https://example.com",   "Example Domain"),
    ("https://rust-lang.org", "Rust Programming Language"),
])]
async fn title_check(ctx: TestContext, case: (&str, &str)) {
    let page = ctx.page().await?;
    page.goto(case.0, None).await?;
    expect(&page).to_contain_title(case.1).await?;
}

Generates one test per row, named title_check (<row values>).

Built-in fixtures

Name Scope Type
browser Worker Arc<Browser>
context Test Arc<ContextRef>
page Test Arc<Page>
test_info Test Arc<TestInfo>

Resolution walks Global → Worker → Test. Workers reuse the browser; each test gets a fresh context and page. Custom fixtures register at the FixturePool level with a scope tag; dependencies form a DAG validated at startup.

Hooks

#[before_all]   async fn setup(ctx: TestContext) { ... }    // once per suite per worker
#[after_all]    async fn teardown(ctx: TestContext) { ... } // once per suite per worker (runs on failure)
#[before_each]  async fn auth(ctx: TestContext) { ... }     // before every test
#[after_each]   async fn dump(ctx: TestContext) { ... }     // after every test, even on failure

Expect matchers (38)

All matchers support .not, .with_timeout(Duration), .with_message(&str), .soft(). Auto-retry on the Playwright polling schedule [100, 250, 500, 1000, ...] ms up to expect_timeout (default 5000 ms).

Page (4): to_have_title, to_contain_title, to_have_url, to_contain_url.

Locator — visibility / state (10): to_be_visible, to_be_hidden, to_be_enabled, to_be_disabled, to_be_checked, to_be_editable, to_be_attached, to_be_empty, to_be_focused, to_be_in_viewport.

Locator — text / value (6): to_have_text, to_contain_text, to_have_value, to_have_values, to_have_texts, to_contain_texts.

Locator — attributes (9): to_have_attribute, to_have_class, to_contain_class, to_have_css, to_have_id, to_have_role, to_have_accessible_name, to_have_accessible_description, to_have_accessible_error_message.

Locator — other (5): to_have_js_property, to_have_count, to_match_snapshot, to_have_screenshot, to_match_aria_snapshot.

Poll / satisfy (4): to_equal, to_satisfy, to_pass, to_pass_with_options.

Reporters

Built-in reporter names (passed as [[test.reporter]] name = "..." or --reporter NAME[:OPTIONS] on the CLI):

terminal, progress, dot, json, junit, html, blob, allure, github, rerun, messages / ndjson (Cucumber Messages), usage, cucumber-json, empty.

Multiple reporters can run simultaneously — events fan out via a broadcast bus.

CLI flags (after --)

--headed                 Show browser window
--backend NAME           cdp-pipe | cdp-raw | webkit | bidi
--browser NAME           chromium | firefox | webkit (selects default backend)
-j N, --workers N        Parallel workers
--retries N              Retry failed tests
--timeout MS             Per-test timeout
--expect-timeout MS      Assertion timeout
-g PATTERN, --grep ...   Filter by test name (regex, case-insensitive)
--grep-invert PATTERN    Exclude tests matching pattern
--tag NAME               Filter by tag
--shard N/TOTAL          Run only shard N of TOTAL
--list                   List tests without running
--update-snapshots       Update snapshot files
--last-failed            Re-run only previously failed tests
--forbid-only            Fail if any #[ferritest(only)] is present
-c, --project NAME       Filter by project
--reporter SPEC          Reporter name (repeatable)
--profile NAME           Apply a named [test.profiles.NAME] preset

Architecture

The TestRunner::run() pipeline is the single execution path for #[ferritest], #[ferritest_each], BDD scenarios (via ferridriver-bdd), and any external translator.

TestPlan
  └── filter (shard, grep, tag, only, last-failed, forbid-only)
  └── validate fixture DAG
  └── run global setup
  └── Dispatcher (MPMC work-stealing channel)
        ├── Worker 0   Browser
        ├── Worker 1   Browser
        └── ...        (concurrent launch via tokio::join!)
  └── collect results (retry failed tests via re-enqueue)
  └── run global teardown
  └── exit code

Workers launch browsers concurrently (not serially) — overlapping launches save 80–100 ms per extra worker on warm machines.

License

MIT OR Apache-2.0