vitest-dev/vitest · error · AroundHookMultipleCallsError

The `${callbackName}` callback was called multiple times in

Error message

The `${callbackName}` callback was called multiple times in the `${hookName}` hook. The callback can only be called once per hook.

What it means

`aroundEach` and `aroundAll` hooks receive a `runTest`/`runSuite` callback that must be called exactly once to proceed with the test/suite. In `callAroundHooks` (run.ts:342-356), the `use` closure tracks a `useCalled` flag; if `use`/`runTest`/`runSuite` is invoked a second time within the same hook, an `AroundHookMultipleCallsError` is thrown.

Source

Thrown at packages/vitest/src/runtime/runner/run.ts:352

    // Promise that resolves when hook completes
    let resolveHookComplete!: () => void
    let rejectHookComplete!: (error: Error) => void
    const hookCompletePromise = new Promise<void>((resolve, reject) => {
      resolveHookComplete = resolve
      rejectHookComplete = reject
    })

    const use = async () => {
      // shouldn't continue to next (runTest/Suite or inner aroundEach/All) when aroundEach/All setup timed out.
      if (setupTimeout.isTimedOut()) {
        // we can throw any error to bail out.
        // this error is not seen by end users since `runNextHook` already rejected with timeout error
        // and this error is caught by `rejectHookComplete`.
        throw new Error('__VITEST_INTERNAL_AROUND_HOOK_ABORT__')
      }

      if (useCalled) {
        throw new AroundHookMultipleCallsError(
          `The \`${callbackName}\` callback was called multiple times in the \`${hookName}\` hook. `
          + `The callback can only be called once per hook.`,
        )
      }
      useCalled = true
      resolveUseCalled()

      // Setup phase completed - clear setup timer
      setupTimeout.clear()
      setupLimitConcurrencyRelease?.()

      // Run inner hooks - don't time this against our teardown timeout
      await runNextHook(index + 1).catch(e => hookErrors.push(e))

      teardownLimitConcurrencyRelease = await limitMaxConcurrency.acquire()

      // Start teardown timer after inner hooks complete - only times this hook's teardown code
      teardownTimeout = createTimeoutPromise(timeout, 'teardown', stackTraceError)

View on GitHub (pinned to d568f8ce37)

Solutions

  1. Call `runTest`/`runSuite` exactly once, on the main code path.
  2. Ensure no try/catch/finally branch calls the callback more than once.
  3. If you need retry behavior, do it inside the test, not by re-invoking runTest.

Example fix

// before
aroundEach(async (runTest) => {
  await setup()
  await runTest()
  await runTest() // second call throws
})
// after
aroundEach(async (runTest) => {
  await setup()
  await runTest()
  await teardown()
})
Defensive patterns

Strategy: validation

Validate before calling

// Guard against double-call in your around hook with a local flag if logic is complex.
aroundEach(async (runTest) => {
  let called = false
  const runOnce = async () => {
    if (called) throw new Error('runTest already called')
    called = true
    return runTest()
  }
  await setup()
  await runOnce()
  await teardown()
})

Prevention

When it happens

Trigger: Calling `runTest()` twice in an `aroundEach` hook; calling `runSuite()` in a loop inside `aroundAll`; awaiting `runTest()` and then calling it again conditionally; an accidental double-call due to a try/finally that calls it in both paths.

Common situations: Wrapping runTest in conditional logic where both branches call it; retry logic that re-invokes runTest; misunderstanding that runTest is single-shot.

Related errors


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