Skip to content
advanced Phase 13 · Testing with Vitest and Vue Test Utils

Mocking Modules and Stubbing Children

Replace fetch, router, and child components for deterministic tests.

45m
0 problems
Topic Progress 0%

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() or await 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?

Question 1 options

2. Which boundary should a component test for OrderList mock?

Question 2 options

3. What exactly does shallowMount(CheckoutPage) render?

Question 3 options

4. A test hangs right after vi.useFakeTimers(). What is the actual cause?

Question 4 options

Flashcards

Question

What does vi.mock's hoisting mean for a factory's variables?

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?

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?

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?

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.

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.

Continue Learning