Replace fetch and the router at the module boundary with vi.mock
Your OrderList component imports fetchOrders from @/api/orders, and that function hits a real HTTP endpoint. A component test that needs three different order sets must not depend on a server, a fixture database, or network timing — so the test replaces the module before the component ever imports it. That is vi.mock.
import { mount } from '@vue/test-utils'
import OrderList from '@/components/OrderList.vue'
import { fetchOrders } from '@/api/orders'
vi.mock('@/api/orders', () => ({
fetchOrders: vi.fn(),
}))
test('renders rows returned by the API', async () => {
vi.mocked(fetchOrders).mockResolvedValue([
{ id: 1, total: 42 },
{ id: 2, total: 17 },
])
const wrapper = mount(OrderList)
await vi.waitFor(() => expect(wrapper.findAll('li')).toHaveLength(2))
expect(wrapper.text()).toContain('42')
})
Two mechanics make this work, and both regularly surprise people.
Hoisting. Vitest lifts every vi.mock() call to the top of the file, above your imports — the factory runs before the imported module would normally be evaluated. That is what lets the factory replace @/api/orders before OrderList.vue resolves its import. The flip side: the factory cannot reference variables declared later in the file. Writing vi.mock('@/api/orders', () => ({ fetchOrders })) when fetchOrders is a const declared below fails with ReferenceError: Cannot access 'fetchOrders' before initialization. The sanctioned escape hatch is vi.hoisted:
const fetchOrders = vi.hoisted(() => vi.fn())
vi.mock('@/api/orders', () => ({ fetchOrders }))
Where the mock lives. vi.mock operates on Vitest's module registry: any module importing the path gets your factory's exports instead of the real file — the component, other tests, deep transitive imports. That makes it a module-boundary tool. You mock the boundary (@/api/orders, vue-router's useRouter, a websocket client), never the internal helpers that boundary wraps.
When you only need part of a module replaced — say, the real formatters should keep running while the network call is faked — compose the factory from the real module:
vi.mock('@/api/orders', async (importOriginal) => ({
...(await importOriginal()),
fetchOrders: vi.fn(),
}))
Omitting the factory (vi.mock('@/api/orders')) asks Vitest to automock: every export becomes a vi.fn() that returns undefined. That is fine for a module of pure functions you only need to assert calls on, and wrong the moment the module exports config objects, constants, or anything with side effects — they come back hollow, and the component fails in ways that look like your bug. A factory states your intent explicitly, and it is the pattern to reach for when the module doubles as a contract, which is exactly what a router module is. Components import useRouter() everywhere; replace the hook while keeping the real navigation types:
const push = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
useRouter: () => ({ push, replace: vi.fn() }),
}))
test('navigates to the order after submit', async () => {
await wrapper.get('form').trigger('submit')
expect(push).toHaveBeenCalledWith('/orders/1')
})
The partial spread matters: vue-router also exports types, createRouter, and components you may still need, and a full factory replacement would hollow all of them out. In TypeScript, vi.mocked(fetchOrders) does not wrap anything at runtime — it is a type cast that tells the compiler the import is a mock, so mockResolvedValue type-checks. The mock itself came from vi.fn() in the factory.
Globals need vi.stubGlobal('fetch', ...) rather than vi.mock, because fetch is not imported; pair every stub with vi.unstubAllGlobals() in afterEach so the browser API does not leak into the next test.
The failure mode of over-mocking is a suite that tests nothing: mock the composable that computes totals, mock the helper that formats dates, mock the store the component reads — and now the test only verifies that mocks return what you told them to return. Real regressions (a wrong discount formula, a date rendered in the wrong timezone) sail through green. Mock the boundary you cannot control; run everything else for real. Determinism comes from replacing the uncontrolled edge — network, time, third-party SDKs — not from replacing your own code.
Module mocking is half the isolation toolkit. The other half replaces things that are not modules at all: child components rendered inside your template, which the next chapter stubs.
Stub children so only the parent's logic runs
You are testing CheckoutPage: it reads the cart, renders three child components (CartSummary, AddressForm, PaymentBox), and enables the submit button only when the cart is valid. You care about the enable/disable logic, not about PaymentBox initializing a payment SDK. Rendering the whole tree costs time and drags in dependencies that need their own mocks. Stubbing replaces the irrelevant children with empty shells.
import { mount, shallowMount } from '@vue/test-utils'
import CheckoutPage from '@/views/CheckoutPage.vue'
test('disables submit while the address is incomplete', async () => {
const wrapper = shallowMount(CheckoutPage, {
props: { cartValid: true, addressComplete: false },
})
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeDefined()
await wrapper.setProps({ addressComplete: true })
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined()
})
shallowMount stubs every child automatically — each one renders as a <child-name-stub> element that passes props through as attributes but runs none of the child's template, script, or lifecycle. The props still show up on the stub, which is genuinely useful: you can assert what the parent passed without executing the child:
expect(wrapper.findComponent(CartSummary).props()).toEqual({ total: 59 })
Selective stubbing keeps the children you do care about. Both mount and shallowMount accept stubs, and per-mount stubs live under global — the same per-test scoping you use for plugins in Mounting Components in Tests:
const wrapper = mount(CheckoutPage, {
global: {
stubs: {
PaymentBox: true, // default shell
CartSummary: { template: '<div class="summary" />' }, // custom rendering
'router-link': true, // stub by tag name
},
},
})
A custom stub with a template is how you keep slot content or a specific hook visible — for example, stubbing PaymentBox with <div><slot name="error" /></div> when the test's claim is that the parent fills the error slot.
To drive a stubbed child instead of merely inspecting it, emit the event the real child would emit:
await wrapper.findComponent(AddressForm).vm.$emit('change', { zip: '10115' })
expect(wrapper.get('[data-test="submit"]').attributes('disabled')).toBeUndefined()
That simulates the child's contribution to the parent's behavior without running a line of the child's code.
Every stub is a deliberate blind spot, so the trade-off table matters more than the syntax:
| Strategy | Runs | Hides | Use when |
|---|---|---|---|
mount (no stubs) |
whole tree | nothing | critical end-to-end flows |
mount + global.stubs |
everything except chosen children | specific expensive children | parent logic with one heavy dependency |
shallowMount |
parent only | every child | parent's own state and event wiring |
| Real children everywhere | whole tree | nothing | integration tests proving props/events connect |
Stubbing hides prop-name typos (a child receiving total when it declares amount never complains under a stub), missing slot content, and child-side rendering bugs. That is why a healthy suite keeps at least one unstubbed test for each critical flow — stubs sharpen the unit tests; they do not replace the integration coverage.
One naming note that saves debugging time: events and props are matched against the component's registered name, so <MyChild> in a template stubs as stubs: { MyChild: true } or stubs: { 'my-child': true }; both work, but consistency with how the component is registered avoids a stub that silently does nothing.
The reverse switch exists too. If a shared setup file stubs PaymentBox for the whole suite — because it initializes a payment SDK nobody wants running in tests — a single test can opt back into the real child with stubs: { PaymentBox: false }. That combination is how you keep the default suite fast while preserving one unstubbed integration test per critical flow: stubs off by exception, real children by explicit request, never by accident.
Stubs replace components. The third isolation tool replaces waiting — the clock itself, which vi.useFakeTimers() hands you in the next chapter.
Advance fake timers instead of waiting on setTimeout
A search box that debounces 300 ms before querying is untestable with real time: either the test waits a third of a second per case, or it asserts before the debounce fires and fails intermittently. Fake timers remove the clock from the equation — the test decides when time passes.
import { mount } from '@vue/test-utils'
import SearchBox from '@/components/SearchBox.vue'
test('queries only after typing stops', async () => {
vi.useFakeTimers()
const wrapper = mount(SearchBox, {
global: { stubs: { SearchResults: true } },
})
await wrapper.get('input').setValue('vue')
vi.advanceTimersByTime(299)
expect(searchSpy).not.toHaveBeenCalled() // still inside the debounce window
vi.advanceTimersByTime(1)
await flushPromises()
expect(searchSpy).toHaveBeenCalledWith('vue')
vi.useRealTimers()
})
Vitest fakes setTimeout, setInterval, Date, and friends (configure exactly which with vi.useFakeTimers({ toFake: [...] })). The pair of moves — advance, then await the work the timer kicked off — is the shape of every timer test. Always restore in afterEach: a fake Date or a pending fake interval that survives into the next test produces failures that point at innocent code.
Faking Date deserves special mention because it tests rendered time without waiting for it. A list row that shows "Expires in 2 days" is computed from Date.now() at render time, so fix the clock and the assertion becomes exact:
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-03-01T09:00:00Z'))
const wrapper = mount(OrderRow, { props: { expiresAt: '2026-03-03' } })
expect(wrapper.text()).toContain('Expires in 2 days')
This is also the correct tool for testing expiry banners, relative timestamps ("5 minutes ago"), and subscription countdowns — and it doubles as a hydration-safety check later: anything that reads the fakeable Date in a render path must still wait until after mount when the server has no clock to agree with. When a test finishes with timers still fake, vi.getTimerCount() tells you how many pending timers leaked; assert it is zero before vi.useRealTimers() and a forgotten advance becomes a visible failure instead of a mystery flake.
Two interaction traps catch most people:
- Microtasks stay real. Advancing a timer fires the callback, but promise continuations still need
await flushPromises()orawait vi.advanceTimersByTimeAsync(ms)before assertions see their effects. - Real waits deadlock. Code that does
await new Promise(r => setTimeout(r, 5000))never resolves under fake timers unless you advance. If a test hangs after enabling fake timers, something in the component or a library is awaiting the clock you just froze — advance it explicitly, or drop fake timers for that test.
With module mocks, component stubs, and fake timers in hand, the selection becomes a short decision:
| The dependency is… | Tool | Key rule |
|---|---|---|
| an imported module (API, SDK, router) | vi.mock |
mock at the boundary; partial via importOriginal |
| a child component in the template | stubs / shallowMount |
keep one unstubbed flow test |
time (setTimeout, debounce, Date) |
vi.useFakeTimers() |
advance, await, restore |
the browser global (fetch, matchMedia) |
vi.stubGlobal |
unstub in teardown |
| response timing inside a promise | deferred promise resolved by the test | never a real sleep |
The question to keep asking is what claim the test makes. A claim about the parent's logic demands the parent run for real and its edges be faked; a claim about the whole flow demands real children and only faked time and network. Collapsing every test into "mock everything" produces a suite that is green, fast, and worthless — mocks are scaffolding around your code, and your code itself should run.
That is the complete isolation story: modules replaced with vi.mock at their boundary, children replaced with stubs only where they drown the unit under test, and time replaced with a controllable clock. The suite you can now write — deterministic, order-independent, fast — is the exact suite Testing Composables in Isolation prepared you for, and it is the testing floor the roadmap's performance work builds on next: Profiling Render Performance.
Quiz
1. A factory references a const declared below it: `vi.mock('@/api/orders', () => ({ fetchOrders }))`. What happens?
2. Which boundary should a component test for OrderList mock?
3. What exactly does shallowMount(CheckoutPage) render?
4. A test hangs right after vi.useFakeTimers(). What is the actual cause?
Flashcards
Question
What does vi.mock's hoisting mean for a factory's variables?
Click to reveal answer
Answer
vi.mock runs above imports and top-level declarations, so factories cannot close over later consts (ReferenceError). Wrap factory dependencies in vi.hoisted(() => ...) so they exist in the hoisted region.
Question
When do you use a partial mock with importOriginal?
Click to reveal answer
Answer
When only part of a module should be replaced — spread importOriginal() in the factory and override the uncontrolled export (e.g. keep vue-router's real utilities while faking useRouter). A full factory hollows out every export.
Question
What do stubs hide that a healthy suite must still cover?
Click to reveal answer
Answer
Prop-name typos, slot content, and child-side rendering bugs. Keep at least one unstubbed test per critical flow; stubs sharpen unit tests, they do not replace integration coverage.
Question
What is the correct fake-timer sequence in a test?
Click to reveal answer
Answer
vi.useFakeTimers() → act → vi.advanceTimersByTime(ms) → await flushPromises()/nextTick → assert → vi.useRealTimers() (ideally in afterEach). Microtasks stay real, so awaiting still matters after advancing.
Question
State the rule for what to mock in a component test.
Click to reveal answer
Answer
Mock at module boundaries you cannot control (network, SDKs, router calls, clock, browser globals); run your own code for real. Over-mocking internal helpers lets regressions pass green.
Revision Notes
Key Takeaways
- 1. vi.mock is hoisted above imports, which is what replaces a module before the component loads it — and why factory dependencies must use vi.hoisted
- 2. Mock the boundary you cannot control (network, SDKs, clock); internal code runs for real or the suite verifies only your mocks
- 3. Stubs and shallowMount isolate a parent but hide prop typos, slots, and child bugs — keep one unstubbed test per critical flow
- 4. Fake timers control setTimeout and Date: advance, await microtasks (they stay real), then restore — a hang is a clock you forgot to advance
- 5. Choose the tool from the test's claim: module mock, child stub, fake time, global stub, or test-owned deferred promise
Interview Tips
- • "What's the difference between vi.mock and a stub?" — vi.mock replaces an imported module in the module registry before import; a stub replaces a rendered child component. Different layers, different claims
- • "Why did my vi.mock factory throw a ReferenceError?" — hoisting; answer with vi.hoisted and explain that the factory executes above the file's declarations
- • "How do you test a debounce?" — fake timers, advance in two steps to prove the boundary (299 ms no call, 300 ms call), await the resulting promises, restore real timers
- • "When is mocking harmful?" — when it covers your own helpers; state the boundary rule and mention that fully mocked suites stay green while real regressions ship
Cheat Sheet
Mocking Modules and Stubbing Children
Module boundary:
const fetchOrders = vi.hoisted(() => vi.fn())
vi.mock('@/api/orders', () => ({ fetchOrders })) // or partial:
vi.mock('@/api/orders', async (importOriginal) => ({
...(await importOriginal()), fetchOrders: vi.fn(),
}))
vi.mocked(fetchOrders).mockResolvedValue([...]) // types-only cast
vi.stubGlobal('fetch', vi.fn()) + vi.unstubAllGlobals() in afterEach
Children:
| Tool | Effect |
|---|---|
shallowMount |
every child stubbed |
mount + global.stubs |
chosen children stubbed (true or custom template) |
stubs: { X: false } |
opts one test back into the real child |
findComponent(X).vm.$emit(...) |
simulates the child's contribution |
Time:
vi.useFakeTimers()
vi.advanceTimersByTime(300); await flushPromises()
vi.setSystemTime(new Date('2026-03-01T09:00:00Z'))
expect(vi.getTimerCount()).toBe(0); vi.useRealTimers()
Pick by claim: module → vi.mock; child → stubs; time → fake timers; browser global → vi.stubGlobal; response timing → test-owned deferred promise. Never a real sleep, never your own helpers.