How to check test isolation
Run the file alone, then the one test by name with -t, then the whole suite, and compare the three verdicts. On the project below one test passes alone and fails in the suite, because a test in another file wrote a log line that this one asserts is absent.
Why check this
Run this after a test fails in the pipeline and passes when you run its file by hand, and before you reorder, rename or split test files. All three change the order tests run in.
The failure it prevents is a suite that certifies nothing. A test that passes only because an earlier test registered a handler asserts that test's setup, not the code. Delete the earlier test and the assertion breaks in a file nobody edited.
Order dependence has a direction, and the two directions are different bugs. A test that passes alone and fails in the suite reads state somebody left behind. A test that fails alone and passes in the suite depends on setup it does not own. The project below has one of each.
A test that fails on its own, repeatedly and at a rate, is a different problem: see How to detect flaky tests.
Prerequisites
- Node 22 and npm. Every output here came from Node 22.23.2 and Vitest 5.0.0 on Windows 11, on 2026-09-12. Print your own with
npx vitest --version, because the shuffle flags and the pool defaults moved between Vitest majors. - A scratch project, built in an empty directory outside your repository. See the Vitest CLI reference.
mkdir isodemo && cd isodemo
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5
mkdir src test tmp
- Two source modules.
registry.jsholds state in a module-levelMap,audit.jsholds it in a file.
// src/registry.js
const handlers = new Map();
export function register(name, fn) {
handlers.set(name, fn);
}
export function names() {
return [...handlers.keys()];
}
export function run(name, row) {
const fn = handlers.get(name);
if (!fn) throw new Error('no handler named ' + name);
return fn(row);
}
// src/audit.js
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
const FILE = new URL('../tmp/audit.log', import.meta.url);
export function append(line) {
appendFileSync(FILE, line + '\n');
}
export function lines() {
return existsSync(FILE) ? readFileSync(FILE, 'utf8').split('\n').filter(Boolean) : [];
}
- Two test files. Neither is wrong on its own.
// test/export-pipeline.test.js
import { describe, it, expect } from 'vitest';
import { register, names, run } from '../src/registry.js';
import { append } from '../src/audit.js';
describe('csv export', () => {
it('formats a row', () => {
expect(['A1', 2, 3.5].join(',')).toBe('A1,2,3.5');
});
it('counts the rows in a batch', () => {
expect([{ sku: 'A1' }, { sku: 'A2' }].length).toBe(2);
});
it('rejects an empty batch', () => {
expect(() => { if ([].length === 0) throw new Error('empty'); }).toThrow('empty');
});
it('validates a sku', () => {
expect(/^[A-Z]\d+$/.test('A17')).toBe(true);
});
it('registers the csv handler', () => {
register('csv', (row) => Object.values(row).join(','));
expect(names()).toContain('csv');
});
it('rounds a price to two places', () => {
expect((2.345).toFixed(2)).toBe('2.35');
});
it('escapes a quote', () => {
expect('say "hi"'.replaceAll('"', '""')).toBe('say ""hi""');
});
it('writes one audit line per export', () => {
append('export 1 batch');
expect(true).toBe(true);
});
});
describe('handler registry', () => {
it('starts empty for a fresh process', () => {
expect(names()).toEqual([]);
});
it('runs a registered handler', () => {
expect(run('csv', { sku: 'A1', qty: 2 })).toBe('A1,2');
});
});
// test/audit-log.test.js
import { describe, it, expect } from 'vitest';
import { lines } from '../src/audit.js';
describe('audit log', () => {
it('is empty before any export runs', () => {
expect(lines()).toEqual([]);
});
});
- Start from a clean state with
rm -f tmp/audit.log. Step 1 assumes that file is absent.
Steps
- Step 1.
Run the small file on its own.
npx vitest run test/audit-log.test.jsTest Files 1 passed (1) Tests 1 passed (1) Start at 11:28:07 Duration 224ms (transform 44%, import 42%, worker 8%, tests 6%, environment 1%)Green. Nothing in this run says why.
- Step 2.
Run the whole suite with file parallelism off, so the order repeats.
npx vitest run --no-file-parallelism❯ test/export-pipeline.test.js (10 tests | 1 failed) 14ms ❯ handler registry (2) × starts empty for a fresh process 7ms ❯ test/audit-log.test.js (1 test | 1 failed) 9ms ❯ audit log (1) × is empty before any export runs 7ms … FAIL test/audit-log.test.js > audit log > is empty before any export runs AssertionError: expected [ 'export 1 batch' ] to deeply equal [] - Expected + Received - [] + [ + "export 1 batch", + ] ❯ test/audit-log.test.js:6:21 … Test Files 2 failed (2) Tests 2 failed | 9 passed (11) Duration 374ms (import 40%, transform 31%, tests 20%, worker 8%)Two failures, and the larger file ran first. Vitest 5 orders files by size, not by name and not by the order you typed.
- Step 3.
Run the identical command from step 1, with no edit in between.
npx vitest run test/audit-log.test.js❯ test/audit-log.test.js (1 test | 1 failed) 9ms ❯ audit log (1) × is empty before any export runs 7ms … FAIL test/audit-log.test.js > audit log > is empty before any export runs AssertionError: expected [ 'export 1 batch' ] to deeply equal [] … Test Files 1 failed (1) Tests 1 failed (1) Duration 181ms (transform 43%, import 29%, tests 18%, worker 9%)Same command, same code, opposite verdict. The carrier is
tmp/audit.log, written by a test in the other file. Module state dies with the process, a file does not. - Step 4.
Move to the other failure and run only its file.
npx vitest run test/export-pipeline.test.js❯ test/export-pipeline.test.js (10 tests | 1 failed) 12ms ❯ handler registry (2) × starts empty for a fresh process 6ms … FAIL test/export-pipeline.test.js > handler registry > starts empty for a fresh process AssertionError: expected [ 'csv' ] to deeply equal [] - Expected + Received - [] + [ + "csv", + ] ❯ test/export-pipeline.test.js:43:21 41| describe('handler registry', () => { 42| it('starts empty for a fresh process', () => { 43| expect(names()).toEqual([]); | ^ 44| }); 45| … Test Files 1 failed (1) Tests 1 failed | 9 passed (10)It still fails, so this dependency lives inside one file. That narrows the carrier to a module-level variable.
- Step 5.
Run that one test by name and read the third verdict.
npx vitest run test/export-pipeline.test.js -t "starts empty"Test Files 1 passed (1) Tests 1 passed | 9 skipped (10) Duration 199ms (transform 46%, import 41%, worker 7%, tests 5%)One test, three answers: green alone, red in its file, red in the suite. The test is not the bug. Something before it is.
- Step 6.
Bisect by name. Keep the failing test and add back the first half of the tests above it.
npx vitest run test/export-pipeline.test.js -t "formats a row|counts the rows|rejects an empty|validates a sku|starts empty"Test Files 1 passed (1) Tests 5 passed | 5 skipped (10) Duration 186ms (transform 44%, import 40%, tests 8%, worker 8%)Green, so the culprit is in the half left out. Running the other four names with the target went red, confirming the split.
- Step 7.
Halve until two names are left.
npx vitest run test/export-pipeline.test.js -t "registers the csv|starts empty"❯ test/export-pipeline.test.js (10 tests | 1 failed | 8 skipped) 9ms ❯ handler registry (2) × starts empty for a fresh process 6ms … FAIL test/export-pipeline.test.js > handler registry > starts empty for a fresh process AssertionError: expected [ 'csv' ] to deeply equal [] … Test Files 1 failed (1) Tests 1 failed | 1 passed | 8 skipped (10)Two tests, one failure.
registers the csv handleris the writer. Four runs named it without reading the other six. - Step 8.
Shuffle the order with a fixed seed and read which test moves.
npx vitest run test/export-pipeline.test.js --sequence.shuffle --sequence.seed=3 --reporter=verboseRunning tests with seed "3" × test/export-pipeline.test.js > handler registry > runs a registered handler 6ms → no handler named csv ✓ test/export-pipeline.test.js > handler registry > starts empty for a fresh process 1ms ✓ test/export-pipeline.test.js > csv export > rejects an empty batch 1ms ✓ test/export-pipeline.test.js > csv export > formats a row 0ms ✓ test/export-pipeline.test.js > csv export > writes one audit line per export 1ms ✓ test/export-pipeline.test.js > csv export > validates a sku 0ms ✓ test/export-pipeline.test.js > csv export > rounds a price to two places 0ms ✓ test/export-pipeline.test.js > csv export > registers the csv handler 1ms ✓ test/export-pipeline.test.js > csv export > escapes a quote 1ms ✓ test/export-pipeline.test.js > csv export > counts the rows in a batch 0ms … FAIL test/export-pipeline.test.js > handler registry > runs a registered handler Error: no handler named csv ❯ run src/registry.js:13:18 11| export function run(name, row) { 12| const fn = handlers.get(name); 13| if (!fn) throw new Error('no handler named ' + name); | ^ 14| return fn(row); 15| } ❯ test/export-pipeline.test.js:47:12 … Test Files 1 failed (1) Tests 1 failed | 9 passed (10)Seed 3 puts the registry block first, so the other direction shows.
runs a registered handlernow fails and the test that failed in every earlier step passes.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Green alone, red in the suite | The test reads state an earlier test left | Bisect by name, then reset that state in the writer |
| Red alone, green in the suite | The test depends on setup it does not own | Move the setup into the test or into a beforeEach |
| Two runs of one command disagree | The carrier outlives the process: a file, a port, a database | Give each run its own path, or delete it in setup |
| Red in its file, green with -t | The carrier is a module-level variable in an imported module | Reset the module between tests, or stop holding state there |
| Green at every seed you tried | No order dependence at those seeds | Record the seeds; a passing seed says nothing about the others |
| Different tests fail at different seeds | Several tests share one carrier | Fix the writer, not each reader |
Common mistakes
What to check next
- How to detect parallel test interference: the same failure, caused by two tests running at once.
- How to reset mocks between tests: the commonest carrier, and the flags that clear it.
- How to detect flaky tests: when the failure has a rate instead of an order.
- How to check why jest did not exit after tests: state that outlives the run.
- How to check which tests are slowest: the same durations, read for a different reason.
FAQ
How do I run a single test file in vitest?
Pass the path: npx vitest run test/audit-log.test.js. The argument is a filter, not a path, so a fragment runs everything it matches. npx vitest run test ran both files here.
Does a test that fails alone mean the test is broken?
It depends on setup it does not own, and the fix belongs in that test, usually a beforeEach. A test that fails in the suite and passes alone is the opposite case, and its fix belongs earlier.
Which test do I fix, the one that fails or the one before it?
The writer. The reader is only where the damage surfaces. Step 7 named registers the csv handler, and clearing the registry after it fixes both directions at once.
Does vitest reset module state between test files?
Yes while --isolate is on, the default: each file gets its own module graph. --no-isolate lets globals cross files, measured in How to detect parallel test interference.
Should CI run with shuffle on?
Shuffle on a schedule rather than on every merge, and print the seed into the job log. A shuffled failure with no recorded seed is a red build and no way back to the order that caused it.
Verified
Verified by Maks Vernynode 22.23.2vitest 5.0.0
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny