How to Use cargo-nextest for Faster Test Runs

cargo-nextest is a drop-in replacement for cargo test that runs each test in its own process for true isolation, supports per-test timeouts, and is usually faster on big suites.

When cargo test starts feeling slow

You hit save. You run your test suite. You watch a wall of green dots scroll by for forty-five seconds. Then one test hangs. The dots stop. You wait another minute. Nothing happens. You kill the process, scroll up through a tangled mess of interleaved stdout, and try to guess which assertion actually failed. This is the exact moment cargo test stops being a convenience and starts being a bottleneck.

The built-in test runner packs every test inside a single binary and runs them as threads. Threads share memory and process resources. If one thread panics, segfaults, or deadlocks, it can take the whole binary down. Output gets captured at the binary level, so failing tests often dump their logs into a shared buffer that mixes with neighboring tests. You spend more time parsing terminal noise than fixing code.

cargo-nextest changes the architecture. It launches each test in its own subprocess. Think of it like moving from a shared office desk to individual soundproof booths. One person can scream, spill coffee, or fall asleep without affecting anyone else. The runner schedules these booths across your CPU cores, collects the results, and prints a clean report. The tradeoff is process creation overhead, but for any test that does real work, the fork-exec cost is negligible. The isolation pays for itself. Stop fighting the terminal output and let the runner handle the heavy lifting.

Installing the runner

You install cargo-nextest like any other cargo subcommand. The project publishes precompiled binaries, but cargo install works universally and keeps your toolchain consistent.

# Fetches the crate source and compiles it locally.
# The --locked flag forces cargo to use the exact dependency versions
# shipped in the crate's Cargo.lock, preventing unexpected breakage.
cargo install cargo-nextest --locked

Convention aside: the Rust community treats --locked as standard practice for toolchain installs. It guarantees you get the exact version the maintainers tested, even if upstream dependencies release breaking updates. Skip it only if you are actively developing nextest itself.

Verify the installation after the build finishes:

# Prints the installed version. Fails with "no such subcommand" if
# ~/.cargo/bin is missing from your shell PATH.
cargo nextest --version

If you are setting up a CI runner, grab the precompiled binary from the project site instead. Compiling nextest from source takes two to four minutes on most machines. The precompiled download takes three seconds. Save the wall-clock time and route it toward your actual test suite.

How the subprocess model actually works

You do not need to rewrite your tests. The #[test] attribute works exactly the same. Here is a standard test file:

// Standard unit test. nextest discovers this automatically during binary scan.
#[test]
fn addition_works() {
    assert_eq!(2 + 2, 4);
}

// Panic test. nextest catches the panic inside the subprocess
// and marks it as a failure without crashing the runner.
#[test]
#[should_panic(expected = "divide by zero")]
fn divide_by_zero_panics() {
    let _ = 1 / 0;
}

Run it with a single command swap:

# Replaces cargo test. Spawns a subprocess per test and streams results live.
cargo nextest run

The terminal shows a live progress bar. Passing tests tick off instantly. Failing tests print their name, their exact stdout/stderr, and the panic location. No scrolling. No guessing.

When you run cargo nextest run, the tool first compiles your test binaries using the standard cargo pipeline. It then scans the binaries to discover every test function. Instead of linking them into a single executable and running it, nextest extracts the test metadata and launches each one as a separate process. This architecture enables per-test timeouts. You set a limit in your config file. If a test crosses that threshold, nextest sends a termination signal to that specific process. The rest of the suite keeps running.

Output handling changes completely. Since each process has its own stdout pipe, nextest buffers the output and prints it only when the test finishes. This prevents the interleaved log salad that happens when multiple threads print to the same terminal simultaneously. You get a clean, chronological report that matches the order tests actually completed. The runner also tracks wall-clock time per test, so you can identify slow outliers without manual profiling. Treat the output buffer as a safety net. It keeps your terminal readable when things break.

Configuration and filtering in practice

Real projects need more than a bare run command. You will filter tests, adjust timeouts, and tune parallelism. nextest reads configuration from .config/nextest.toml at your workspace root.

# Base profile for local development. Sets a 30-second warning threshold
# and kills tests that run longer than 60 seconds total.
[profile.default]
slow-timeout = { period = "30s", terminate-after = 2 }
test-threads = "num-cpus"
retries = 0

# CI profile overrides. Increases timeout tolerance and enables automatic
# retries for flaky network or timing-dependent tests.
[profile.ci]
slow-timeout = { period = "60s", terminate-after = 3 }
retries = 2
fail-fast = false

Activate the CI profile in your pipeline:

# Loads [profile.ci] settings. Runs the full suite with retry logic enabled.
cargo nextest run --profile ci

The terminate-after multiplier controls the kill threshold. A test gets a warning after period seconds. If it survives terminate-after warnings, nextest kills it. With period = "30s" and terminate-after = 2, the test gets warned at thirty seconds and terminated at sixty. Convention aside: most teams set terminate-after to 2 or 3. Anything higher masks real deadlocks. Anything lower triggers false positives on slow CI runners.

Filtering works through command-line arguments or expression syntax. Pass a bare string to match test names. Use -E for precise control over packages, binaries, and test kinds.

# Runs only tests containing "auth" in their name.
cargo nextest run auth

# Runs a single exact test using nextest's filter expression language.
cargo nextest run -E 'test(=tests::login::valid_credentials)'

# Forwards stdout/stderr live instead of buffering. Equivalent to --nocapture.
RUST_LOG=debug cargo nextest run my_test_name --no-capture

The --no-capture flag is a community standard for debugging. When you are chasing a race condition or a missing log line, live output beats buffered summaries. Keep it off for normal runs to preserve clean terminal output. The -E expression language supports kind(test), binary(my_integration_tests), and package(my-crate). Combine them with & and | for complex workspace queries. Master the filter syntax early. It saves hours when debugging large monorepos.

Common pitfalls and how to avoid them

Switching test runners exposes hidden assumptions in your codebase. The most common trap is shared global state. Because each test runs in a fresh process, lazy-initialized singletons reset on every invocation. If a test accidentally depends on a global counter or a cached database connection from a previous run, it will fail under nextest. Treat this as a test design flaw. Isolate your fixtures or use process-scoped setup.

Port and file conflicts surface immediately. The threaded runner sometimes masks collisions because tests run sequentially on the same thread or share a process-wide socket table. Subprocesses get fresh OS resources, so two tests binding to 127.0.0.1:8080 will collide hard. Bind to 127.0.0.1:0 and let the OS assign a free port. Pass the resolved address into your test logic.

Doctests do not run. nextest only handles compiled test binaries. Documentation examples inside /// comments still require cargo test --doc. Most teams run both commands in sequence on CI. The runner will print a friendly warning if it detects doctests in your workspace. Ignore the warning and run the doc command separately.

Unexpected timeouts usually mean one of two things. Your timeout threshold is too aggressive for a legitimately slow integration test, or your test is actually deadlocking. Check the slow-timeout.period value first. If the test is genuinely hanging, the timeout just saved you from waiting forever. Audit your slowest tests after the first week. They usually reveal missing awaits or unbounded loops.

When to reach for it vs alternatives

Use cargo nextest run as your default local test command when your suite contains more than a dozen tests. Use cargo nextest run --profile ci in your pipeline when you need automatic retries, longer timeouts, and deterministic output ordering. Use cargo test --doc exclusively for documentation examples when you need to verify that your code snippets compile and run. Stick with plain cargo test only when you are prototyping a single file or running a micro-benchmark where process spawn overhead matters more than isolation.

Where to go next