DEV Community

Cover image for How to Make Your Rust Tests Run Faster in CI (A Practical Guide)
Rodrigo Burgos
Rodrigo Burgos

Posted on

How to Make Your Rust Tests Run Faster in CI (A Practical Guide)

Slow CI pipelines are often blamed on:

  • Heavy test suites
  • Complex integrations
  • Rust compilation time

But in many cases, the real issue is much simpler:

  • Your tests are not fully using the CPU available in the CI runner.
  • But in many cases, the real issue is much simpler:
  • Your tests are not fully using the CPU available in the CI runner.

Step 1 — Understand How cargo test Uses Threads

Rust’s test harness runs tests in parallel by default. However, in CI environments:

  • CPU limits may restrict available cores
  • Containers may expose fewer threads
  • The harness may default to 1 thread in constrained setups
  • You should never assume your CI is using all available CPUs.

Instead, verify it.

Step 2 — Check How Many CPUs Your Runner Has

Inside your CI job, run:

nproc
Enter fullscreen mode Exit fullscreen mode

Example output:

2
Enter fullscreen mode Exit fullscreen mode

This means the environment has 2 logical CPUs available. If you don’t explicitly configure thread usage, your tests might not use both.

Step 3 — Explicitly Set --test-threads

script:
  - cargo test -p my_crate module_a
  - cargo test -p my_crate module_b
  - cargo test -p my_crate module_c
  - cargo test -p my_crate module_d
Enter fullscreen mode Exit fullscreen mode

Each invocation runs sequentially. To ensure each test run uses all available CPU cores, capture the number of CPUs dynamically:

script:
  - THREADS=$(nproc)
  - echo "Running tests with ${THREADS} threads"
  - cargo test -p my_crate module_a -- --test-threads=${THREADS}
  - cargo test -p my_crate module_b -- --test-threads=${THREADS}
  - cargo test -p my_crate module_c -- --test-threads=${THREADS}
  - cargo test -p my_crate module_d -- --test-threads=${THREADS}
Enter fullscreen mode Exit fullscreen mode

Why the Double Dash (--) Is Important. The -- separator is critical. Everything before -- is interpreted by cargo. Everything after -- is passed to the test binary (Rust’s test harness).

--test-threads is a test harness argument not a cargo argument. If you forget the separator, the flag won’t work.

Step 4 — Why Use $(nproc) Instead of a Fixed Number?

You could hardcode:

--test-threads=2
Enter fullscreen mode Exit fullscreen mode

But that creates a hidden maintenance issue. If the runner changes from 2 CPUs to 4, your CI won’t scale automatically.

Using: THREADS=$(nproc) ensures:

  • Automatic adaptation
  • No future edits required
  • Better portability between environments

Step 5 — Make Sure Your Tests Are Safe to Parallelize

Parallel test execution requires test isolation. Your tests should:

  • Avoid global mutable state
  • Avoid shared in-memory singletons
  • Avoid reusing the same database instance
  • Avoid mutating global environment variables

A safe pattern is to instantiate dependencies per test:

fn create_test_repository() -> InMemoryRepository {
    InMemoryRepository::new()
}

[tokio::test]
fn example_test() {
    let repo = create_test_repository();
    // test logic here
}
Enter fullscreen mode Exit fullscreen mode

Each test gets its own isolated state. When You Should Disable Parallelism. If a test suite depends on shared external state (for example, a real database instance), you may need to force sequential execution:

cargo test -- --test-threads=1
Enter fullscreen mode Exit fullscreen mode

Apply this only to specific test groups that require it. Do not disable parallelism globally unless necessary.

Optional Optimization: Avoid Repeating Expensive Setup

Sometimes slow tests are caused by repeated expensive operations (for example, hashing, cryptographic setup, or large fixture generation). You can cache computed values safely using OnceLock:

use std::sync::OnceLock;

static PRECOMPUTED_VALUE: OnceLock<String> = OnceLock::new();

fn get_precomputed_value() -> String {
    PRECOMPUTED_VALUE
        .get_or_init(|| {
            expensive_operation()
        })
        .clone()
}

fn expensive_operation() -> String {
    // Simulate heavy work
    "computed_result".to_string()
}
Enter fullscreen mode Exit fullscreen mode

This ensures:

  • The expensive operation runs only once
  • Tests remain deterministic
  • No unsafe global mutation occurs

However, always evaluate tradeoffs:

  • Does it significantly reduce runtime?
  • Does it add unnecessary complexity?
  • Is parallelism alone sufficient?

Often, proper thread configuration already solves most CI performance issues.

Expected Impact

If your CI was running tests effectively single-threaded on a multi-core runner, explicitly configuring --test-threads can:

  • Reduce test stage time dramatically
  • Improve resource utilization
  • Avoid unnecessary infrastructure upgrades

In many cases, improvements of 2–3x are realistic.

Final Checklist

If your Rust CI feels slow, verify the following:

  • How many CPUs does the runner expose? (nproc)
  • Are tests running in parallel?
  • Is --test-threads explicitly configured?
  • Are tests properly isolated?
  • Are expensive operations unnecessarily repeated?

Before rewriting your test suite or scaling infrastructure, make sure you are actually using the hardware available to you.

Top comments (0)