Parallelism and isolation
ferridriver's test runner is built around two hard rules:
- Every test gets a fresh
BrowserContext— storage, cookies, permissions, and network state are isolated. - Every worker owns exactly one
Browser— one browser process per worker, for the whole run.
Everything else in the execution model follows from those two rules.
Worker model
Worker boot. All N workers launch their browsers concurrently
using tokio::join!, not sequentially. On a warm machine this saves
80–100 ms per additional worker because browser startup overlaps.
Dispatch. The dispatcher is an unbounded MPMC channel. Each test is enqueued once. Workers pull work as they finish; fast workers naturally pick up more. No thread-pool hashing, no per-worker queues to balance.
Teardown. Contexts close after each test (with optional screenshot on failure). The browser stays alive for the worker's entire run — browser launches are the most expensive thing you can do, so the model amortizes them.
Configuring worker count
CLI flag: -j N / --workers N. Defaults to the logical CPU count.
Under a CI runner with a fixed CPU budget, pin this. Letting the runner auto-scale on a shared host leads to unpredictable timings.
Test isolation
Every #[ferritest] body receives a TestContext whose page() /
browser_context() / browser() are cached fixtures:
browser— worker-scoped, shared across all tests on this worker.context— test-scoped, created fresh for this test, torn down at end.page— test-scoped, opened in the fresh context.
This means:
- Cookies and localStorage from test A cannot leak into test B, even on the same worker.
- A failing
before_eachin test A does not poison test B's state — its context was never created. - You can
context.add_cookies(...)inside the test body with no cleanup logic; the context goes away when the test finishes.
Parallel vs serial suites
By default all tests are fully parallel: the dispatcher treats every test as an independent work item.
Mark a suite serial when tests share external state (database rows,
file locks, a specific login session) that cannot be isolated per-test:
A serial suite is enqueued as a single WorkItem::Serial — one worker
grabs the whole batch, runs tests in source order, and skips the rest
on first failure. The other workers keep processing parallel tests
from the queue.
Sharding for CI
--shard N/M splits the test list into M roughly-equal shards and
runs only shard N. The split is deterministic given a stable test
discovery order (it hashes the full test name with FxHasher).
Combine with JUnit output and a CI test-report merger (e.g. GitHub's built-in one) to aggregate results.
Retries
A failing test is re-enqueued as WorkItem::Single — any worker can
pick it up, not necessarily the one that failed.
RetryPolicy::final_status determines the outcome:
This separation matters for flake detection: a Flaky test is not a
regression, but it's also not silent — reporters surface the retry
history so you can decide whether to investigate or quarantine.
Practical guidance
- Start with
workers = 4. Four is almost always faster than one. Beyond 4, you start thrashing I/O and RAM on most laptops and small CI runners. - If tests are flaky at
workers = 8but stable atworkers = 4, you have a hidden shared-state dependency (a DB row, a localStorage key, a login session). Find it; don't just lower the worker count. - Use
serialsparingly. It is the single most expensive escape hatch. Prefer per-test fixtures that isolate the shared state. --retries 2in CI is fine. Anything higher is a smell.