---
title: "Component Testing in Angular with Vitest: TestBed, jsdom, and Browser Mode"
description: "How Angular component tests actually behave under Vitest: TestBed is unchanged, the DOM is jsdom (no layout, no real CSS), and the specs that need a real browser are the ones to move to Vitest browser mode."
deck: "Once you have decided to migrate, the surprise is how little your component tests change: [Vitest is the default runner since Angular v21](https://angular.dev/guide/testing), but `TestBed` and `ComponentFixture` are the same API. What changes is the DOM underneath. The runner uses [jsdom](https://angular.dev/guide/testing), which has no layout engine, so some specs need [Vitest browser mode](https://angular.dev/guide/testing/migrating-to-vitest) and a real browser instead."
author: "André Ramos"
url: "https://andreramos.dev/angular/vitest-component-testing-angular/"
lang: "en"
type: "article"
---

# Component Testing in Angular with Vitest: TestBed, jsdom, and Browser Mode

> Once you have decided to migrate, the surprise is how little your component tests change: [Vitest is the default runner since Angular v21](https://angular.dev/guide/testing), but `TestBed` and `ComponentFixture` are the same API. What changes is the DOM underneath. The runner uses [jsdom](https://angular.dev/guide/testing), which has no layout engine, so some specs need [Vitest browser mode](https://angular.dev/guide/testing/migrating-to-vitest) and a real browser instead.

## TestBed did not change, the runner did

The fear when a project moves its test runner is that every component spec needs rewriting. For component tests, that is mostly not the case. The Angular Vitest builder runs the same `TestBed`, `TestBed.createComponent`, and `ComponentFixture` you already use. The spec body that creates a component, reads `componentInstance`, queries `nativeElement`, and asserts on text is identical under Vitest.

What `ng new` changed is the harness around that body, not the body itself. On Angular v21 the test target is the `@angular/build:unit-test` builder, `describe`/`it`/`expect` come from `vitest/globals`, and the tests run in a simulated DOM. The component testing API is the part that stayed put.

So the useful question is not "how do I rewrite my component tests". It is "which of my component tests touch something the simulated DOM cannot do". That is a much shorter list, and it is the list this article is about.

*A component test, unchanged under Vitest*

```ts
import { TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';

describe('CounterComponent', () => {
  it('increments the count on click', async () => {
    const fixture = TestBed.createComponent(CounterComponent);
    await fixture.whenStable();

    const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
    button.click();
    await fixture.whenStable();

    expect(fixture.nativeElement.querySelector('.count').textContent).toContain('1');
  });
});
```

## The DOM under your tests is jsdom, not Chrome

Here is the one fact that explains most of the surprises. By default the Vitest runner does not open a browser. It runs your component in jsdom, a JavaScript implementation of the DOM. The Angular docs say it plainly: "To simulate the browser's DOM, Vitest uses a library called jsdom." The CLI also auto-detects happy-dom and uses it instead if you install it, but for a fresh `ng new` project the environment is jsdom.

jsdom is enough for a large share of component tests: structure, text content, attributes, classes, event handlers, inputs and outputs, and the wiring between template and class. None of that needs a layout engine. The trouble starts only when a test asks the DOM a question jsdom cannot answer.

And jsdom is honest about the gap. Its own description is that it "does not have the capability to render visual content, and will act like a headless browser by default". There is no CSS layout, so `getBoundingClientRect()` and `offsetTop` return zeros. A `<canvas>` behaves like a `<div>` unless you add the `canvas` package as a peer dependency. A test that measures, scrolls, or reads computed geometry is testing a browser jsdom is not pretending to be.

| Component test needs | jsdom | Why |
|---|---|---|
| Rendered structure, text, classes, attributes | Works | DOM tree without layout. |
| Click, input, focus, output events | Works | Events dispatch without a renderer. |
| Inputs, outputs, template/class wiring | Works | No browser geometry involved. |
| `getBoundingClientRect`, `offsetTop`, sizes | Returns zeros | No layout engine to compute geometry. |
| Real CSS, media queries, computed styles | Not applied | jsdom parses CSS but does not lay it out. |
| `<canvas>` drawing and pixel reads | Acts like a `<div>` | Needs the `canvas` peer package, and even then is not a GPU. |
| Scroll position, IntersectionObserver geometry | Unreliable | Depends on layout jsdom does not run. |

## When jsdom is not enough: browser mode

For the specs that fail the table above, Angular's answer is Vitest browser mode: the same `TestBed` test, but rendered in a real browser driven by Playwright or WebdriverIO. The migration guide frames it as the option for "tests that rely on browser-specific APIs (like rendering) or for debugging". You opt in per project by installing a provider and adding the `browsers` option to the `test` target in `angular.json`, or passing `--browsers` on the command line.

The provider is a real dependency. `@vitest/browser-playwright` gives you `chromium`, `firefox`, and `webkit`; `@vitest/browser-webdriverio` drives `chrome` and the others through WebDriver. Tests run headed by default and switch to headless when the `CI` environment variable is set, which is the behavior you want: watch them locally, run them quiet in the pipeline.

I would not flip the whole suite to browser mode. A real browser is slower to start and heavier in CI than jsdom, and most component specs gain nothing from it. The judgment I apply is narrow: I reach for browser mode only for the specs that read real geometry or draw to a canvas, and I leave everything else on jsdom. The goal is two environments by intent, not one environment for fear of the other.

*Opt a project into browser mode (Playwright provider)*

```bash
npm install --save-dev @vitest/browser-playwright
npx playwright install chromium

# In angular.json, under the test target options:
#   "browsers": ["chromium"]
# Or per run, without editing angular.json:
ng test --browsers=chromium
```

## Change detection in the test: detectChanges, OnPush, and zoneless

The other thing worth knowing is how the test tells Angular that state changed. The old reflex is `fixture.detectChanges()`. It still works, and for a large existing suite the Angular docs are explicit that it is "likely not worth the effort" of converting away from it. But it is not the pattern the team steers new tests toward.

The recommended shape is `await fixture.whenStable()`. The reasoning in the zoneless guide is that `detectChanges()` "forces change detection to run when Angular might otherwise have not scheduled change detection", so the test stops resembling production. Letting Angular decide when to synchronize, then awaiting stability, is closer to how the component behaves when it ships. The examples in this article use `whenStable()` for that reason.

If you test a component zoneless, add `provideZonelessChangeDetection()` to `TestBed.configureTestingModule`. Skip it while zone.js is still in your polyfills and Angular throws `NG0908: In this configuration Angular requires Zone.js`. With OnPush, which is the default strategy in Angular 22, the discipline is the same: notify Angular of the change (a signal write, an input, an event) and await stability, rather than reaching for a manual `detectChanges()` that papers over a missing notification.

*A zoneless, OnPush component test*

```ts
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { CartBadgeComponent } from './cart-badge.component';

describe('CartBadgeComponent (zoneless, OnPush)', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideZonelessChangeDetection()],
    });
  });

  it('shows the item count from the signal input', async () => {
    const fixture = TestBed.createComponent(CartBadgeComponent);
    fixture.componentRef.setInput('count', 3);
    await fixture.whenStable();

    expect(fixture.nativeElement.textContent).toContain('3');
  });
});
```

## What this changes coming from Karma

Under Karma the tests ran in a real Chrome that the launcher started. That is the habit to unlearn: by default, Vitest does not. A spec that passed under Karma because Chrome happened to lay out the DOM, measure an element, or render a canvas can fail under jsdom for a reason that has nothing to do with your component. The fix is not to distrust the migration. It is to recognize that spec as a browser-mode candidate and move only that one.

The timing helpers are the other Karma habit that does not survive the move. `fakeAsync`, `tick`, `flush`, and `waitForAsync` lean on a Zone the Vitest builder does not set up around each test, so they throw the ProxyZone error. That is a separate job with its own two paths, covered in [fakeAsync and the ProxyZone Error](/angular/angular-vitest-fakeasync-proxyzone-error). For the config side of the move, and why the test target is one line, see [Karma to Vitest: The Config That Actually Runs](/angular/karma-to-vitest-angular-config). And before any of it, [whether to migrate a given suite at all](/angular/move-angular-test-suite-to-vitest) is its own decision.

The component testing API survived the change almost intact. The work is not rewriting tests. It is sorting them by what the simulated DOM can answer, and sending the short list of geometry and canvas specs to a real browser.

## Reusable artifact: jsdom vs browser mode: which environment for this component test

- Default to jsdom. Structure, text, classes, attributes, events, inputs, outputs, and template/class wiring all run there with no browser needed.
- Move a spec to browser mode when it reads real geometry: `getBoundingClientRect`, `offsetTop`, scroll position, or IntersectionObserver based on layout, all of which jsdom returns as zeros.
- Move a spec to browser mode when it draws to or reads from a `<canvas>`, or depends on computed CSS and media queries that jsdom parses but never lays out.
- Keep browser mode per spec, not per suite: install `@vitest/browser-playwright`, set `browsers` in `angular.json`, and let it run headless under CI while the rest stays on jsdom.
- Whichever environment, notify Angular of the change and `await fixture.whenStable()` rather than forcing `detectChanges()`; add `provideZonelessChangeDetection()` to `TestBed` for zoneless or OnPush components.

## FAQ

### Does TestBed still work with Vitest in Angular?

Yes. The Angular Vitest builder runs the same `TestBed`, `TestBed.createComponent`, and `ComponentFixture` API as before. A component spec that creates a fixture, queries `nativeElement`, and asserts on text or events does not change when you switch the runner to Vitest. What changes is the DOM environment underneath and the timing helpers, not the component testing API.

### What DOM environment does the Angular Vitest runner use by default?

jsdom. The Angular docs state that "to simulate the browser's DOM, Vitest uses a library called jsdom". The CLI auto-detects happy-dom and uses it instead if you install it, but a fresh `ng new` project runs tests in jsdom, which has no layout engine and does not render visual content.

### When do I need Vitest browser mode instead of jsdom for an Angular component?

When the test depends on something jsdom cannot do: real layout and geometry (`getBoundingClientRect`, `offsetTop`, scroll, IntersectionObserver), applied CSS and media queries, or canvas drawing. jsdom returns zeros for layout and treats `<canvas>` like a `<div>`. For those specs, install a provider like `@vitest/browser-playwright` and set the `browsers` option so they run in a real browser.

### Should I call fixture.detectChanges() or await fixture.whenStable() in a Vitest test?

`fixture.detectChanges()` still works, and the Angular docs say converting a large existing suite away from it is likely not worth the effort. For new tests, prefer `await fixture.whenStable()`: `detectChanges()` forces change detection when Angular might not have scheduled it, so the test drifts from production behavior. For zoneless or OnPush components, notify Angular of the change and await stability.

### How do I test a zoneless or OnPush Angular component under Vitest?

Add `provideZonelessChangeDetection()` to `TestBed.configureTestingModule`, drive the component through real notifications (a signal write, an input via `setInput`, an event), and `await fixture.whenStable()`. If zone.js is still in your polyfills and you request zoneless without the provider, Angular throws `NG0908: In this configuration Angular requires Zone.js`.

## Sources checked

- https://angular.dev/guide/testing
- https://angular.dev/guide/testing/migrating-to-vitest
- https://angular.dev/api/core/testing/TestBed
- https://angular.dev/api/core/testing/ComponentFixture
- https://angular.dev/guide/zoneless
- https://angular.dev/api/core/provideZonelessChangeDetection
- https://vitest.dev/guide/browser/
- https://vitest.dev/guide/browser/playwright
- https://github.com/jsdom/jsdom
- https://blog.angular.dev/announcing-angular-v21-57946c34f14b

## Related

- [When to Move an Angular Test Suite to Vitest](https://andreramos.dev/angular/move-angular-test-suite-to-vitest/)
- [Karma to Vitest in Angular: The Config That Actually Runs](https://andreramos.dev/angular/karma-to-vitest-angular-config/)
- [fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest](https://andreramos.dev/angular/angular-vitest-fakeasync-proxyzone-error/)
