A Vue Test Utils suite that shallow-mounts nearly everything stays green while production breaks; which bugs do its stubs hide, and how would you restructure it?
answer
- a stub accepts whatever you tell it
- event names nobody checks
- slots that never render
- hooks and directives switched off
- full mount, stub the boundaries
basics
~20 sStubs drop the child's side of each contract: any emitted event name is accepted, slots do not render, and setup, hooks, inject, transition hooks and stubbed directives never run. Default to full mount, stubbing only expensive boundaries.
solid answer
~40 sVue Test Utils' automatic stubs keep a child's props declaration and nothing else. So a shallow suite misses: an event the child renamed (the test drives the parent with `stub.vm.$emit('select')`, which any stub accepts because it declares no emits); broken slot templates the parent passes to children (stubs render no slots, and `renderStubDefaultSlot` covers only the default slot); a child's missing `inject`, failing `onMounted` or wrong fallthrough attrs; logic in transition JavaScript hooks under the default `<Transition>` stub; and directive behaviour stubbed with `vTooltip: true`. I would flip the default to `mount`, stub by name only true boundaries such as charts, maps or third-party widgets, give each child a test of its own emitted contract through real DOM interaction, and keep a few un-stubbed integration tests across the parent-child seams that change most.
code
ts · 19 linesimport { mount, shallowMount } from '@vue/test-utils'
import { nextTick } from 'vue'
import ReportPage from './ReportPage.vue'
import SalesChart from './SalesChart.vue'
// Green even after SalesChart renamed its event to 'point-select':
test('over-stubbed: drives the parent through a stand-in', async () => {
const wrapper = shallowMount(ReportPage)
wrapper.findComponent(SalesChart).vm.$emit('select', { month: 'Jan' })
await nextTick()
expect(wrapper.text()).toContain('Jan')
})
// Fails on the rename, because the real chart emits from a real click:
test('child contract: SalesChart emits point-select on bar click', async () => {
const chart = mount(SalesChart, { props: { series: [{ month: 'Jan', total: 1 }] } })
await chart.get('[data-test="bar-Jan"]').trigger('click')
expect(chart.emitted('point-select')?.[0]).toEqual([{ month: 'Jan' }])
})go deeper
Understand that a stub is a stand-in that keeps a child's props and nothing else, so a test using stubs cannot see the child's behaviour.
List what the automatic stub drops: template, setup, hooks, emits declaration, inject and slots. Explain why vm.$emit on a stub accepts any event name.
Diagnose an over-stubbed suite by finding tests driven through stubs and by breaking a seam on purpose, then restructure toward full mount, stubs only at real boundaries, and per-child emitted-contract tests.
Own the stubbing policy: a documented list of boundary stubs, a ban on global stubs without an owning suite, and seam-mutation checks in review, trading a little speed for contracts that fail loudly.
## Why a stubbed suite can be green and wrong A **stub** in Vue Test Utils is a stand-in component. The automatic one, created by `shallowMount`, `shallow: true` or `global.stubs: { Child: true }`, keeps exactly two things from the real child: **its props declaration** and **its identity**, so `findComponent(Child)` still finds it. Everything else about the child — its template, `setup`, lifecycle hooks, `emits` declaration, `inject` calls, slot rendering — is gone. Each of those missing pieces is one side of a **contract** between parent and child. A shallow test checks the parent's side against a partner that agrees with whatever the parent does. When the real child changes its side, nothing fails. ## The bugs stubs hide | hidden bug | why the stub hides it | |---|---| | child renamed `select` to `point-select` | the stub declares no emits; `stub.vm.$emit('select')` still reaches the parent's handler | | broken slot template passed to a child | stubs render no slots; `renderStubDefaultSlot` renders only the default slot | | child `inject`s something the parent stopped providing | the child's setup never runs, so the missing injection is never read | | child crashes in `onMounted` with the parent's data | hooks never run on a stub | | focus return or `closed` emit in `@after-leave` | the default `<Transition>` stub does not support JavaScript hooks | | tooltip or permission directive misbehaves | `global.stubs: { vTooltip: true }` replaces the directive with a no-op | The first row is the classic: a parent listens with `@select`, the real child now emits `point-select`, the handler is dead in production, and the parent's test — which calls `$emit` on the stub itself — never noticed. Two stubbing behaviours do **not** hide bugs, and it helps to know which. Because the automatic stub copies the child's props declaration, **prop validators and `required` warnings still run** on the values the parent passes. And a stub can still be found by the original definition, so a test does not silently lose track of which child it is asserting on. ## Diagnosing an over-stubbed suite 1. **Search for `shallowMount`, `shallow: true` and `stubs:`** and count how many suites stub components they then drive with `vm.$emit`. 2. **Check for slot assertions under shallow** — any expectation about text passed into a child's slot is either failing or asserting on nothing. 3. **Look for `global.stubs` in the shared setup file**: a global stub silently applies to every test in the project. 4. **Mutation-test a seam**: rename an emitted event or a slot name in a child and see how many tests fail. Zero failures means the seam is untested. ## Restructuring the suite - **Default to `mount`.** Render real children and stub by name only at boundaries that are slow, nondeterministic or not yours: canvas charts, maps, rich-text editors, embedded third-party widgets. Keep a short, documented list of these. - **Test each child's outgoing contract in its own suite.** Drive the child with real DOM interaction (`trigger`, `setValue`) and assert its `emitted()` event name and payload, so a rename fails where it happens. - **Keep a few integration tests across seams** that change often — parent plus real child plus real slot content — so a mismatch between the two sides fails at least once. - **Turn the transition stub off** where hook logic matters, with `global: { stubs: { transition: false } }`. - **Use `stubs: { Child: false }`** to un-stub the one child a shallow test is actually about, rather than asserting through a stand-in. - **Keep custom stubs inert and typed.** A stub that declares props and renders a `data-test` hook is fine; a stub that reimplements behaviour is a second implementation drifting from the first. ## When shallow is still the right tool Shallow rendering still earns its place for a **container** whose only job is choosing which children to render with which props, or a layout composed of many expensive widgets. There, the parent-side contract is the whole point, and the children's contracts belong to their own tests. The rule of thumb from the Vue Test Utils guide holds: the more you stub, the less production-like the test becomes, so every stub should be justified by what it removes and paired with a test that covers what it hides.
- If stubs copy the child's props, does a shallow test catch a parent passing a wrongly typed prop?Partly. Because the automatic stub copies the props declaration, Vue's dev-mode prop validation still runs, so a wrong type or missing `required` prop produces a warning. It only fails the test if the suite treats Vue warnings as failures, or asserts on `props()`. It still cannot catch the child mishandling a valid value.
- Where should a project-wide stub such as a charting widget live, and what is the risk?In the shared setup via `config.global.stubs`, next to a comment naming why it is stubbed and which suite tests the real widget. The risk is invisibility: a global stub silently applies to every mount, so an engineer writing a new test may not realise the component they are asserting on is a stand-in.
- How would you prove that a given parent-child seam is actually tested?Break it on purpose: rename the child's emitted event or a slot name, run the suite and count the failures. If nothing fails, the seam is covered only by tests that stub the child, and you add a test that uses the real child or checks the child's own `emitted()` contract.
saying these in an interview costs you the question
- A stub emitting the event proves the real child emits it too.
- renderStubDefaultSlot makes shallow tests cover every slot a parent passes.
- Stubbed children still run onMounted, so side-effect bugs surface anyway.
- Stubs discard props, so prop validation never runs under shallow.
- More stubbing always gives faster tests with no loss of coverage.