A component test can check a class name, or it can check what a person sees: a button called "Add", a field labelled "New task". The second kind survives a redesign and catches the bugs users actually meet, such as a text field a screen reader cannot name. Testing Library gives you the queries and simulated user actions for that kind of test.
Such tests often run in happy-dom, a fast imitation of the browser's DOM that runs in Node. Vitest Browser Mode runs the same component in a real Chromium instead: slower, but with real layout, focus and browser APIs. The examples use Vitest 4.1.5, @testing-library/vue 8.1, @testing-library/user-event 14.6, vitest-browser-vue 2.1 and Playwright 1.59, all current on 5 May.
Find elements by role and label
Testing Library is a family of small libraries built on one idea: a test should use your component the way a person does. People do not find a button by its CSS class. They find it by what it says, and screen reader users find it by its role (button, textbox, link) and its accessible name, the text assistive technology reads out.
So the queries come in a recommended order. First getByRole, with a name, for almost everything. Then getByLabelText for form fields, getByPlaceholderText, getByText and getByDisplayValue. getByAltText and getByTitle come after those, and getByTestId is last, for elements nothing else can reach.
There is a useful side effect. If getByRole('button', { name: 'Add' }) cannot find your button, a screen reader user probably cannot either. The test fails for the same reason the page fails.
Write the first test in happy-dom
Here is a small form that emits a task title and clears itself:
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{ add: [title: string] }>()
const title = ref('')
function submit() {
emit('add', title.value.trim())
title.value = ''
}
</script>
<template>
<form @submit.prevent="submit">
<label>
New task
<input v-model="title" type="text">
</label>
<button type="submit" :disabled="!title.trim()">Add</button>
</form>
</template>
Install the runner, the Vue plugin, happy-dom and the three Testing Library packages:
npm install -D vitest @vitejs/plugin-vue happy-dom @testing-library/vue @testing-library/user-event @testing-library/jest-dom
@testing-library/vue unmounts the rendered component after each test only when afterEach is a global, which it is not in Vitest by default. A setup file runs that cleanup explicitly and adds the jest-dom matchers such as toBeDisabled():
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/vue'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})
Then tell Vitest to compile .vue files, run tests in happy-dom and load the setup file first:
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'],
},
})
Now the test:
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { expect, test } from 'vitest'
import AddTodo from './AddTodo.vue'
test('adds a task and clears the field', async () => {
const user = userEvent.setup()
const { emitted } = render(AddTodo)
const input = screen.getByRole('textbox', { name: 'New task' })
const button = screen.getByRole('button', { name: 'Add' })
expect(button).toBeDisabled()
await user.type(input, 'Buy milk')
await user.click(button)
expect(emitted().add).toEqual([['Buy milk']])
expect(input).toHaveValue('')
})
render mounts the component into the document, and screen runs queries against the whole document. emitted() lists every event the component has sent, with its arguments: here one add event carrying 'Buy milk'.
userEvent.setup() creates a simulated user. user.type presses each key in turn, firing the same sequence of events a real keyboard produces, from keydown to keyup, and user.click sends the pointer and mouse events that lead to a click. The older fireEvent dispatches a single event, which skips steps a real interaction includes, so prefer user-event for anything a person does. Every user call returns a promise: await it, then assert.
Catch a label that is not tied to its input
The first version of this form often looks like this:
<label>New task</label>
<input v-model="title" type="text">
It looks identical on screen. The test fails with Unable to find an accessible element with the role "textbox" and name "New task", because nothing ties the label to the input. A screen reader announces an unnamed text field, and clicking the label does not focus the field.
Wrap the input in the label, or connect them with for and an id:
<label>
New task
<input v-model="title" type="text">
</label>
Add a browser project next to happy-dom
Vitest Browser Mode runs test files inside a real browser that Playwright controls, with your Vite config and real CSS. Install the Playwright provider, the Vue renderer for Browser Mode and Playwright, then download Chromium:
npm install -D @vitest/browser-playwright vitest-browser-vue playwright
npx playwright install chromium
Replace vitest.config.ts with two projects, so the happy-dom tests keep running and browser tests get their own. extends: true lets each project inherit the Vue plugin from the root:
import vue from '@vitejs/plugin-vue'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
test: {
projects: [
{
extends: true,
test: {
name: 'unit',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts'],
environment: 'happy-dom',
setupFiles: ['./vitest.setup.ts'],
},
},
{
extends: true,
test: {
name: 'browser',
include: ['src/**/*.browser.test.ts'],
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: 'chromium' }],
},
},
},
],
},
})
The file name picks the project: a file ending in .browser.test.ts runs in Chromium, and every other .test.ts file runs in happy-dom.
Convert the test to Browser Mode
Here is the same test, converted:
import { expect, test } from 'vitest'
import { page } from 'vitest/browser'
import { render } from 'vitest-browser-vue'
import AddTodo from './AddTodo.vue'
test('adds a task and clears the field', async () => {
const screen = await render(AddTodo)
const input = page.getByRole('textbox', { name: 'New task' })
const button = page.getByRole('button', { name: 'Add' })
await expect.element(button).toBeDisabled()
await input.fill('Buy milk')
await button.click()
expect(screen.emitted().add).toEqual([['Buy milk']])
await expect.element(input).toHaveValue('')
})
Five things changed:
rendercomes fromvitest-browser-vueand is awaited. Its result, namedscreenhere, still hasemitted().page.getByRolereturns a locator: a description of how to find the element, resolved each time you use it, so it never goes stale.fillandclickare real browser input sent through Playwright, not events created in JavaScript.expect.elementretries the assertion until it passes or times out, which removes the timing guesswork of DOM tests.- The browser test needs no setup file. Vitest's browser assertions include matchers like
toBeDisabled()andtoHaveValue(), andvitest-browser-vueunmounts the previous component before each test starts rather than after it ends, so the last render stays visible in the browser UI.
Screenshots and traces when a test fails
When a browser test fails in a headless run, Vitest takes a screenshot of the page and prints its path under Failure screenshot: in the terminal. The file goes into a __screenshots__ folder next to the test file. The option behind it, browser.screenshotFailures, is on by default except in the Vitest UI, where you can already see the page. Open the image and you see exactly what the user would have seen: often a dialog still covering the button, or an error message you did not expect.
For harder cases, add trace: 'retain-on-failure' under test.browser. A failing test then leaves a Playwright trace in a __traces__ folder next to the test file, which you open with npx playwright show-trace. Vitest 4.1 groups each click, fill and expect.element in the trace timeline and links it to the line in your test, and with vitest-browser-vue 2.1 the render appears there too.
Decide which tests need a real browser
Browser tests are slower: each run starts a browser, and each action makes a round trip to Playwright. In return you get a real layout engine. happy-dom has none at all: every element measures zero by zero, nothing scrolls, and visibility is a guess based on styles.
Move a test into Browser Mode when the component depends on something happy-dom only imitates:
- Layout and measurement: virtual lists, tooltips that position themselves, anything using
getBoundingClientRectorResizeObserver. - Focus and keyboard behaviour: focus traps in dialogs, roving focus in menus, focus returning after a modal closes.
- Real browser APIs:
<dialog>,IntersectionObserver, canvas, clipboard. - How it looks, with screenshot assertions.
Keep logic tests in happy-dom or plain Node: composables, stores, formatting, and forms like this one where the question is "what was emitted". The form test above is a good example to practise converting, but in a real project it can stay in happy-dom.
npx vitest runs both projects. While you work, npx vitest --project unit runs only the fast happy-dom tests.