Skip to content

Vitest ​

Plugin vs barrel ​

WARNING

Config: untestutils/vitest/plugin. Specs: untestutils/vitest. Do not load the barrel in config.

Plugin options ​

ts
untestutils({
  recipes,              // from defineRecipes(..., import.meta.url)
  prewarm: ['site'],
  browsers: ['chromium'],
  artifactsRoot: undefined,
  prewarmConcurrency: 4,   // default: half the CPU cores
  buildTimeout: 600_000,   // default: Vitest hookTimeout
  gc: { keep: 2 },         // default
  retryInfra: 1,           // retry only infra errors
})
OptionNotes
recipesMap from your recipes module
prewarmIds to prepare/start in global setup — in parallel, up to prewarmConcurrency
browsersEngines for page / goto (default ['chromium'])
artifactsRootSets UNTESTUTILS_ARTIFACTS_DIR
prewarmConcurrencyMax parallel prewarm builds (default cpus / 2, min 1)
buildTimeoutBudget for building a test.override({ harness }) target (see below). false = old lazy build inside the first test
gcPrune old builds at teardown: keep keep newest per recipe + all used this run (default { keep: 2 }, false off)
hydrationgoto hydration wait: 'strict' (default) fails with a clear error, 'lenient' = pre-0.8 silent timeout
retryInfraRetry up to N times only for infra errors (see Retries)

Override engine per run: UNTESTUTILS_BROWSER=firefox. Env equivalents: UNTESTUTILS_PREWARM_CONCURRENCY, UNTESTUTILS_BUILD_TIMEOUT (0 = off), UNTESTUTILS_GC (0 = off, or keep count), UNTESTUTILS_GOTO_HYDRATION.

useHarness ​

ts
const app = await useHarness('site')
await app.$fetch('/')
app.url
app.dir
await app.files.read('index.html')

Prefer registered ids so Vitest and Playwright share the prepare cache.

Fixtures ​

FixtureRole
harnessRecipe id via test.override({ harness })
browserNamechromium | firefox | webkit
baseURLCurrent harness URL
pagePlaywright Page
gotopage.goto (+ Nuxt waitUntil: 'hydration' | 'route')
requestAPIRequestContext
ts
import { describe, test, expect } from 'untestutils/vitest'

describe('ui', () => {
  test.override({ harness: 'site' })

  test('home', async ({ page, goto }) => {
    await goto('/', { waitUntil: 'hydration' })
    await expect(page.getByRole('heading')).toBeVisible()
  })
})

Ensure useHarness or prewarm started the target so baseURL resolves.

Build time vs test time ​

test.override({ harness }) registers a beforeAll in that suite which builds/starts the target with buildTimeout (default: hookTimeout). A cold build is charged to that hook — not to the first test's testTimeout — so a slow build cannot time out a test and per-test timings stay honest. The baseURL fixture then reuses the live target. (Only the exported test is wrapped; for a custom test.extend(...) chain call useHarness in your own beforeAll.)

goto hydration ​

After load / networkidle / default navigation, strict mode (default) waits up to 30 s for Nuxt hydration on Nuxt pages only (__NUXT__ / #__nuxt / useNuxtApp). If hydration never finishes it throws (goto(url): Nuxt hydration not ready … (isHydrating=true)), usually a client error worth seeing. hydration: 'lenient' restores the old silent behaviour, including its 30 s probe on non-Nuxt pages; explicit waitUntil: 'hydration' | 'route' always stays strict; waitUntil: 'domcontentloaded' skips the wait.

Retries ​

A blanket retry: 1 hides real flakes. Retry only harness/browser failures and list what was retried:

ts
import { untestutils, RetryReporter, infraRetry } from 'untestutils/vitest/plugin'

export default defineConfig({
  plugins: [untestutils({ recipes, retryInfra: process.env.CI ? 1 : 0 })],
  test: {
    // or set it yourself: retry: infraRetry(1)
    reporters: ['default', new RetryReporter({ outputFile: 'test-results/retried.json' })],
  },
})

INFRA_ERROR_PATTERN matches ECONNRESET, ECONNREFUSED, EPIPE, socket hang up, net::ERR_CONNECTION_*, Target page, context or browser has been closed, browser disconnects / page crashes. Assertion failures never retry. RetryReporter prints every retried test (FLAKY, passed after 1 retry or failed after …) with each attempt's error. A crashed worker browser is relaunched for the next test.

Unit environments (multi-framework) ​

environment: 'untestutils' is a router (vitest-environment-untestutils) that loads @untestutils/<framework>/environment.

ts
// Vite example — same pattern for next / astro / sveltekit / remix / solidstart
import { defineVitestProject } from '@untestutils/vite/config'

export default defineVitestProject({
  test: {
    environmentOptions: {
      untestutils: {
        framework: 'vite', // set automatically by defineVitestProject
        appIsolation: 'worker', // opt-in; soft reset between tests
        domEnvironment: 'happy-dom',
      },
    },
  },
})
OptionNotes
untestutils.frameworknuxt | vite | next | astro | sveltekit | remix | solidstart
Legacy environmentOptions.nuxtStill resolves Nuxt (no framework required)
Shared DOM@untestutils/vitest/unit-dom
Lifecycle@untestutils/vitest/unit-lifecycle (registerSetupEntry, host reset)

Contract helpers (where the adapter exports them): setupApp / resetSharedApp / restartSharedApp / mount helper. Nuxt also keeps resetSharedNuxtApp / restartSharedNuxtApp and aliases resetSharedApp / restartSharedApp.

Honest boundaries (client unit ≠ full SSR): Next/Remix — no RSC/loader SSR; SvelteKit — no load/actions; SolidStart — no Vinxi SSR; Astro — island/container only. Status: Support matrix.

E2e plugin and unit env still do not share one Vitest project.

Nuxt unit (separate project) ​

E2e plugin and Nuxt unit env do not share one Vitest config:

  • E2e: untestutils/vitest/plugin
  • Unit: environment: 'untestutils' + untestutils/config / untestutils/runtime (or @untestutils/nuxt/config)

See API overview (config, runtime, module).

appIsolation: 'worker' (opt-in) ​

Today this surface is the Nuxt unit adapter (environment: 'untestutils'). Framework-agnostic hooks live in @untestutils/vitest/unit-lifecycle (registerSetupEntry, disposeBestEffort, host/timers reset, applyWorkerIsolationDefaults, registerSharedReset, getOrCreateWorkerState) so future unit environments can reuse the same worker/file lifecycle without Nuxt imports.

By default each unit file pays for a full setupNuxt() boot. For large component suites, reuse one Nuxt app per Vitest worker:

ts
export default defineVitestProject({
  test: {
    environment: 'untestutils',
    pool: 'threads',
    environmentOptions: {
      nuxt: {
        appIsolation: 'worker', // auto-sets isolate: false + maxConcurrency: 1 when unset
        // resetBetweenTests: true  // default in worker mode
      },
    },
  },
})
Default fileworker
setupNuxtonce per test file (remounts even with isolate: false)once per Vitest worker
isolateVitest default (true)false (auto)
maxConcurrencyVitest default1 (auto)
State between filesfresh window / remountshared app — soft-reset between tests
Soft resetn/aresetSharedNuxtApp via onTestFinished (scheduled from beforeEach; falls back to afterEach)

resetSharedNuxtApp (auto when resetBetweenTests is on) clears route/state/data/errors, VTU mounts (including plain mount() via enableAutoUnmount), registerEndpoint handlers, cookies (tracked document.cookie name+path — covers path-scoped cookies set in tests; jsdom jar when present), localStorage/sessionStorage, fake timers, Vitest env/global stubs, and per-test overrideNuxtImport values. Opt out per layer: resetSharedNuxtApp({ host: false, timers: false, stubs: false }).

Custom cleanups: registerSharedNuxtReset(key, fn) — keyed, replace-in-place on setupFile re-eval (prefer over unkeyed (fn)).

Not reset: startup plugin state, HttpOnly / other-path cookies the jar cannot see in some envs, DOM nodes that existed at baseline capture, background work flushPromises does not settle.

After Vite HMR / full reload in watch, call restartSharedNuxtApp() (or rely on enableSharedNuxtHotRestart, registered automatically in worker mode) to invalidate the memoized boot.

Per-test import overrides ​

mockNuxtImport after the shared boot is unreliable. Ship worker-level wrappers:

ts
// setup file (once per worker)
import { mockNuxtImport } from 'untestutils/runtime'
import { overridableNuxtImport } from 'untestutils/runtime'
mockNuxtImport('useFoo', overridableNuxtImport('useFoo', () => 'default'))

// in a test
import { overrideNuxtImport, overrideNuxtRoute } from 'untestutils/runtime'
const restore = overrideNuxtImport('useFoo', () => 'mocked')
// … assertions …
restore()

Also: getOrCreateWorkerState(name, create) for registries the app captured at startup. Soft reset clears override values; wrappers stay.

Do not call enableAutoUnmount yourself in Nuxt worker mode — the Nuxt runtime installs it (VTU throws on a second enable).

Alignment with nuxt/test-utils ​

ConcernPR #1821#1750untestutils
Memoize setupNuxtalways (window promise)opt-in appIsolation: 'worker'opt-in appIsolation: 'worker'
Soft reset APIdocs caveat onlyroute/state/host checklistshipped (resetSharedNuxtApp + host/timers/stubs)
Browser entrysetupWindow + promise + skip node entryn/asame shape + worker-aware registerNuxtSetupEntry

Keep the Vitest project homogeneous (environment: 'untestutils' only). Mixing @vitest-environment node in the same project tears the DOM env down between files and kills reuse.

Measured on this repo (pnpm test:unit-bench, 12 files, maxWorkers: 1, mean-of-3): file-scoped ~9.6 s vs worker-scoped ~2.8 s (~3.5×). See playground/unit-bench/results.json.

Next ​

Released under the MIT License.