Vitest Browser Mode: Running Angular Tests in Real Chromium, Firefox and WebKit
Test layout-dependent behavior (IntersectionObserver, @defer on viewport, sticky positioning, appearance) in a real browser with Vitest browser mode, not in jsdom. On Bookstore we show an infinite scroll bug that loaded only 24 of 30 books on a wide screen while the jsdom test passed, with a separate test target, the MSW worker, toMatchScreenshot and Chromium and WebKit runs.
Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Vitest 5.1 (browser mode, @vitest/browser-playwright 5.1), Playwright 1.65, Mock Service Worker 3.0 and @testing-library/angular 19.6 on Node.js 24.
Guess First
The catalog page of Bookstore, the online bookstore used as the shared example across this site's series, adds the list from the API to the DOM 12 books at a time. At the end of the list there is a 1-pixel "sentinel" element; when it becomes visible, the next 12 books are added. The directive that tracks visibility looks like this:
@Directive({ selector: '[appInViewport]' })
export class InViewport {
readonly enter = output<void>();
constructor() {
const element: HTMLElement = inject(ElementRef).nativeElement;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.enter.emit();
}
});
observer.observe(element);
inject(DestroyRef).onDestroy(() => observer.disconnect());
}
}
@if (visibleCount() < books().length) {
<div class="sentinel" appInViewport (enter)="showMore()"></div>
}
jsdom has no IntersectionObserver, so the test fakes it with vi.stubGlobal and triggers the "end of list reached" moment by hand:
it('shows twelve more books when the end of the list is reached', async () => {
let reachEnd = (): void => {};
vi.stubGlobal(
'IntersectionObserver',
class {
constructor(callback: IntersectionObserverCallback) {
reachEnd = () => callback([{ isIntersecting: true } as IntersectionObserverEntry], this as never);
}
observe(): void {}
unobserve(): void {}
disconnect(): void {}
},
);
const { fixture } = await render(CatalogPage, {
providers: [provideHttpClientTesting()],
deferBlockBehavior: DeferBlockBehavior.Manual,
});
TestBed.inject(HttpTestingController).expectOne('/api/books').flush(catalogOf(30));
expect(await screen.findAllByRole('heading', { level: 3 })).toHaveLength(12);
reachEnd();
await fixture.whenStable();
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(24);
});
The test passes. The catalog has 30 books. How many books does a customer see after opening the page on a 1920×1080 monitor and scrolling to the bottom?
Answer: 24, Because the Sentinel Stayed Visible
I ran the same scenario in a real Chromium with Vitest browser mode:
FAIL bookstore-web (chromium) src/app/catalog/catalog-page.browser.spec.ts > CatalogPage in a real browser > shows every book after scrolling to the end on a large screen
AssertionError: expected 24 to be 30 // Object.is equality
Failure screenshot:
- .vitest/attachments/failure-screenshots/catalog-page.browser.spec.ts/CatalogPage-in-a-real-browser---shows-every-book-after-scrolling-to-the-end-on-a-large-screen.png
On a wide screen seven cards fit in a row. The first 12 books take two rows, so the sentinel is visible as soon as the page opens; the observer reports once, and there are 24 books. Those 24 books also fit on the screen, and the sentinel stays visible. IntersectionObserver only reports when the intersection state changes; "still visible" is not a change, so the third report never comes. At 375×812 and 800×600 the same test passed: with fewer columns, the newly added books push the sentinel off the screen.
The jsdom test could not have caught this bug. The observer we faked does not carry the most important behavior of the real one, namely when it reports; the test only says "if a report comes, 12 books are added". It is the same bug as in post 5, where a mock did not carry the real behavior, this time in a browser API. The fix is to observe the sentinel again whenever the number of visible books changes. observe() reports the current state once for a newly added target; if the sentinel is still visible, the next 12 books load too:
export class InViewport {
readonly appInViewport = input<unknown>();
readonly enter = output<void>();
constructor() {
const element: HTMLElement = inject(ElementRef).nativeElement;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
this.enter.emit();
}
});
effect(() => {
// Re-observe when the value changes: observe() reports the current state again
this.appInViewport();
observer.unobserve(element);
observer.observe(element);
});
inject(DestroyRef).onDestroy(() => observer.disconnect());
}
}
With [appInViewport]="visibleCount()" in the template, the browser test passed. The jsdom test was green with both versions; we kept it, because it tests the page's "add 12 books when a report comes" logic in 43 ms. The layout-dependent part is now tested in a real browser.
What jsdom Cannot See
In post 1, comparing jsdom and happy-dom, we saw that neither computes layout: element heights are 0 and getBoundingClientRect() always returns zeros. As a result, these cannot be tested in jsdom:
- The result of layout and CSS: whether an element is visible, sticky or fixed positioning, media queries, one thing covering another.
- APIs that depend on visibility:
IntersectionObserver,ResizeObserver, Angular's@defer (on viewport)trigger. - Real focus and scrolling:
scrollIntoView, scroll events, where focus goes. - Engine-specific behavior and appearance: differences between Chromium, Firefox and WebKit, screenshots.
Vitest browser mode runs the same Vitest tests in a real browser instead of Node.js. The test file loads in an iframe in the browser; the DOM, CSS and Web APIs are real. You need a provider that opens and closes the browser; in Bookstore we use Playwright.
Setup: A Separate Test Target
The Angular documentation says to install the provider package and Playwright for browser mode:
npm install --save-dev @vitest/browser-playwright playwright@1.65.0
npx playwright install chromium webkit
The builder's browsers option turns on browsers. Since we do not want to move the whole suite to the browser, we added a second test target to angular.json; the jsdom suite leaves out .browser.spec.ts files:
"test": {
"builder": "@angular/build:unit-test",
"options": {
"setupFiles": ["src/test-setup.ts"],
"providersFile": "src/test-providers.ts",
"runnerConfig": "vitest-base.config.mts",
"exclude": ["**/*.browser.spec.ts"]
}
},
"test-browser": {
"builder": "@angular/build:unit-test",
"options": {
"include": ["**/*.browser.spec.ts"],
"browsers": ["chromiumHeadless"],
"setupFiles": ["src/test-setup.browser.ts"],
"providersFile": "src/test-providers.ts",
"runnerConfig": "vitest-base.config.mts"
}
}
"test:ci": "ng test --no-watch --no-progress",
"test:browser": "ng run bookstore-web:test-browser --no-watch --no-progress"
The Headless suffix on the browser name runs the browser without opening a window. According to the Angular documentation, the default is to run with a window, and headless mode is used when the CI environment variable is set. If you want to watch what the test does locally, you can run with a window using --browsers chromium.
The reason for a separate setup file showed up on the first run. The jsdom suite's test-setup.ts loads @testing-library/jest-dom/vitest, and that package could not load in the browser:
Error: Failed to import test file /home/user/bookstore-web/src/test-setup.ts
Caused by: ReferenceError: process is not defined
❯ node_modules/picocolors/picocolors.js:1:9
Vitest browser mode already provides matchers such as toBeInTheDocument, toHaveTextContent and toBeVisible; test-setup.browser.ts only registers the Turkish locale data.
For HTTP we use the MSW handlers from post 5. In the browser, MSW works through a Service Worker; the worker file is copied into the public/ folder with npx msw init public --save, and the handlers are attached with setupWorker from msw/browser:
// src/testing/msw-browser.ts
export const worker = setupWorker(...handlers);
export function useMockApiInBrowser(): void {
beforeAll(() => worker.start({ quiet: true, onUnhandledFrame: 'error' }));
afterEach(() => worker.resetHandlers());
afterAll(() => worker.stop());
}
A 30-book catalog for /api/books and a rating endpoint were added to handlers.ts; the jsdom and browser tests use the same fake API. The infinite scroll test:
it('shows every book after scrolling to the end on a large screen', async () => {
await page.viewport(1920, 1080);
await render(CatalogPage);
expect(await screen.findAllByRole('heading', { level: 3 })).not.toHaveLength(0);
window.scrollTo(0, document.body.scrollHeight);
await expect.poll(() => screen.getAllByRole('heading', { level: 3 }).length).toBe(30);
});
page comes from vitest/browser and represents the iframe the test runs in; page.viewport() changes its size. expect.poll runs the given function again and again, for 1 second by default, until the value reaches the expected one; you do not need to write a sleep for asynchronous loading. Testing Library queries work the same way in the browser.
@defer and the Sticky Cart in a Real Browser
In post 3 we triggered the @defer (on viewport) block in BookCard by hand in jsdom with DeferBlockBehavior.Manual, because there was no IntersectionObserver. In the browser we can test the trigger itself: the first card's rating should load, and the rating of the 12th card, which is off screen, should not load until it is scrolled into view:
it('loads the ratings of a card only when it scrolls into view', async () => {
await page.viewport(800, 600);
await render(CatalogPage, { deferBlockBehavior: DeferBlockBehavior.Playthrough });
const cards = await screen.findAllByRole('article');
expect(await within(cards[0]).findByText('4.5 / 5 (12 ratings)')).toBeInTheDocument();
expect(cards[11]).not.toHaveTextContent('12 ratings');
cards[11].scrollIntoView();
expect(await within(cards[11]).findByText('4.5 / 5 (12 ratings)')).toBeInTheDocument();
});
The deferBlockBehavior: DeferBlockBehavior.Playthrough line matters. TestBed's default is Playthrough, but according to its source, Angular Testing Library's render() uses Manual when the option is not given. Without the line the test waited 15 seconds and failed; in the ARIA tree the card still showed the "Ratings" placeholder.
The cart status at the top of the catalog page (Cart: 2 books · ₺900,00) stays at the top of the screen with position: sticky. This is another behavior you can only see with layout:
it('keeps the cart status visible while the list scrolls', async () => {
await page.viewport(800, 600);
await render(CatalogPage);
await screen.findAllByRole('article');
window.scrollTo(0, 1500);
const cartStatus = page.getByRole('complementary', { name: 'Cart status' });
await expect.element(cartStatus).toBeInViewport();
expect(cartStatus.element().getBoundingClientRect().top).toBe(0);
});
expect.element takes a locator and retries until the assertion passes; according to the Vitest documentation, toBeInViewport measures visibility with IntersectionObserver. To see what the test protects, I added a common change to the page component: :host { display: block; overflow-x: hidden; } to hide horizontal overflow. The app compiled, the jsdom suite stayed green, and the browser test failed:
Error: expect(element).toBeInViewport()
Received element is not in viewport (actual ratio: 0.000):
<aside
aria-label="Cart status"
class="cart-bar"
/>
An ancestor whose overflow is anything other than visible creates a new scroll container for the sticky element; the cart now sticks to the component, which never scrolls, instead of the window, and slides up and away with the page.
Visual Comparison: toMatchScreenshot
Some bugs are only in appearance: the color, size or alignment of the stars. Vitest browser mode's toMatchScreenshot assertion compares a screenshot of an element with a stored reference. The Vitest documentation marks this API as experimental:
it('fills the stars up to the selected rating', async () => {
await render(RatingInput, { inputs: { label: 'Rate the book', value: 3 } });
await expect.element(page.getByRole('group', { name: 'Rate the book' })).toMatchScreenshot('three-of-five');
});
On the first run there is no reference; Vitest creates it and fails the test on purpose:
Error: expect(element).toMatchScreenshot()
No existing reference screenshot found; a new one was created. Review it before running tests again.
The reference is written to the __screenshots__/rating-input.browser.spec.ts/ folder next to the test file; the file name is made of the name, the browser and the operating system platform (three-of-five-chromium-<platform>.png). You need to check the image and commit it. The builder has no equivalent of Vitest's --update option; after an intentional change in appearance, we delete the reference and run the test again.
I tried two changes. Reducing the star size from 1.5rem to 1.25rem failed the test:
Screenshot does not match the stored reference.
Expected image dimensions to be 410×51px, but received 410×46px.
Changing the color of a filled star from #f5a623 to #e8a33d, however, passed. The default comparator is pixelmatch, and its color difference threshold (threshold) is 0.1; it deliberately tolerates color shifts that are hard to tell apart by eye. You can lower the threshold, but then font and anti-aliasing differences start showing up as failures too.
There is a trap in .gitignore as well. The .gitignore generated by ng new in Angular 22.2 contains a __screenshots__/ line. While that line is there, references never get into the repository, and every CI run fails with "No existing reference". In Bookstore we removed the line; screenshots of failing tests and the actual and diff images of comparisons live under .vitest/, which we ignored.
Running in Different Engines
The Playwright provider supports Chromium, Firefox and WebKit. Instead of writing all of them in angular.json, you can pass them on the command line:
npx ng run bookstore-web:test-browser --no-watch --browsers chromiumHeadless --browsers webkitHeadless
Bookstore's four browser tests passed in Chromium and WebKit in 2.4–2.5 seconds; a separate reference image was created for WebKit. The two references have the same size (410×51) but their contents differ; the engines do not draw text and characters pixel for pixel the same. That is why references are kept per browser and platform. For the same reason, references should be produced on the operating system CI uses, ideally in CI's own container; a Linux runner cannot find a reference produced on Windows, because the file name contains the platform. For Firefox the browser name is firefoxHeadless.
Engine differences do not always show up visually. Our tests showed no behavioral differences, but IntersectionObserver threshold calculations, the focus behavior of form controls and date and time inputs can differ between engines. Instead of running every test in three engines, it is cheaper to pick the few tests with engine-specific risk and run only those in multiple browsers.
In Production
Time and resources. On this machine the four browser tests took 1.1–1.6 seconds in a single Chromium; the 18-file jsdom suite with 67 tests took 1.8–2.6 seconds (depending on the load on the machine). The tests themselves are fast: the infinite scroll test took 106–124 ms and the sticky test 13–18 ms. The real cost is starting the browser and installing and caching browsers in CI. Adding browser installation and the Playwright cache to the pipeline is the subject of the GitHub Actions series post "Angular CI with GitHub Actions".
Because mockServiceWorker.js is in the public/ folder, it is copied into the production build too. It does nothing as long as the app does not register it; if you do not want it there anyway, remove it from the assets of the production configuration.
Run the browser tests as a separate job. The jsdom suite gives fast feedback on every commit and in watch mode; running the browser suite once per pull request is enough.
Common Pitfalls
Moving the whole suite to browser mode. To try it, I also ran the jsdom tests with the test-browser target. 18 of the 21 files passed; the three files that set up MSW with msw/node could not load in the browser:
Caused by: TypeError: import_browser_external_node_async_hooks.AsyncLocalStorage is not a constructor
❯ node_modules/@mswjs/interceptors/lib/node/net-D5G58O2w.js:1415:18
You would have to separate Node-specific code, review tools such as vi.useFakeTimers that work comfortably in jsdom for the browser, and add browsers to CI; in return you gain nothing for tests that do not depend on layout. Use browser mode only for behavior jsdom cannot see.
Faking a browser API and believing you test the real behavior. The stub at the start of this post did not know when IntersectionObserver reports. If the timing or the conditions of the API you fake are part of the behavior, write that test in a real browser too.
Forgetting ATL's deferBlockBehavior default. Without the option, render() does not trigger @defer blocks; to test the trigger you have to pass Playthrough.
Waiting with sleep. In the browser everything is asynchronous and timings depend on the machine. expect.element, expect.poll and Testing Library's findBy* queries wait until the condition holds; a fixed-time wait is both slow and brittle.
A Small Experiment
★☆☆ Add ratio: 1 to the sticky test (toBeInViewport({ ratio: 1 })) and verify that the whole cart status is visible. What happens if you write top: -10px instead of top: 0?
★★☆ Write a second visual test for RatingInput: capture its disabled state and review the reference. Can you tell the disabled stars apart visually?
★★★ Use it.each with page.viewport() to run the infinite scroll test twice, for 375×812 (phone) and 1920×1080 (wide screen). Revert the fix and see at which size it fails.
Browser mode tested components in a real browser, but still one by one, inside TestBed. In the next post we open the whole built application with Playwright: search for a book, add it to the cart, check out; we fake the API with page.route and sign in only once with storageState.