vitest-dev/vitest · error · Error

process.exit unexpectedly called with "${code}"

Error message

process.exit unexpectedly called with "${code}"

What it means

Vitest deliberately overrides process.exit inside the module-runner setup (base.ts:30) so that any code under test calling process.exit throws instead of killing the whole worker process. The thrown Error carries the exit code the code tried to use. This preserves test isolation so one test cannot terminate the worker and mask other failures.

Source

Thrown at packages/vitest/src/runtime/workers/base.ts:31

import { VitestEvaluatedModules } from '../moduleRunner/evaluatedModules'
import { createNodeImportMeta } from '../moduleRunner/moduleRunner'
import { startVitestModuleRunner } from '../moduleRunner/startVitestModuleRunner'
import { run } from '../runBaseTests'
import { setupEnv } from '../setup-common'
import { getSafeWorkerState, provideWorkerState } from '../utils'

let _moduleRunner: TestModuleRunner

const evaluatedModules = new VitestEvaluatedModules()
const moduleExecutionInfo = new Map()

async function startModuleRunner(options: ContextModuleRunnerOptions): Promise<TestModuleRunner> {
  if (_moduleRunner) {
    return _moduleRunner
  }

  process.exit = (code = process.exitCode || 0): never => {
    throw new Error(`process.exit unexpectedly called with "${code}"`)
  }
  const state = () => getSafeWorkerState() || options.state

  listenForErrors(state)

  if (options.state.config.experimental.viteModuleRunner === false) {
    const root = options.state.config.root
    let mocker: TestModuleMocker | undefined
    if (options.state.config.experimental.nodeLoader !== false) {
      // this additionally imports acorn/magic-string
      const { NativeModuleMocker } = await import('../moduleRunner/nativeModuleMocker')
      mocker = new NativeModuleMocker({
        async resolveId(id, importer) {
          // TODO: use import.meta.resolve instead
          return state().rpc.resolve(id, importer, '__vitest__')
        },
        root,
        moduleDirectories: state().config.deps.moduleDirectories || ['/node_modules/'],

View on GitHub (pinned to d568f8ce37)

Solutions

  1. Stub process.exit in the test (vi.stubEnv/spyOn) so it resolves instead of exiting, then assert it was called.
  2. Refactor the code under test to throw an error instead of calling process.exit, and assert on the thrown error.
  3. If the exit is in a dependency you cannot change, mock that module via vi.mock.

Example fix

// before: code under test exits
function parse(args) {
  if (!args[0]) process.exit(1)
}

// after: throw instead, or spy in the test
// code
function parse(args) {
  if (!args[0]) throw new Error('missing arg')
}
// test
const spy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') })
expect(() => parse([])).toThrow('missing arg')
Defensive patterns

Strategy: try-catch

Try / catch

const exitSpy = vi.spyOn(process, 'exit')
  .mockImplementation((code?: number) => {
    throw new Error(`process.exit(${code}) called`)
  })
try {
  await runCodeUnderTest()
} catch (e) {
  expect((e as Error).message).toMatch(/process\.exit/)
} finally {
  exitSpy.mockRestore()
}

Prevention

When it happens

Trigger: A test file or a module it imports (directly or transitively) invokes process.exit() / process.exit(code) during collection or execution under the threads/forks pool with the base environment. The override is installed in startModuleRunner and stays active while tests run.

Common situations: Testing a CLI module or library that calls process.exit on invalid input or error paths; importing a third-party dependency that hard-exits on a feature flag or missing config; code that uses process.exit as control flow.

Related errors


AI-assisted analysis of vitest-dev/vitest@d568f8ce37 (2026-08-03). Data as JSON: /data/errors/69693a6a17ae1098.json. Report an issue: GitHub.