
One of the most exciting developments in the last few years when it comes to testing is definitely Vitest Browser Mode . In this article I am going to tell you everything you need to know. And this is…
One of the most exciting developments in the last few years when it comes to testing is definitely Vitest Browser Mode . In this article I am going to tell you everything you need to know.
And this is such a big change in the JS testing ecosystem - I believe that within a year or two, all frontend engineers will need to know what Vitest Browser Mode is just like we all have to at least be aware of Jest/Vitest test runners, Cypress/Playwright E2E tests etc.
Good questions!
expect(val).toBe(expectedVal) sort of tests. When testing React components in these, they run in your terminal with a fake simulated DOM.
This blog post is about Vitest Browser Mode - it is a special way of running tests within Vitest.
If you are very new to all of this, check out my completely free 19 lessons on Vitest fundamentals
Vitest Browser Mode combines several powerful features:
This is different to running E2E (End to end) tests directly in Playwright.
With Vitest browser mode we are still testing individual components by themselves. Just with a real browser. So all the web APIs like session storage, cookies, local storage, fetch requests, URL manipulation, clipboard API, geolocation, web workers and more just work out of the box now!
Here is a demo of the UI for it (but remember - it can run in your terminal as well in headless mode):
It is the end of November 2025 right now. Vitest came out with version 4 last month, which marked Vitest Browser Mode as stable. So it is quite new.
I predict that by November 2027 Vitest Browser Mode will become a standard part of testing frontend React applications. I do not think it will completely replace the typical way (Vitest/Jest with React Testing Library) by then, but it will be part of our standard set of tools we use. I'll come back in a couple of years and see how this turned out...
Anyway, onto the rest of this Vitest Browser Mode introduction/tutorial/setup guide!
When you test React components with 'regular' Vitest (or Jest), you will normally mount/render your components in a simulated DOM (often jsdom ).
This is (very!) good for most components. But it isn't completely realistic.
If you have ever wanted to test components which use Web APIs like:
window.sessionStorage or window.localStorageMutationObserver, IntersectionObserver or ResizeObservernavigator.clipboardwindow.location and URL manipulation...then you've probably come across some limitations of these 'fake' DOMs that run in your terminal.
You normally end up either manually mocking them, or adding a npm package (which will just mock it for you).
Also, if a test breaks it can be quite awkward with seeing the rendered HTML and figuring out why it isn't working as expected.
Of course, we've always had End-to-end (E2E) test frameworks (Playwright and Cypress are two of the most popular tools). They will run a headless browser (Chrome, Firefox etc) on a full page, and of course this means it has access to test all the real web APIs.
Vitest basically runs (headless or in UI mode) a real browser, and runs your component inside an iframe. So you can control the viewport size, and you get all the real CSS and Web API support.
(To be clear: on a typical E2E test you'd be testing an entire page. In standard React Testing Library tests, you would be testing a single component. In Vitest Browser Mode you will be testing a single component at a time).
Your test file can interact directly with the DOM (although it is recommended to use helper functions and not directly interact with the DOM), as your test code/assertions run in the same context in the (headless) browser.
You can use all the normal Vitest syntax, plus the special DOM matcher assertions functions and rendering functions to ensure you can fully control what happens in the browser rendering your component.
Vitest Browser Mode tests look familiar to typical React tests. You will see functions like .getByRole(), .getByTestId().
However, the syntax is more similar to Playwright.
In the rest of this section I'll go over the core concepts of writing a test in Vitest Browser Mode to test a React component.
render() function from vitest-browser-react,render(<YourComponent/>)render() function is async (unlike in React Testing Library)await it's return value
.getByText() to query for elements)screenimport { render } from 'vitest-browser-react';
test('can render', async () => {
const screen = await render(<YourComponent />);
// ... rest of test here
});
Ok so now you have rendered your React component.
You now need to find elements to make assertions, and perform interactive actions (like clicking).
When you want to find rendered elements, use functions like screen.getByText('something') or screen.getByRole('button'). (They look similar to React Testing Library query functions btw but they do work a bit different as I will explain)
With either the page import, or the return value from calling render(), you
have access to these functions:
IMPORTANT >> These getBy functions return a Locator, not the HTMLElement directly.
These Locator objects are immediately returned from the getBy functions (no await needed).
const screen = await render(<YourComponent />);
// no await needed here
const locator = screen.getByText('click here');
There are then a few ways to use these Locator objects.
You can pass a Locator straight into expect(...) and use DOM matchers like .toHaveTextContent(...) and it will work.
const Component = () => {
return <h1>some heading</h1>;
};
test('can get a heading', async () => {
const screen = await render(<Component />);
const locator = screen.getByRole('heading');
// pass it straight into expect(...) and run
// an assertion function against it:
expect(locator).toHaveTextContent('some heading');
});
Remember: The returned object from the getBy... functions is actually a lazy, synchronous locator. This means we can use it to get multiple elements, or for async behaviour (where the element might not be there yet). You will see this when we start to use await expect.element() to test async behaviour.
If this use of Locator objects is new to you, you might be asking what they're for...
The use of Locators lets you find elements that were rendered in an asynchronous way.
In the following example, after 100ms the state value changes. We can use expect.element(...) to asynchronously retry until it passes (or times out)
const Component = () => {
const [headingText, setHeadingText] = useState('initial');
useEffect(() => {
window.setTimeout(() => setHeadingText('updated'), 100);
}, []);
return <h1>{headingText}</h1>;
};
test('sees updated heading value', async () => {
const screen = await render(<Component />);
// no async here...
const headingLocator = screen.getByRole('heading');
// no async here either
expect(headingLocator).toHaveTextContent('initial');
// but now we want to assert it against the updated value
// we can do that with `await expect.element()` which will poll until the
// assertion passes:
await expect.element(headingLocator).toHaveTextContent('updated');
});
When writing tests with React Testing Library, there are different functions to get a single element (findByText() for example) vs finding multiple findAllByText())
In Vitest Browser Mode there is only the getBy... functions (no getAllBy...).
In Vitest Browser Mode, a Locator can be used to find multiple elements that match:
const MultipleButtons = () => {
return (
<div>
<button>First button</button>
<button>Second button</button>
</div>
);
};
test('multiple buttons render', async () => {
const screen = await render(<MultipleButtons />);
// using the same getBy... function to get a locator
const locator = screen.getByRole('button');
// this locator will have 2 elements in it
expect(locator).toHaveLength(2);
// you can access the elements like this with nth()
const firstButton = locator.nth(0);
expect(firstButton).toHaveTextContent('First button');
// or with first()/last():
expect(locator.first()).toHaveTextContent('First button');
expect(locator.last()).toHaveTextContent('Second button');
// or like this:
const allButtonsFromLocator = locator.elements();
expect(allButtonsFromLocator[0]).toHaveTextContent('First button');
expect(allButtonsFromLocator[1]).toHaveTextContent('Second button');
});
If there was some async behaviour (such as the second button appearing after some state change/duration), then you could also use await expect.element(...) like this:
await expect.element(locator).toHaveLength(2);
This works because await expect.element(...) will retry until the assertion function passes (or until it times out).
In React Testing Library it is common to make use of the queryBy... functions to check an element is not rendered in the DOM:
// THIS IS FOR REACT TESTING LIBRARY - not Vitest Browser Mode
test('clicking will hide an element', async () => {
render(<SomeComponent />);
expect(screen.getByText('click me')).toBeInTheDocument();
// trigger something to hide an element
await userEvent.click(screen.getByRole('button'));
// use queryBy to assert it isn't visible
expect(screen.queryByText('click me')).not.toBeInTheDocument();
// or:
expect(screen.queryByText('click me')).toBeNull();
});
With Vitest Browser Mode, there is no special queryBy... function. Only the getBy... functions.
You can instead just use getBy with an assertion like .not.toBeInTheDocument()
const HideHeadingWhenClicked = () => {
const [visible, setVisible] = useState(true);
return (
<div>
<button onClick={() => setVisible(false)}>Click me</button>
{visible && <h1>welcome</h1>}
</div>
);
};
test('hides a welcome message when clicked', async () => {
const screen = await render(<HideHeadingWhenClicked />);
const button = screen.getByRole('button');
const heading = screen.getByRole('heading');
expect(heading).toBeInTheDocument();
await button.click();
await expect.element(screen.getByRole('heading')).not.toBeInTheDocument();
// note: using .toBeNull() wouldn't work here!
});
Each Locator object has a bunch of interaction functions that will look familiar to anyone who has used userEvent (from React Testing Library).
So once you have a Locator (with exactly one element in it!) you can call these functions!
Some things to know about calling the interaction methods on Locator objects:
const ClickAButton = ({ handlerFn }) => {
return (
<div>
<button onClick={handlerFn}>Click me</button>
</div>
);
};
test('when clicking button, the passed in prop is triggerered', async () => {
const fn = vi.fn();
const screen = await render(<ClickAButton handlerFn={fn} />);
const locator = screen.getByRole('button');
await locator.click();
expect(fn).toHaveBeenCalledTimes(1);
});
The main functions you will probably want to use are:
.click(),.fill() (for filling in textbox inputs),.selectOptions() (for selecting <select> options)If you want to see the full list check it out here
Basically - if a function like screen.getByText(...) returns exactly one element, you can just await screen.getByText('click me').click()
(As everything is quite intertwined, some of the previous examples also covered this)
There are lots of built in DOM assertion functions that you can use with Locator objects.
And there are a few ways to run the assertions on them.
toBeDisabled()toBeEnabled()toBeEmptyDOMElement()toBeInTheDocument()toBeInvalid()toBeRequired()toBeValid()toBeVisible()toBeInViewport()toContainElement()toContainHTML()toHaveAccessibleDescription()toHaveAccessibleErrorMessage()toHaveAccessibleName()toHaveAttribute()toHaveClass()toHaveFocus()toHaveFormValues()toHaveStyle()toHaveTextContent()toHaveValue()toHaveDisplayValue()toBeChecked()toBePartiallyChecked()toHaveRole()toHaveSelection()toMatchScreenshot()If a Locator has exactly one element (without any async behaviour to 'wait' for) then just use it in the most simple way:
// <button>hello</button>
const buttonLocator = screen.getByRole('button');
expect(buttonLocator).toHaveTextContent('hello');
If after some re-render(s), a Locator will have exactly one element (e.g. after some state change/timeout), then you can pass the Locator into await expect.element(...)
const buttonLocator = screen.getByRole('button');
await expect.element(buttonLocator).toHaveTextContent('hello');
I'm trying to keep the blog post quite simple and easy to read, but if you want to see a slightly more complex test here is a counter component and its Vitest Browser Mode test:
import { expect, it } from 'vitest';
import { render } from 'vitest-browser-react';
import { page } from 'vitest/browser';
import { useState } from 'react';
const CounterComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
it('should let you increment', async () => {
await render(<CounterComponent />);
const headingLocator = page.getByRole('heading');
// no need to await expect.element(), as there is no async requirement for this assertion
expect(headingLocator).toHaveTextContent('Count: 0');
// get a locator for a button, then await the click() method on it:
const button = page.getByRole('button');
await button.click(); // << awaited after the click
// can make assertion without any await here BTW
expect(page.getByRole('heading')).toHaveTextContent('Count: 1');
// note: if there was other async behaviour (such as a timeout) or we didn't await our .click()
// call, then you could also make use of await expect.element
await expect.element(page.getByRole('heading')).toHaveTextContent('Count: 1');
});
The above introduction hopefully helped understand how to write (and read) a Vitest Browser Mode test. Now let's move onto the initial setup and configuration of Vitest Browser Mode.
A provider in this context means where/how you actually run your tests in the browser.
There are three providers to choose from right now - but you should almost definitely use the playwright one
Here are the options:
So despite the choice of 3 - your best bet is to stick with Playwright.
I am assuming you already have Vitest installed. (If not: there are tons of guides out there. Basically just install vitest and set up your vitest.config.ts file.
Once you have Vitest installed, you should install these packages to get Vitest Browser mode working. I am going to continue this demo to get it set up for a React based app (including for a NextJS app).
npm install --save-dev @vitest/browser-playwright @vitest/ui vitest vitest-browser-reactThe specific versions of all related packages that I am using for this demo are:
await render(<YourComponent/>)).And these are some related ones that you may already have installed:
In this tutorial I am using two config files - one for Browser-mode Vitest config, and one config file for regular (not Browser-Mode) tests. This does mean some duplication. See the note below on project configs, which might be more suitable for your app.
The way I
prefer to set it up is to have your
normal vitest.config.ts, and then
make another one called
vitest.browser.config.ts (or .mts
file endings)
You can follow along, just create a
vitest.browser.config.ts (or
vitest.browser.config.mts)
You can definitely run Vitest Browser Mode in an app that normally uses Jest. You will use Vitest just for the Browser Mode tests.
To simplify things and keep the Vitest Browser Mode tests separate to the other test files, I've gone with a *.browser.ts and *.browser.tsx filename for your tests.
This is so that you can easily filter test files just for the browser mode config, leaving you existing tests (which are probably named *.test.tsx or *.spec.tsx) alone.
You can change these settings to whatever suits your need.
Sometimes *.spec.ts are used for e2e tests, so that convention could also work for Browser Mode tests.
As far as I can tell, there is no standard way yet that most apps follow - if you know of one, please reach out to me and I'll update this blog post.
This is my vitest.browser.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
// run this before each test. In this case
// it is used to ensure that the CSS is loaded
// Note: you will need to create this file
// see the repo link for an example
setupFiles: [
'vitest.browser.setup.ts', // or whatever filename you gave your setup file
],
// list of file name extensions used for browser tests:
include: ['./**/*.browser.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
// note: you may want to use `exclude` on your
// non-Browser Mode config to exclude them there
// so we don't have to import describe, expect, etc:
globals: true,
// config for browser mode:
browser: {
enabled: true,
headless: process.env.CI === 'true',
provider: playwright(),
screenshotDirectory: 'vitest-test-results',
instances: [
// Playwright supports Chromium, Firefox, or WebKit
{ browser: 'chromium' },
],
},
},
});
I also created a vitest.browser.setup.ts file, which runs before any tests (because of the setupFiles config). This is used in my demo starter repo to import the CSS - see the file here .
Before running the tests you have to remember to install Playwright browsers (Chromium etc). You only have to do this once.
If you are using yarn, the command is:
yarn playwright install --with-deps
Then to run the browser mode tests on your machine, run this (or vitest.browser.config.mts if you name it that):
yarn vitest --browser --config=vitest.browser.config.ts --ui
If you have followed along with the config files above, this will look for all files ending in *.browser.tsx.
If you want a demo test file that will pass tests, pull this Vitest Browser Mode test into your codebase.
If you are looking for a few more options (put these in a scripts script in your package.json):
vitest --browserheadless:true in the config, use vitest --browser --uinpx vitest --browsernpx vitest --browser=chromium or npx vitest --browser=chromium --uivitest --config=vitest.browser.config.tsTo see them in action, check out package.json in my Vitest Browser Mode starter repo.
In my sample starter kit repo on GitHub for Vitest + React, I have two test files:
As React Testing Library (RTL) is so popular, I will assume many readers of this blog are familiar with it. So I will compare writing a test in Vitest Browser Mode and how you would have done the same with React Testing Library.
Until recently it wasn't really stable (even though for most users it would be fine). But since Vitest v4 it is marked as stable. You can start using it right now on your local machine and on your CI/CD pipeline (like GitHub Actions).
You can also start using it alongside any existing tests (Vitest, Jest, etc.). The only thing to consider is to make sure that your tests for Vitest Browser Mode are unique enough (so I've gone with *.browser.tsx for my Browser Mode test files).
You can follow the instructions above - they cover the basics.
I have a GitHub Action workflow here that I think is the easiest way to get set up. Just copy over the config files.
Most of the useful parts are in:
This blog post is meant as an intro to help you grasp the very basics.
If you have any questions please reach out to me.
> Fast execution... despite running in a real browser, which at first might seem like it would be slow - it is actually really fast.
What makes it fast?
I'd also like a definition for "fast". Because my unit tests run in 100ms. Is it that fast? Or "fast" for a browser test
More options in the ecosystem seems better, but I was surprised to see this in the linked vitest docs:
> However, to run tests in CI you need to install either playwright or webdriverio. We also recommend switching to either one of them for testing locally instead of using the default preview provider since it relies on simulating events instead of using Chrome DevTools Protocol.
I used to do this with Karma test runner. The best part was how it didn't try to capture everything, so debugging with breakpoints was really easy.
I like Vitest browser mode, but it's a pain to just "detach" for a specific frame and run that test in isolation, with my actual breakpoints.