---
title: "Testing Library and Vitest Browser Mode for Vue · vuejs.school"
description: "Write Vue component tests that find elements by role and label, type like a real user, and then move the same test into a real browser with Vitest."
canonical_url: "https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue"
meta:
  "og:description": "Write Vue component tests that find elements by role and label, type like a real user, and then move the same test into a real browser with Vitest."
  "og:image": "https://vuejs.school/_og/d/a_~VGVzdGluZyBMaWJyYXJ5IGFuZCBWaXRlc3QgQnJvd3NlciBNb2RlIGZvciBWdWUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Testing%2BLibrary%2Band%2BVitest%2BBrowser%2BMode%2Bfor%2BVue%2Cdescription_~V3JpdGUgVnVlIGNvbXBvbmVudCB0ZXN0cyB0aGF0IGZpbmQgZWxlbWVudHMgYnkgcm9sZSBhbmQgbGFiZWwsIHR5cGUgbGlrZSBhIHJlYWwgdXNlciwgYW5kIHRoZW4gbW92ZSB0aGUgc2FtZSB0ZXN0IGludG8gYSByZWFsIGJyb3dzZXIgd2l0aCBWaXRlc3Qu%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLWxpYnJhcnktYW5kLXZpdGVzdC1icm93c2VyLW1vZGUtZm9yLXZ1ZSI%2Cs_wnPiEHhTjcNJwxfJ.png"
  "og:image:alt": "Testing Library and Vitest Browser Mode for Vue · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Testing Library and Vitest Browser Mode for Vue · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~VGVzdGluZyBMaWJyYXJ5IGFuZCBWaXRlc3QgQnJvd3NlciBNb2RlIGZvciBWdWUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Testing%2BLibrary%2Band%2BVitest%2BBrowser%2BMode%2Bfor%2BVue%2Cdescription_~V3JpdGUgVnVlIGNvbXBvbmVudCB0ZXN0cyB0aGF0IGZpbmQgZWxlbWVudHMgYnkgcm9sZSBhbmQgbGFiZWwsIHR5cGUgbGlrZSBhIHJlYWwgdXNlciwgYW5kIHRoZW4gbW92ZSB0aGUgc2FtZSB0ZXN0IGludG8gYSByZWFsIGJyb3dzZXIgd2l0aCBWaXRlc3Qu%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLWxpYnJhcnktYW5kLXZpdGVzdC1icm93c2VyLW1vZGUtZm9yLXZ1ZSI%2Cs_wnPiEHhTjcNJwxfJ.png"
  "twitter:image:alt": "Testing Library and Vitest Browser Mode for Vue · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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](https://testing-library.com/docs/queries/about/). 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:

*src/components/AddTodo.vue*

```
<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()`:

*vitest.setup.ts*

```
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:

*vitest.config.ts*

```
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:

*src/components/AddTodo.test.ts*

```
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:

*src/components/AddTodo.vue**Breaks***

```
<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`:

*src/components/AddTodo.vue**Works***

```
<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:

*vitest.config.ts*

```
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:

*src/components/AddTodo.browser.test.ts*

```
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:

- `render` comes from `vitest-browser-vue` and is awaited. Its result, named `screen` here, still has `emitted()`.
- `page.getByRole` returns a locator: a description of how to find the element, resolved each time you use it, so it never goes stale.
- `fill` and `click` are real browser input sent through Playwright, not events created in JavaScript.
- `expect.element` retries 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()` and `toHaveValue()`, and `vitest-browser-vue` unmounts 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 `getBoundingClientRect` or `ResizeObserver`.
- 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.

**Check it stuck**3 questions from the Testing Library and browser mode quiz.

[**Practice now**](https://vuejs.school/learn/testing/testing-library-and-browser-mode?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 6 min read**Vitest 5: what changed for Vue tests**](https://vuejs.school/community/blog/vitest-5-what-changed-for-vue-tests) [Release notes · 6 min read**Vitest 5 release candidate: what's coming for Vue tests**](https://vuejs.school/community/blog/vitest-5-rc-whats-coming)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-05-05","description":"Write Vue component tests that find elements by role and label, type like a real user, and then move the same test into a real browser with Vitest.","name":"Testing Library and Vitest Browser Mode for Vue","url":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue","breadcrumb":{"@id":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-05-05","description":"Write Vue component tests that find elements by role and label, type like a real user, and then move the same test into a real browser with Vitest.","headline":"Testing Library and Vitest Browser Mode for Vue","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~VGVzdGluZyBMaWJyYXJ5IGFuZCBWaXRlc3QgQnJvd3NlciBNb2RlIGZvciBWdWUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Testing%2BLibrary%2Band%2BVitest%2BBrowser%2BMode%2Bfor%2BVue%2Cdescription_~V3JpdGUgVnVlIGNvbXBvbmVudCB0ZXN0cyB0aGF0IGZpbmQgZWxlbWVudHMgYnkgcm9sZSBhbmQgbGFiZWwsIHR5cGUgbGlrZSBhIHJlYWwgdXNlciwgYW5kIHRoZW4gbW92ZSB0aGUgc2FtZSB0ZXN0IGludG8gYSByZWFsIGJyb3dzZXIgd2l0aCBWaXRlc3Qu%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLWxpYnJhcnktYW5kLXZpdGVzdC1icm93c2VyLW1vZGUtZm9yLXZ1ZSI%2Cs_wnPiEHhTjcNJwxfJ.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/testing-library-and-vitest-browser-mode-for-vue#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Testing Library and Vitest Browser Mode for Vue","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~VGVzdGluZyBMaWJyYXJ5IGFuZCBWaXRlc3QgQnJvd3NlciBNb2RlIGZvciBWdWUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Testing%2BLibrary%2Band%2BVitest%2BBrowser%2BMode%2Bfor%2BVue%2Cdescription_~V3JpdGUgVnVlIGNvbXBvbmVudCB0ZXN0cyB0aGF0IGZpbmQgZWxlbWVudHMgYnkgcm9sZSBhbmQgbGFiZWwsIHR5cGUgbGlrZSBhIHJlYWwgdXNlciwgYW5kIHRoZW4gbW92ZSB0aGUgc2FtZSB0ZXN0IGludG8gYSByZWFsIGJyb3dzZXIgd2l0aCBWaXRlc3Qu%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLWxpYnJhcnktYW5kLXZpdGVzdC1icm93c2VyLW1vZGUtZm9yLXZ1ZSI%2Cs_wnPiEHhTjcNJwxfJ.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~VGVzdGluZyBMaWJyYXJ5IGFuZCBWaXRlc3QgQnJvd3NlciBNb2RlIGZvciBWdWUgwrcgdnVlanMuc2Nob29s%2Cc_OgCard.takumi%2Ctitle_Testing%2BLibrary%2Band%2BVitest%2BBrowser%2BMode%2Bfor%2BVue%2Cdescription_~V3JpdGUgVnVlIGNvbXBvbmVudCB0ZXN0cyB0aGF0IGZpbmQgZWxlbWVudHMgYnkgcm9sZSBhbmQgbGFiZWwsIHR5cGUgbGlrZSBhIHJlYWwgdXNlciwgYW5kIHRoZW4gbW92ZSB0aGUgc2FtZSB0ZXN0IGludG8gYSByZWFsIGJyb3dzZXIgd2l0aCBWaXRlc3Qu%2Ceyebrow_~VHV0b3JpYWwgwrcgNSBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy90ZXN0aW5nLWxpYnJhcnktYW5kLXZpdGVzdC1icm93c2VyLW1vZGUtZm9yLXZ1ZSI%2Cs_wnPiEHhTjcNJwxfJ.png"}]}
```