Comparing Vue Test Utils With Testing Library

Vue has two mainstream component-testing libraries, and they are not competitors so much as two layers of the same stack. Vue Test Utils is the official, lower-level library: it mounts a component and hands back a wrapper that exposes the instance, its emitted events, its child components, and fine-grained control over stubbing. @testing-library/vue is built on top of it and deliberately hides all of that, offering only queries that a user could perform and interactions a user could make. This guide compares the two on the questions that decide which to use — what each lets a test depend on, how each behaves under refactoring, and what each makes easy or hard — and ends with a mixed strategy that uses each where it fits. It sits under Vue and Svelte component testing.

Root Cause Analysis

The difference that matters is not syntax but coupling. A Vue Test Utils test can read wrapper.vm.selected, find a child with findComponent(Dropdown), and check wrapper.emitted('change'). Each of those couples the test to an implementation detail: the name of a reactive variable, the identity of a child component, the name of an event. Refactor any of them and the test fails, though the user-visible behaviour is unchanged.

Testing Library removes those handles on purpose. There is no instance, no component tree, no emitted-events list — only the rendered DOM, queried by role, label and text. A refactor that preserves behaviour preserves the tests, and a change that breaks behaviour breaks them, which is the property a test suite most needs.

The cost of that discipline is real in a minority of cases. Some contracts genuinely are about events or component structure — a low-level design-system component whose job is to emit a well-defined event, or a layout component whose job is to render a particular child. Those are awkward to express through the DOM alone, and Vue Test Utils expresses them directly. The mistake is using the lower-level tool by default rather than for those cases.

What each library lets a test depend on Vue Test Utils exposes the component instance, child components, emitted events and the DOM, while Testing Library exposes only the DOM through accessible queries, so tests built on it cannot couple to internals that change during refactoring. Vue Test Utils wrapper wrapper.vm — instance state findComponent — the tree emitted() — event names the DOM Testing Library the DOM, by role and label no instance, no tree, no event list — by design
The dark rows are handles on implementation detail; Testing Library's absence of them is the point.

Reproducible Setup

The same component tested both ways makes the difference concrete. A dropdown that emits the selected option and renders a listbox.

<!-- src/components/SortSelect.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ options: Array<{ value: string; label: string }>; modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [string] }>();
const open = ref(false);
function choose(v: string) { emit('update:modelValue', v); open.value = false; }
</script>

<template>
  <button aria-haspopup="listbox" :aria-expanded="open" @click="open = !open">
    Sort: {{ options.find((o) => o.value === modelValue)?.label }}
  </button>
  <ul v-if="open" role="listbox" aria-label="Sort order">
    <li v-for="o in options" :key="o.value" role="option" :aria-selected="o.value === modelValue" @click="choose(o.value)">{{ o.label }}</li>
  </ul>
</template>

Implementation

Step 1 — The Vue Test Utils version. Direct and concise, and coupled to the internal open flag and the event name.

import { mount } from '@vue/test-utils';
import SortSelect from './SortSelect.vue';

const options = [{ value: 'new', label: 'Newest' }, { value: 'price', label: 'Price' }];

test('VTU: emits the chosen value and closes', async () => {
  const wrapper = mount(SortSelect, { props: { options, modelValue: 'new' } });
  await wrapper.find('button').trigger('click');
  expect(wrapper.vm.open).toBe(true);                               // internal state
  await wrapper.findAll('li')[1].trigger('click');
  expect(wrapper.emitted('update:modelValue')).toEqual([['price']]); // event name
  expect(wrapper.vm.open).toBe(false);
});

Read that test as a specification and notice what it states. It says the component keeps a boolean called open, that the second li element is the one to click, and that the event is named update:modelValue. None of those facts is part of what the component promises its users or its parent; all of them are things a developer might reasonably change next week.

Step 2 — The Testing Library version. Slightly longer, and coupled only to roles, names and what a parent receives.

import { render, screen } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';

test('TL: chooses an option and closes the list', async () => {
  const user = userEvent.setup();
  const onUpdate = vi.fn();
  render(SortSelect, { props: { options, modelValue: 'new', 'onUpdate:modelValue': onUpdate } });

  await user.click(screen.getByRole('button', { name: /sort: newest/i }));
  expect(screen.getByRole('listbox', { name: 'Sort order' })).toBeInTheDocument();

  await user.click(screen.getByRole('option', { name: 'Price' }));
  expect(onUpdate).toHaveBeenCalledWith('price');
  expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});

The same test as a specification reads differently: there is a button labelled with the current sort, it opens a listbox named “Sort order”, choosing “Price” tells the parent price and closes the list. Every one of those is something a user or a parent depends on, and none depends on how the component is built inside. That is also, not coincidentally, a description a product owner could read and agree with.

Step 3 — Refactor the component and watch which test breaks. Rename open to expanded and move the list into a child SortOptions component — a behaviour-preserving refactor.

// after the refactor:
// VTU test:  ✗ wrapper.vm.open is undefined
// TL test:   ✓ still passes — the listbox still appears and closes

Step 4 — Use Vue Test Utils where the contract is internal. A design-system component whose documented API is the event it emits, with a specific payload shape, is legitimately tested at that level.

test('emits a sort event with direction for the analytics wrapper', async () => {
  const wrapper = mount(SortableHeader, { props: { column: 'price' } });
  await wrapper.trigger('click');
  expect(wrapper.emitted('sort')).toEqual([[{ column: 'price', direction: 'asc' }]]);
});
How each test reacts to a behaviour-preserving refactor Renaming internal state and extracting a child component leaves behaviour unchanged; the Vue Test Utils test fails because it read the old variable, while the Testing Library test passes because it only observed the rendered roles. refactor rename state, extract child VTU test fails wrapper.vm.open is undefined TL test passes the listbox still behaves
A failure that reports no user-visible change is a false alarm, and false alarms are what erode trust in a suite.

The distinguishing question is who consumes the contract. An application component’s consumers are users, so its contract is what they see and do. A design-system primitive’s consumers are other components, and its contract can legitimately be an event payload or a slot structure. Asking “who depends on this?” before choosing the library settles most cases without debate.

Step 5 — Avoid shallow mounting as a default. shallowMount stubs every child component, which makes tests fast and blind to integration between parent and child. Stub specific heavy children — a chart, a map — with global.stubs, and let the rest render.

render(Dashboard, { global: { stubs: { RevenueChart: { template: '<div data-testid="chart-stub" />' } } } });

Step 6 — Adopt a written default. A one-line rule in the testing policy — “Testing Library by default; Vue Test Utils for design-system event contracts” — stops each author relitigating the choice, as discussed in writing a testing policy a team will follow.

For teams migrating an existing Vue Test Utils suite, convert opportunistically rather than in a campaign: whenever a test breaks on a refactor, rewrite it with Testing Library instead of fixing its selector. The tests that break most often are exactly the ones most coupled to internals, so this approach converts the worst offenders first and leaves stable tests alone.

Verification

Measure coupling directly. Count the uses of .vm., findComponent and emitted( across component tests; the number should be small and each use should be explainable as a genuine internal contract.

grep -rcE "\.vm\.|findComponent|emitted\(" src --include="*.test.ts" | grep -v ":0" | sort -t: -k2 -rn | head
# src/components/design-system/SortableHeader.test.ts:3
# (a short, deliberate list)

Then run a refactor exercise on one component — rename internal state, extract a child — and count how many tests fail. In a suite built mostly on Testing Library, the answer should be zero, and any test that does fail is a candidate for rewriting.

A mixed strategy for a real Vue codebase Feature and page components are tested with Testing Library, design-system primitives whose contract is an event or slot structure use Vue Test Utils, and heavy children such as charts are stubbed selectively rather than shallow-mounting everything. Testing Library features and pages the large majority Vue Test Utils design-system primitives event and slot contracts selective stubs charts, maps, editors not shallowMount
The tools coexist comfortably when each has a clearly stated job.

Troubleshooting

Symptom: a Testing Library query cannot find an element that is clearly there. Diagnosis: the element lacks the role or accessible name the query asks for — an unlabelled button, a list without a role. Fix: fix the component’s semantics rather than reaching for Vue Test Utils; the difficulty is an accessibility finding.

Symptom: Vue Test Utils tests break on every refactor. Diagnosis: they read instance state or query by component. Fix: rewrite them against the DOM with Testing Library, keeping Vue Test Utils only where the contract is genuinely internal.

Symptom: shallow-mounted tests pass while the page is broken. Diagnosis: every child is stubbed, so parent-child integration is never exercised. Fix: mount fully and stub only heavy or irrelevant children by name.

Symptom: mixing both libraries in one file causes duplicate mounts. Diagnosis: each library mounts separately and cleans up on its own schedule. Fix: use one library per test file, which also makes the choice visible from the file’s imports.

FAQ

Is Vue Test Utils deprecated?

No — it is the official library and actively maintained, and Testing Library for Vue depends on it. The recommendation here is about which API to write most tests against, not about abandoning the underlying tool.

Are Testing Library tests slower?

Negligibly. Both mount the same component through the same Vue runtime; Testing Library adds a thin query layer. User-event’s realistic interaction sequence is marginally slower than trigger, and it is also what makes the test representative of real usage.

What about testing emitted events in Testing Library?

Pass a handler prop such as onUpdate:modelValue and assert it was called, as in Step 2. That tests the event from the parent’s side, which is the side that matters, without reading the component’s emitted-events list.

Does the same reasoning apply to React and Svelte?

Yes. The argument — test through the DOM by default, reach for internals only for genuinely internal contracts — is framework-independent, and it is the same argument made in migrating from Enzyme to React Testing Library.