Test runners

Six cubes, one per supported test runner, in a row above one line that ends in a check mark

vci works with six test runners: Vitest, pytest, Go, Cargo, Rails (Minitest) and RSpec. This page shows, for each one, how to set it up, what vci records, what it refuses, and what to pin so a test passed on your laptop is skipped on a Linux CI runner.

Each runner has an adapter: the part of vci that runs your tests and records what they read. The adapter decides what a unit is: the smallest piece of your suite that vci can skip. The diagram shows the unit for each runner.

The unit that can be skipped, per runner: a test file for Vitest and pytest, a package for Go, a test target for Cargo, a Minitest file for Rails and a spec file for RSpec. Each unit runs in its own process, and one repository can mix runners.

What every adapter shares. Each unit runs in its own process, so nothing leaks in from another unit. While it runs, a collector (code vci loads into the test process) records every file, package and environment variable the test actually used. (Rust has no such hook, so the cargo adapter uses a conservative rule instead: see the table below.) When the unit passes, vci signs an attestation: a record that says "this unit passed with exactly these inputs". If the collector cannot see something the test did, or an input lies outside the repository, vci refuses to attest. The unit then simply runs in CI, as it would without vci.

AdapterUnitHow inputs are found
vitesttest fileobserved at run time (module graph, file reads, env reads)
pytesttest fileobserved at run time (Python audit hooks, import system)
gopackageobserved at run time (a logger overlaid into the standard library) plus go list
cargotest targetconservative: every file of the package and its in-repo dependencies
railsMinitest file, or RSpec spec fileobserved at run time (a collector wrapping Ruby's file, env and process entry points)

The rules for every adapter.

  • A unit is attested only when it passed, nothing it did was invisible to the collector, and every input is inside the repository.
  • A unit with a skipped test is not attested. Rust's #[ignore] is the exception.
  • An attestation vouches for the unit run on its own, in its own process.

The commands in every set-up block are the same five steps:

CommandWhat it does
vci initwrites vci.toml (settings), .vci/allowed_signers (your public key) and, when the repository has an origin remote, .git-meta (where attestations are published)
git commitcommits them. CI reads keys and policy from the base branch (the branch a pull request merges into), so merge this commit to main first.
vci runruns tests, records inputs, signs an attestation for each unit that passed
vci planlists which units would be skipped (SKIP) and which would run (RUN)
vci pushpublishes the attestations to refs/meta/main

Copy-and-paste setups for each runner, with real output, are on the Examples page.

Vitest

Needs Node 22.15+ and Vitest >=3.2 <6. The unit is a test file.

Set up

cd your-project
# or set VCI_JS_PLUGIN instead
npm install --save-dev /path/to/vci/js/vitest-plugin

vci init --key ~/.ssh/id_ed25519.pub --no-install
git add vci.toml .vci/allowed_signers .git-meta \
  && git commit -m "Enable vci"

vci run src/b.test.ts --key ~/.ssh/id_ed25519
vci plan --base-ref origin/main
vci push

What is recorded

Inputs of every test file

Besides what the test used, every test file also records these global inputs: package.json, lockfiles, .npmrc, the Vitest config, the tsconfig.json chain, vci.toml and the test's snapshot file.

What is refused

Laptop to Linux runner

pytest

Needs uv and pytest 9. The project must be a uv project with a committed uv.lock and a pytest config file in the project directory. The unit is a test file, run in its own process.

Set up

cd your-project
# a uv-managed build that also exists for the CI platform
uv python install 3.14.4
# pin the full version
echo 3.14.4 > .python-version
# never pick up the system Python
export UV_PYTHON_PREFERENCE=only-managed
uv lock

vci init --adapter pytest --key ~/.ssh/id_ed25519.pub
git add vci.toml .vci/allowed_signers .git-meta uv.lock \
  .python-version && git commit -m "Enable vci"

vci run tests/test_b.py --key ~/.ssh/id_ed25519
vci plan --base-ref origin/main
vci push

What is recorded

Any change to uv.lock runs everything.

What is refused

Laptop to Linux runner

Go

Needs a Go module and Go 1.26. The unit is a package: every directory with _test.go files. Test ids are package directories relative to the repository root.

Set up

cd your-module
vci init --adapter go --key ~/.ssh/id_ed25519.pub
git add vci.toml .vci/allowed_signers .git-meta \
  && git commit -m "Enable vci"

# default: every package with tests
vci run ./b --key ~/.ssh/id_ed25519
vci plan --base-ref origin/main
vci push

What is recorded

What is refused

Network waiver. Many test helpers import net/http without using it (testify's assert does). Set go_allow_net = true under [policy] to state that these tests do no network I/O. The waiver is recorded in the attestation and only accepted while the base commit's policy still sets it. It is the same as ignore_taints = ["go:net"]; ignore_taints waives the other refusals too (see configuration).

Laptop to Linux runner

Rust (Cargo)

Needs a Cargo workspace or package with a committed Cargo.lock. The unit is a cargo test target of a workspace member.

Set up

A unit's test id is its package directory, #, and the target:

Test idWhat cargo test runs
<package dir>#libthe library's unit tests
<package dir>#docthe library's doctests
<package dir>#bin:<name>a binary's unit tests
<package dir>#test:<name>an integration test file tests/<name>.rs
cd your-workspace
vci init --adapter cargo --key ~/.ssh/id_ed25519.pub
git add vci.toml .vci/allowed_signers .git-meta Cargo.lock \
  && git commit -m "Enable vci"

# one unit
vci run 'crates/b#test:b' --key ~/.ssh/id_ed25519
# every unit of that package
vci run crates/b --key ~/.ssh/id_ed25519
vci plan --base-ref origin/main
vci push

What is recorded

A weaker guarantee than the other adapters. Rust has no hook for what a test reads at run time. Instead, every file in the package directory of the unit, and of every in-repo crate it builds, is an input.

A test that reads files elsewhere in the repository must declare them in vci.toml:

[[inputs]]
match = ["crates/b#test:*"]
extra = ["testdata/**", "shared/config.json"]

vci scans the source for undeclared paths and refuses the unit when it finds one, but a path built at run time is not seen.

What is refused

Decided by scanning the source text, not by watching the test run:

Laptop to Linux runner

Rails

Needs Rails 8 and the project's Ruby with its bundle installed (tested with Ruby 3.4.9 and SQLite). The unit is a test file, run in its own process. Two runners are supported:

runnerUnit, and how vci runs it
"minitest"a file bin/rails test runs by default;
run as bin/rails test <file>
"rspec"a spec file RSpec's own configuration lists;
run as rspec --options .rspec <file>

Set runner in vci.toml, or leave it out and vci detects it from the lockfile and the directories present. This section covers Minitest and everything the two share; RSpec has its own section below.

Set up

cd your-app
# so the CI runner can install the same bundle
bundle lock --add-platform x86_64-linux
vci init --adapter rails --key ~/.ssh/id_ed25519.pub
git add vci.toml .vci/allowed_signers .git-meta Gemfile.lock \
  && git commit -m "Enable vci"

vci run test/models/b_test.rb --key ~/.ssh/id_ed25519   # TZ comes from vci.toml's [env] set
vci plan --base-ref origin/main
vci push

The collector is plain Ruby loaded through RUBYOPT; nothing is installed into your project. It sets these run conditions:

What is recorded

Any change to Gemfile.lock runs everything.

What Rails reads at boot is not an input

Since 2.7.36, by default (rails_inputs = "loose"), the files Rails reads while it boots are left out of every test file's inputs: config/routes.rb and config/routes/**, config/initializers/**, config/environments/**, config/application.rb, config/boot.rb, config/environment.rb, config/puma.rb, config/spring.rb, config.ru, db/migrate/**, config/locales/**, and the autoload records, so a new file in app/models runs nothing again. Gemfile.lock, db/schema.rb, config/database.yml, test_helper.rb, test/support and every file a test reads after boot (models, services, fixtures, templates) stay inputs.

The trade-off: a change to a boot file no longer runs any test again on a pull request. The default branch's full run (no_skip_refs) runs every test against it. rails_inputs = "strict" records everything, as before; ignore_inputs = ["config/credentials/**"] adds globs to leave out. Both go next to adapter, or in a project's table. vci run says once what it left out, and vci explain shows the mode. See the reference.

What is refused

vci does not manage the database. The test database is the test engine's, as in a plain bin/rails test: vci never creates, prepares, purges or loads it, and Rails sets it up (maintain_test_schema!, fixtures, transactional tests). SQLite files, MySQL and PostgreSQL servers configured for the test environment all work with no policy. What the database holds is never an input, so rows left behind change no verdict. vci records the schema file of every test database (db/schema.rb, db/cache_schema.rb, ...), config/database.yml, the fixtures and the code, and the adapter names (sqlite3, mysql2, trilogy, postgresql) are compared; no server version is. It promises what CI promises: the test passed on a database the engine set up, not that it was independent of leftover data.

A database server needs only what a Rails CI job always needs: the same adapter as a service container, and bin/rails db:test:prepare before the tests. Keep SQLite files git-ignored (/storage/*, or /db/*.sqlite3*). Still refused: another SQLite file, ATTACH, extensions, a database client a test opens itself, and Active Record connecting to a server or database that is not the test environment's. rails_allow_db was removed in 1.4.0: it still parses, has no effect, and vci run and vci plan warn. The collector is now 0.5.0, so existing Rails and RSpec attestations run once more in CI.

Laptop to Linux runner

A generated Rails app needs a few changes before anything is skipped across machines:

RSpec

RSpec runs through the Rails adapter, so everything in the Rails section applies: the engine's test database, the single process, what is recorded, and the changes needed for a Linux runner. Tested with rspec-core 3.13 and rspec-rails 8. Plain Ruby projects that use RSpec without Rails work the same way.

Set up

cd your-app
bundle lock --add-platform x86_64-linux
vci init --adapter rails --key ~/.ssh/id_ed25519.pub
git add vci.toml .vci/allowed_signers .git-meta Gemfile.lock \
  .rspec && git commit -m "Enable vci"

vci run spec/models/order_spec.rb \
  --key ~/.ssh/id_ed25519
vci plan --base-ref origin/main
vci push
# vci.toml
project = "."
adapter = "rails"
runner = "rspec"

Option files

RSpec reads options from several places, some outside the repository. vci makes local and CI runs behave the same:

SourceTreatment
.rspecread, and an input of every spec file: editing it runs everything
~/.rspec, $XDG_CONFIG_HOME/rspec/optionsnever read
.rspec-localnever read (it is usually git-ignored, so CI could not match it)
SPEC_OPTSremoved unless listed in [env]; in hash its value is compared, in set vci gives it the same value everywhere

What is recorded

On top of the Rails list:

Tip: a spec that only requires spec_helper has far fewer inputs than one that loads rails_helper, and survives most changes to the app.

What is refused

Two things to know. A spec generated by rails generate contains a pending example, so it is refused until you replace it with real examples. And an example with no expectations is still attested: unlike Minitest, there is no check for that yet.

Run conditions vci sets for every spec file

vci also fixes the conditions of every run: the seed defaults to 0, the example status file is redirected to a temp directory, and SimpleCov writes outside the repository.

Laptop to Linux runner

Make the same changes as for a Rails app: see Laptop to Linux runner under Rails. The personal option files (~/.rspec, .rspec-local) are never read, so they cannot make your laptop and the runner differ.

Full reference

This page is a summary. The complete lists of recorded inputs, refusals and known limitations for each adapter are in the reference.