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, but TestBed and ComponentFixture are the same API. What changes is the DOM underneath. The runner uses jsdom, which has no layout engine, so some specs need Vitest browser mode 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 Vitestts
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 needsjsdomWhy
Rendered structure, text, classes, attributesWorksDOM tree without layout.
Click, input, focus, output eventsWorksEvents dispatch without a renderer.
Inputs, outputs, template/class wiringWorksNo browser geometry involved.
getBoundingClientRect, offsetTop, sizesReturns zerosNo layout engine to compute geometry.
Real CSS, media queries, computed stylesNot appliedjsdom parses CSS but does not lay it out.
<canvas> drawing and pixel readsActs like a <div>Needs the canvas peer package, and even then is not a GPU.
Scroll position, IntersectionObserver geometryUnreliableDepends 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 testts
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. For the config side of the move, and why the test target is one line, see Karma to Vitest: The Config That Actually Runs. And before any of it, whether to migrate a given suite at all 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

Angular Vitest Migration Kit

Migrating this suite to Vitest?

The Angular Vitest Migration Kit sizes your migration by risk, then packages the config, the fakeAsync cookbook, an AI-agent skill, and a runnable demo. The conversions in this article, done for you.

Verified on Angular 21.2.14 / Vitest 4.1.8. Includes the migration estimator, the PDF guide, copy-paste config, an AI-agent skill, and a runnable demo.

Get the kit · US$15 →

André Ramosavailable for remote roles, UTC−3Get in touch →

Related

Guide · 6 min

When to Move an Angular Test Suite to Vitest

Vitest is the Angular CLI default, but migrating an existing suite is still experimental: which specs to pilot first, what fakeAsync breaks, and how to keep CI trustworthy.

Read article →

Guide · 7 min

Karma to Vitest in Angular: The Config That Actually Runs

What the Angular v21 Vitest test target actually is (one line), where polyfills and styles come from, the install that drops Karma, and the few options worth adding.

Read article →

Guide · 9 min

fakeAsync and the ProxyZone Error: Fixing Angular Tests Under Vitest

Why fakeAsync, tick, and waitForAsync throw the ProxyZone error under Angular's Vitest runner, and the two fixes: the experimental zone testing patch, or a rewrite to Vitest fake timers.

Read article →