Testing Library and browser mode
Find elements by role and label with Testing Library, pick between getBy, queryBy and findBy, and run the tests a simulated page cannot answer in a real browser with Vitest Browser Mode.
What you'll learn
When you finish, you can do these.- 1Find elements by role and label with Testing Library
- 2Pick getBy, queryBy or findBy for each check
- 3Test focus in a real browser with Vitest Browser Mode
- 4Fix a test that fails on a correct page or checks too soon
Picture a newsletter form: an email field and a Subscribe button. Its field has no label, so a screen reader, the program that reads a page aloud, announces it as "edit text" with no name. Tests that find elements by CSS class, such as .btn-primary, pass anyway: a class says nothing about what a person perceives.
Testing Library, a set of test helpers, finds elements the way people do, by role and label, so its tests fail when the page stops making sense. Vitest, the tool that runs the tests, has a Browser Mode that runs them in a real browser. It catches what a simulated page, a fake page in memory, gets wrong, such as focus: which element the keyboard is on. The Code tab holds SubscribeForm.vue, the form with its label, plus its tests of both kinds and their config, to run in a project of your own. You need one idea from Testing async code and mocks: MSW, a fake server that answers the form's request in a test.
Find elements the way people do
Testing Library's queries, the functions that find elements, look for what a screen reader announces. They match an element's role, what it is (button, textbox, alert), and its accessible name, the text read out with it: a button's text, a field's label.
In the Code tab, the field is labelled Email and the button reads Subscribe. Sent empty, the form shows an alert; once the server answers, a thank-you replaces the form.
getByRole('button', { name: 'Subscribe' }) finds the button a person would find, and getByLabelText('Email') the field labelled Email. If a query cannot find an element, someone using a screen reader cannot either.
The second test in SubscribeForm.spec.ts, the form's test file, fills in an address and presses Subscribe:
const user = userEvent.setup()
render(SubscribeForm)
await user.type(screen.getByLabelText('Email'), 'asha@example.com')
await user.click(screen.getByRole('button', { name: 'Subscribe' }))
render() from @testing-library/vue mounts the form on the simulated page, and screen queries the whole page. userEvent from @testing-library/user-event, set up as user, types and clicks with every event a browser fires, so the form sends asha@example.com.
The setup file, vitest.setup.ts, runs before each test file and does three jobs:
- It adds matchers, the checks after
expect(), such astoBeInTheDocument()from@testing-library/jest-dom. - It starts the MSW server, which answers the form's request.
- It calls
cleanup()after each test, which unmounts whatrender()mounted.
Testing Library calls cleanup() by itself only when Vitest's globals option makes afterEach available without an import. With the option off and no cleanup() call, the second test finds two forms and fails with Found multiple elements with the text of: Email.
getBy, queryBy and findBy
Every query comes in three families, and picking the right one is most of the skill.
getBy…expects exactly one match now. With none it throws an error.queryBy…returnsnullwhen nothing matches. It is the only family for checking that something is absent.findBy…returns a promise and retries until the element appears, for up to one second by default. Use it for anything that shows up after a request or a timer.
The first test checks that no alert shows before anyone presses Subscribe, so it uses queryBy:
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
No alert is there yet, so the check passes.
The thank-you appears only after the server answers. In the tests, the MSW handler in src/mocks/handlers.ts answers after a short delay(), as a real server would. The second test waits for the message with findByText:
expect(await screen.findByText('Thanks, check your inbox')).toBeInTheDocument()
It keeps looking until the message appears, then passes.
Run it in a real browser
Vitest Browser Mode runs tests in a real browser instead of happy-dom or jsdom, the two simulated pages, for checks such as where Tab moves the focus.
Simulated pagehappy-dom or jsdom inside Node. Starts in milliseconds and needs no browser. No layout: sizes and positions (getBoundingClientRect()) are zero. Stylesheets apply only in part: with button:focus-visible { outline: none }, which hides the focus ring, it still reports the normal outline.
Browser ModeReal Chromium, Firefox or WebKit, driven by Playwright, a tool that controls browsers. Slower to start. Layout, CSS, focus and key presses are real, so a missing focus ring or a button hidden under a banner shows up.
Vitest projects, named groups of tests with their own settings, let both kinds of test share vitest.config.ts: the unit project runs *.spec.ts files on happy-dom, the browser project runs *.browser.spec.ts files in Chromium.
Install two packages with npm i -D @vitest/browser-playwright vitest-browser-vue: the provider, which lets Vitest drive the browser through Playwright, and vitest-browser-vue, which renders Vue components there. npx playwright install chromium downloads the browser, and npx vitest run --project browser runs only the browser tests.
The keyboard test in SubscribeForm.browser.spec.ts, the form's browser test file, presses Tab once and checks where the focus lands:
const screen = await render(SubscribeForm)
await userEvent.keyboard('{Tab}')
await expect.element(screen.getByLabelText('Email')).toHaveFocus()
It passes when the Email field holds the focus. In Vitest 5, render() from vitest-browser-vue returns a promise, so await it. userEvent from vitest/browser sends real key presses through Playwright, and expect.element() retries until the assertion passes.
Browser Mode's queries return locators, Vitest's pointers to elements. When one finds nothing, Vitest prints an ARIA tree, the page as a screen reader sees it, which shows which field has no name.
What goes wrong
Both mistakes pick the wrong family. The first checks with getBy that the alert is absent, and fails for the wrong reason.
expect(screen.getByRole('alert')).not.toBeInTheDocument()
getByRole throws before expect runs: TestingLibraryElementError: Unable to find an accessible element with the role "alert". The test fails exactly when the page is right. queryByRole returns null, which .not.toBeInTheDocument() accepts.
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
The second mistake uses getBy for text that arrives after a request.
expect(screen.getByText('Thanks, check your inbox')).toBeInTheDocument()
getByText looks once, while the request is still running, and throws Unable to find an element with the text: Thanks, check your inbox. With a handler that answers at once it can pass, because user.click() waits a moment before it finishes. That makes it worse: the test passes today and fails the day the handler or the server gets slower. findByText waits for the message:
expect(await screen.findByText('Thanks, check your inbox')).toBeInTheDocument()
What to remember
- Query by role and accessible name first, then by label; a query that cannot find an element points at an accessibility bug.
- Without Vitest
globals, callcleanup()from@testing-library/vueinafterEach. getByfor what must be there now,queryByfor what must not be there,findByfor what appears later.- Keep most tests on happy-dom, and put focus, layout and CSS checks in a
browserproject that runs in a real browser. - In Browser Mode, await
render(), useuserEventfromvitest/browser, and assert withexpect.element().
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
npx playwright install chromium, thennpx vitest run --project browser: both browser tests pass. Delete the<label>fromSubscribeForm.vueand run it again. The keyboard test fails withCannot find element with locator: page.getByLabel('Email'), and the ARIA tree under it lists- textboxwith no name. Put the label back. - Replace the button's text with an icon,
<svg width="16" height="16" aria-hidden="true"><path d="M2 8h12" /></svg>. Both projects fail to find the button by its name, and the ARIA tree shows- buttonwith nothing after it. Addaria-label="Subscribe"to the button: every test passes, and screen readers now announce it too. - In the second unit test, change
await screen.findByTexttoscreen.getByText. It fails withUnable to find an element with the text: Thanks, check your inbox.Removeawait delay()from the handler and it passes again, which is the trap: putfindByTextand the delay back.