skip to content

With Vue Test Utils, each `mount()` creates a new Vue app, so why can a router or store passed through `global.plugins` still leak state between tests?

level: seniorimportance: should knowfreq 35%

answer

  1. what is fresh per mount
  2. the plugin object is not the app
  3. module scope outlives a test
  4. factory per test, shared defaults stateless

basics

~20 s

mount() makes a fresh app each time, but a router or store created at module scope is the same object in every test, and its location or state lives in that object. Build a new instance per test.

solid answer

~40 s

Vue Test Utils calls `createApp` for every `mount`, so the app's provides, registered components, directives and config start empty each time. What `global.plugins` receives, though, is **your object**: a router or store created once at the top of the file is installed into each new app, but its current route or its state lives inside that object, not inside the app. Test A navigates or writes, test B starts from there, and the suite passes alone but fails in sequence. Vue does not warn, because `app.use` only remembers plugins per app. The fix is to **create stateful collaborators per test** with a factory, keep `config.global` for stateless defaults, restore any `config` mutation, and unmount wrappers, for example with `enableAutoUnmount(afterEach)`.

code

ts · 17 lines
ts
import { enableAutoUnmount, mount } from '@vue/test-utils'
import OrdersPage from './OrdersPage.vue'
import { makeRouter, makeStore } from './test-factories'

enableAutoUnmount(afterEach)

function mountOrders() {
  const router = makeRouter()
  const store = makeStore()
  const wrapper = mount(OrdersPage, { global: { plugins: [router, store] } })
  return { wrapper, router, store }
}

test('starts on an empty basket', () => {
  const { wrapper } = mountOrders()
  expect(wrapper.text()).toContain('No items')
})

go deeper

for a junior

Recall that stateful collaborators like a router or store should be created inside each test, not once at the top of the file.

for a middle

Explain the difference between what mount rebuilds, the app and its registrations, and the plugin objects you pass in, which carry their own state.

for a senior

Diagnose order-dependent suites quickly: run alone, reorder, look for file-scope instances and config.global mutations, and add automatic unmounting.

for a principal

Weigh a shared mount helper's convenience against hidden coupling, and set a rule for which collaborators may ever live in shared defaults.

## The symptom A component-test file passes when each test runs alone, but fails when the whole file runs, and the failure moves when you reorder tests. That is **order dependence**: some state written in one test is still there in the next. With Vue Test Utils the usual suspect is surprising, because each `mount()` looks isolated. ## What really is fresh per mount Each call to `mount()` in Vue Test Utils wraps your component in a small parent and calls Vue's `createApp` on it. That gives every mount: - a new **app context**, so app-level `provide` values, `app.component` and `app.directive` registrations start empty; - a new `app.config`, filled from the merged `global.config`; - a new record of installed plugins, since Vue's `app.use` tracks plugins per app. So anything Vue Test Utils *registers* from your options is rebuilt every time. ## What is not fresh `global.plugins` holds **references to objects you created**. Consider: ```ts const router = makeRouter() const store = makeStore() test('A', () => { mount(Page, { global: { plugins: [router, store] } }) }) test('B', () => { mount(Page, { global: { plugins: [router, store] } }) }) ``` Both apps are new, but `router` and `store` are the same two objects in both tests. A router's current location and a store's state are fields of those objects. `app.use(router)` in test B installs an object that still remembers where test A navigated. Vue gives no warning, because the "already applied" check is per app and B's app has never seen the plugin. The same trap applies to three other shared things: 1. **`config.global`**, which is one object shared by every mount; a mutation in test A remains in test B. 2. **Module-level reactive state** in a composable file, such as a `reactive()` object declared outside the function; it lives as long as the module does. 3. **Mounted wrappers never unmounted**, whose watchers, timers or listeners keep running after their test. ## The fix: a factory per test | Collaborator | Where to create it | Why | |---|---|---| | router, store, any stateful plugin | inside the test or `beforeEach` | its state must start clean | | `$t` mock, presentational components, directives | `config.global` in a setup file | stateless, safe to share | | per-test overrides | the mount's own `global` option | scoped to one mount | A practical pattern is one helper that builds fresh collaborators on every call: ```ts function mountPage(options = {}) { const router = makeRouter() const store = makeStore() const wrapper = mount(Page, { global: { plugins: [router, store] }, ...options }) return { wrapper, router, store } } ``` Returning the collaborators lets the test drive and assert on the same instances the component uses. How to build a router or store suitable for tests is those libraries' own subject; the Vue Test Utils rule is only that the instance must be new. ## Cleaning up what remains - Call `enableAutoUnmount(afterEach)` from `@vue/test-utils` once, so every wrapper is unmounted after its test and its effects stop. - If a test must change `config.global`, save the original first and restore it in `afterEach`. - Reset module-level state in composables by exposing a reset function or, better, by keeping that state inside the function. ## How to find the leak quickly 1. Run the failing test alone. If it passes, the cause is shared state, not the component. 2. Run the file in reverse or random order and note which earlier test poisons it. 3. Look for `const x = create...` at file scope that is passed to `global.plugins`, and for any assignment to `config.global` inside a test body. ## How Test Utils 1.x differed In Vue 2, plugins mutated the single global `Vue` constructor, so Test Utils 1.x offered `createLocalVue` and a `localVue` mounting option to keep installs from leaking. Vue 3 has no global instance to pollute, so Test Utils 2.x removed both and gives each mount its own app. That solved *registration* leaks, which is exactly why people now assume every leak is solved. The state inside the plugin objects you pass was never part of that isolation. The mental model to keep: **Vue Test Utils isolates the app, not the objects you hand it.**

  • Why does Vue not warn when the same router object is installed in test after test?
    Vue's `app.use` keeps its record of installed plugins per app, in a set created by `createApp`. Each `mount` creates a new app, so from Vue's point of view the router is being installed for the first time; the `Plugin has already been applied to target app.` warning only fires when one app sees the same plugin twice.
  • Is it ever right to share one plugin instance across tests?
    Only when the plugin holds no state that tests change, such as a plugin that only registers components or directives. Then sharing it, even through `config.global.plugins`, is harmless and saves setup. Anything with a current location, a cache or mutable state should be built per test.

Each mount is a freshly rented flat, but if you carry the same suitcase into every flat, whatever you packed in the last one is still inside it.

saying these in an interview costs you the question

  • Because mount creates a new app, everything passed to global.plugins is fresh too.
  • Vue warns when a plugin instance is reused, so silence means isolation is fine.
  • Order-dependent failures in component tests mean the component itself is flaky.
  • Putting the router in config.global.plugins isolates it per test automatically.
  • Unmounting wrappers is optional because the next mount replaces the DOM anyway.