With Vue Test Utils, why does `global.mocks: { $router }` not affect a `<script setup>` component that calls `useRouter()`, and what works instead?
answer
- how does useRouter find the router
- instance property vs injection
- match the fake to the read
- provide by key, or install the plugin
basics
~20 sglobal.mocks only adds instance properties such as this.$router, while useRouter() calls inject with the router's injection key. Supply that injection instead: provide a fake under the exported routerKey through global.provide, or install a real router through global.plugins.
solid answer
~40 s`global.mocks` in Vue Test Utils puts values on the **component instance**, so it reaches `this.$router` in the Options API and `$router` in a template. `useRouter()` never looks there: in Vue Router it is `inject(routerKey)`, a read of the provides chain. The rule is to fake the channel the component actually reads. For a composable that injects, either pass `global.provide: { [routerKey]: fakeRouter }` using the key Vue Router exports, or install a real router through `global.plugins`, whose `install` provides it. One ordering detail: Vue Test Utils applies `global.provide` **before** `global.plugins`, so a plugin that provides the same key overwrites your fake, with a dev warning that the app already provides that key.
code
ts · 16 linesimport { mount } from '@vue/test-utils'
import { routerKey } from 'vue-router'
import SaveButton from './SaveButton.vue'
test('navigates to the list after saving', async () => {
const push = vi.fn()
const wrapper = mount(SaveButton, {
global: {
// mocks: { $router: { push } } would NOT reach useRouter()
provide: { [routerKey as symbol]: { push } }
}
})
await wrapper.find('button').trigger('click')
expect(push).toHaveBeenCalledWith('/items')
})go deeper
Recall that mocks fakes $-prefixed instance properties and provide feeds inject, and that composables like useRouter usually inject.
Explain the two plugin channels, global properties and app.provide, and show how to provide a fake under an exported injection key.
Diagnose a test that passes against the wrong collaborator: know that plugins install after provide and that the overwrite only shows as a dev warning.
Set a suite convention for which channel components use, so faking stays predictable, and decide when a real plugin install is worth its setup cost.
## Two ways a component can reach a plugin A Vue 3 plugin usually exposes itself to components through one or both of two channels: - **Global instance properties.** During `app.use(plugin)` the plugin writes something like `$router` or `$t` onto `app.config.globalProperties`. Components then read it as `this.$router` in the Options API or `$router` in a template. - **Injections.** The plugin calls `app.provide(someKey, value)`. Composables such as `useRouter()` or `useI18n()` read it with `inject(someKey)` from inside `setup`. The Composition API, and therefore `<script setup>`, leans almost entirely on the second channel. Vue Router's `useRouter()` is literally `inject(routerKey)`, where `routerKey` is a `Symbol` the library exports. ## What `global.mocks` does and does not touch `global.mocks` in Vue Test Utils is documented as a way to mock **global instance properties** injected by third-party plugins. Its implementation is a mixin: 1. In `beforeCreate` of every component, it copies each mock onto the instance. 2. For `<script setup>` components it also wraps the render proxy, so a template read of `$router` returns the mock. Nothing in that process calls `app.provide`. So a component that does `const router = useRouter()` gets whatever `inject(routerKey)` finds, which is nothing. In development Vue warns that the injection was not found, `router` is `undefined`, and the first `router.push(...)` throws. | How the component reads the plugin | Channel | Test option that reaches it | |---|---|---| | `this.$router`, `$router` in a template | instance property | `global.mocks` or `global.config.globalProperties` | | `useRouter()`, `inject(routerKey)` | provides chain | `global.provide` with the same key | | either | both | the real plugin in `global.plugins` | ## Option A: provide a fake under the real key Because the key is exported, the test can satisfy the injection directly: ```ts import { mount } from '@vue/test-utils' import { routerKey } from 'vue-router' const push = vi.fn() mount(SaveButton, { global: { provide: { [routerKey as symbol]: { push } } } }) ``` This is a narrow fake: it contains only what the component calls. It proves the component asks for the right navigation, and nothing more. ## Option B: install the real plugin Passing the real router in `global.plugins` makes Vue Test Utils call `app.use(router)`, so both channels are filled exactly as in the app. That is the right choice when the test depends on real routing behaviour, such as `<RouterLink>` rendering or route params. How to build and prepare a router for tests is Vue Router's own subject. ## Option C: replace the composable module Some teams mock the module that exports `useRouter` with the test runner's module mocking. That works regardless of channel, but it is the runner's feature, not Vue Test Utils', and it couples the test to an import path. ## The ordering trap when you mix A and B Vue Test Utils processes the merged global options in a fixed order: the mocks mixin, app config, then `provide`, then `plugins`, then mixins, components and directives. If a test provides a fake under `routerKey` **and** installs a real router, the router's `install` calls `app.provide(routerKey, ...)` afterwards and wins. In development Vue prints `App already provides property with key "Symbol(router)". It will be overwritten with the new value.` The test then silently runs against the real router while the author believes the fake is in place. ## The general rule - Read the component first: does it use `$something` or `useSomething()`? - Fake **the same channel** it reads, or install the real thing. - Prefer injection-based fakes for `<script setup>` code, since that is how nearly every composable-based library is consumed. - Keep `global.mocks` for Options API components and template globals such as `$t`. The same reasoning applies to any plugin that ships a `useX()` composable: check what the composable injects, and whether the library exports the key. ## Why the fake-by-key approach stays small A provided fake only has to implement what the component calls. For a save button that calls `router.push('/items')`, the fake is an object with one spy. That keeps the test fast and focused, but it also means the test proves nothing about whether `/items` is a real route. Pair such unit tests with a few tests that install the real plugin, so a renamed route or a changed navigation API is still caught somewhere in the suite.
- The same component also renders `$route.params.id` in its template. Does the provided fake cover that?No. A template `$route` is an instance property, which the real router adds through global properties. The provided fake only answers `inject(routerKey)`. Either also mock `$route` with `global.mocks`, change the component to read `useRoute()` consistently, or install a real router so both channels are filled.
- How do you tell, without reading library source, which channel a plugin's composable uses?Mount without the plugin and read the dev warnings. A missing injection produces `injection "..." not found.`, naming the key; a missing global property produces a warning that it was accessed during render but is not defined on the instance. The warning names the channel you have to fill.
saying these in an interview costs you the question
- global.mocks registers its values with app.provide, so composables see them too.
- useRouter() reads this.$router, so mocking $router covers it.
- Providing a fake and installing the real plugin together means the fake wins.
- You can never test a useRouter() component without the real router.
- A string key 'router' in global.provide satisfies inject(routerKey).