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

mkdir isodemo && cd isodemo
npm init -y > /dev/null
npm pkg set type=module
npm install -D vitest@5
mkdir src test tmp
// 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) : [];
}
// 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([]);
  });
});

Steps

  1. Step 1.

    Run the small file on its own.

    npx vitest run test/audit-log.test.js
    
     Test 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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 handler is the writer. Four runs named it without reading the other six.

  8. 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=verbose
    
          Running 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 handler now 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

Sign: A shuffled run finds an order-dependent failure and nobody can reproduce it.Cause: Vitest prints the seed it picked, as a line reading Running tests with seed 1789200561910, and the default is the clock. Without that number copied back into --sequence.seed the failing order is gone. The seed is worth more than the discovery.
Sign: --sequence.shuffle.files is turned on and reports the suite as order-independent.Cause: That form reorders files and leaves the order inside each file alone. On this project it failed the same test at seeds 1, 3 and 5, while the bare --sequence.shuffle, which shuffles both, flipped the failure to the other test at seed 3. A dependency inside one file is invisible to the files-only form.
Sign: The default file order is assumed to be alphabetical.Cause: Vitest 5 orders files by size. Here the 1307-byte file ran before the 210-byte one, the reverse of alphabetical. Adding ten lines to a test file changes its size ranking and therefore the order, which is one way an unrelated commit turns a suite red.
Sign: The test is run on its own, it passes, and the ticket is closed.Cause: Running it alone resets only what the process owns. A file, a port, a database row or a cache written by an earlier run is still there. The command that passed in step 1 failed in step 3 with no edit in between, because tmp/audit.log outlived both processes.

What to check next

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.

intermediate12 minpublished updated Maks Verny