Skip to main content

Back to the Browser with Vitest Browser Mode

The Love and Hate Story with the Browser

The Jasmine + Karma Era

The Angular community testing experience is a love and hate story with the browser. It initially started with the combination of Jasmine (test framework to define tests and make assertions) and Karma (browser-based test runner) where Karma was spawning browser instances to run the tests within them.

This approach was quite popular for a while but it had some drawbacks:

  • Browser discrepancies as Karma would be the browser version available on the machine. This means that you could not guarantee that the tests would run the same way on different machines.
  • Relatively slow startup caused by the time it takes to launch the browser — This is unrelated to test execution itself which was quite fast.
  • Slow and inefficient watch mode.

The Jest Era

Back in 2019, Jest was the de facto JavaScript testing framework with far more features than Jasmine (Note that it was initially using Jasmine under the hood). That's when Nx — which is a major innovation driver in the web ecosystem — added Jest support for Angular and the community started to adopt it. Combined with emulated environments such as JSDOM (more exhaustive than Happy DOM) or Happy DOM (faster than JSDOM), Jest made it possible to provide a more consistent experience. Not as valid as a browser, but consistent.

While fixing some problems, emulated environments brought other challenges like:

  • missing browser APIs,
  • surprising behavior (e.g. requestAnimationFrame callbacks are scheduled using a setInterval(..., 1000 / 60) loop to simulate the 60fps frame rate),
  • and also slower overall execution time.

The Vitest + Playwright Era

In the past few years, tools such as Playwright made it possible to spawn browsers and control them in a much more reliable and predictable way. Either through Chrome DevTools Protocol today, or BiDi in the future.

That is why Vitest Browser Mode leverages providers such as Playwright or WebdriverIO to run tests in real browsers.

Vitest "Partial" Browser Mode

Vitest Browser Mode

Vitest Browser Mode is a way to run your tests in a browser instead of an emulated environment such as JSDOM or Happy DOM on NodeJS.

This is enabled in Angular CLI by changing the unit-test builder configuration like this:

angular.json | project.json
{
"test": {
"builder": "@angular/build:unit-test",
"options": {
"runner": "vitest",
"browsers": ["Chromium"]
}
}
}

In Browser Mode, both the test and the exercised code are running in the same browser window and iframe to be exact. This means that you can directly interact with the DOM through DOM APIs such as:

document.querySelector('button').dispatchEvent(new Event('click'));

... or through wrappers such as @testing-library/user-event that provide a much more convenient API which also produces a little bit more realistic behavior such as triggering all the events in the correct order. For example, calling userEvent.type(inputEl, "Let's cook!") triggers the following events:

  • focus
  • click
  • keydown
  • keypress
  • input
  • keyup
  • keydown
  • ... and so on...

Here is an example of a "Partial" Browser Mode test using @testing-library/angular and @testing-library/user-event:

import { screen } from '@testing-library/angular';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';

test('turn on the stove', async () => {
TestBed.createComponent(Stove);
await userEvent.click(await screen.findByRole('button', { name: 'TURN ON' }));
await expect.poll(() => screen.getByRole('paragraph')).toHaveText('🔥');
});

I call using DOM APIs, "Partial" Browser Mode because it is not leveraging the full power of the Browser Mode.

Here's what happens behind the scenes when running tests in "Partial" Browser Mode:

Vitest "Full" Browser Mode

When using Browser Mode, Vitest exposes a browser API to your tests through the vitest/browser entrypoint:

import { expect, test } from 'vitest';
import { page } from 'vitest/browser';

test('turn on the stove', async () => {
TestBed.createComponent(Stove);
await page.getByRole('button', { name: 'TURN ON' }).click();
await expect.element(page.getByRole('paragraph')).toHaveText('🔥');
});

Under the hood, “Full” Browser Mode changes the flow:

The page API is a unified API that allows you to interact with the DOM through the provider of your choice (e.g. Playwright). You can then benefit from its features such as the actionability checks.

Note that the page API methods return a Locator object that provides a fluent API similar to the Playwright API.

You can see the Locator as the "recipe" of how to find that element in the DOM. It is the action (e.g. click) that will try to find the element in the DOM. Hence, the synchronous nature of the Locator API.

Actionability Checks

A major drawback of emulated environments and "Partial" Browser Mode is that you accidentally perform actions that wouldn't be possible in a real browser. As an example, using DOM APIs, you can click on an element that is covered by another one. This may lead to false negatives. While thanks to the provider's actionability checks, the test would fail and let you know that another element received the click with a log such as:

...
- attempting click action
- <div class="overlay">...</div> intercepts pointer events
...

Provider options

Vitest also automatically augments the types of the page API methods with the options of the provider. For example, the click method will have the delay option when using Playwright as provider. This way you get the best of both worlds automatically.

warning

This type augmentation is not performed automatically when using the Angular CLI unit-test builder. You will have to manually augment the types yourself. Cf. https://github.com/angular/angular-cli/issues/31656.

Note that this works out of the box if you are using the Analog's Vite plugin approach.

expect.element

The moment you import vitest/browser, Vitest will also augment the expect object with a new method: expect.element.

This method is actually syntactic sugar for the expect.poll method. It will retry finding the element in the DOM and test it against the assertion's matcher until it passes or the timeout is reached.

Other features

"Full" Browser Mode also enables other provider features such as Playwright's trace feature.

Headed vs Headless mode

You are free to choose between headed or headless mode when using Browser Mode.

  • Headed mode: the browser window is visible and you can see the browser's UI, and even the components you are testing.
  • Headless mode: the browser window is not visible.
info

Note that using headless mode is unrelated to emulated environments. When using headless mode, you are still using a real browser, just not a visible one.

As of today, the main way to control this mode when using the Angular CLI is to override the browsers option by adding the Headless suffix to all browser names through the configuration or through the CLI.

For example, given the following configuration:

angular.json | project.json
{
"test": {
"builder": "@angular/build:unit-test",
"options": {
"runner": "vitest",
"browsers": ["Chromium", "Firefox", "Webkit"]
},
"configurations": {
"headless": {
"browsers": ["ChromiumHeadless", "FirefoxHeadless", "WebkitHeadless"]
}
}
}
}

Then running nx test -c headless or ng test -c headless will run the tests in headless mode.

Another workaround is to set the CI environment variable as tests run in headless mode on CI environments.

CI=1 ng test

// or

CI=1 nx test
warning

Note that whenever one browser is configured to run in headless mode, all browsers will run in headless mode.

There is also an open issue to provide a better developer experience to control this behavior.
Cf. https://github.com/angular/angular-cli/issues/31655.

Want to go deeper? Join a full live workshop

👉SEE THE FULL PROGRAM👈

Additional Resources

📄️ How to Progressively Migrate to Vitest Browser Mode

A step-by-step recipe for progressively migrating to Vitest Browser Mode in Angular tests.

🙏 Credits

  • Thanks to Charles from the Angular CLI team for the outstanding work streamlining the Vitest migration for the Angular community.
  • Thanks to the Vitest team (Anthony, Ari, Mathias, and Vladimir) for the amazing work and reactivity.