# ferridriver-test
[](https://crates.io/crates/ferridriver-test)
[](https://docs.rs/ferridriver-test)
[](https://github.com/salamaashoush/ferridriver)
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
```rust
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:
```rust
// tests/harness.rs
mod homepage;
mod login;
ferridriver_test::main!();
```
```toml
# Cargo.toml
[[test]]
name = "e2e"
path = "tests/harness.rs"
harness = false
[dev-dependencies]
ferridriver-test = "0.2"
```
```bash
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
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
```rust
#[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
| `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
```rust
#[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
-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