Blog
8 min read

Angular visual regression testing with Vitest

Angular visual regression testing with Vitest: run your TestBed specs in browser mode with ng test, capture each render, and diff it on every pull request.

Igor LuchenkovIgor LuchenkovBuilding UI Verify
angularvisual regression testingvitesttestbedcomponent testing
An Angular TestBed spec that sets the inputs of an OrderSummary component, waits for whenStable, asserts the 112.90 euro total and calls takeSnapshot, next to the capture UI Verify rendered from it: order 10482, paid, two line items, total 112.90 euros.
On this page

Angular visual regression testing means rendering your Angular components in a real browser, capturing how each one looks, and comparing every capture against an approved baseline on each pull request. A difference nobody intended is a regression; a difference the pull request set out to make is accepted and becomes the next baseline. On a current Angular project most of the pieces ship in the box. Angular 21 made Vitest the default runner behind ng test, Vitest can drive a real Chromium, and a TestBed spec that renders a component in that browser already holds everything a screenshot needs.

This guide follows that path on a scratch workspace: ng new on Angular 22.2, one standalone component, two TestBed specs, and the captures UI Verify rendered from them. Every config file, terminal line and image below comes from that run, so each step is one that worked on a real project rather than one that ought to.

What changed when ng test moved to Vitest?

Angular 21 replaced Karma with Vitest as the default unit-test runner for new projects, behind the @angular/build:unit-test builder (Angular's testing guide, checked 2026-10-17). A fresh workspace installs vitest and jsdom, and the specs run in Node.js against that emulated DOM. That is quick and right for logic, but jsdom does no layout and paints nothing, so it cannot tell you what a component looks like. The builder has a second mode for that: install a browser provider, name a browser in the test target's browsers option, and ng test runs the same specs in a real browser.

bash
npm i -D @vitest/browser-playwright playwright
npx playwright install chromium
npx ng test --browsers=chromiumHeadless

Headless mode turns on by itself when the CI environment variable is set, and the Headless suffix forces it on a laptop. Teams still on Karma and Jasmine have a documented way across: point the test target at the new builder, then run the refactor-jasmine-vitest schematic, which rewrites spies, matchers and focused specs. Angular's migration guide still labels migrating an existing project experimental as of 2026-10-17, so plan a review pass over whatever the schematic touched.

How do you add visual capture to ng test?

Through runnerConfig. Angular assembles the Vitest configuration in memory from angular.json, so a new workspace has no vitest.config.ts to edit. The runnerConfig option names a Vitest config file that gets merged into the one Angular builds, and ng generate config vitest scaffolds it for you. The @uiverify/vitest plugin goes in that file, while the browser stays in angular.json, where Angular expects it.

bash
npm i -D @uiverify/vitest
npx ng generate config vitest
angular.json (the test target)
"test": {
  "builder": "@angular/build:unit-test",
  "options": {
    "runnerConfig": "vitest-base.config.mts",
    "browsers": ["chromiumHeadless"],
    "browserViewport": "428x420",
    "providersFile": "src/test-providers.ts"
  }
}
vitest-base.config.mts
import { defineConfig } from 'vitest/config';
import { uiverifyPlugin } from '@uiverify/vitest/plugin';

export default defineConfig({
  plugins: [uiverifyPlugin()],
});

Two settings are particular to Angular, and the scratch run needed both:

  • Keep the SDK out of the spec bundle. The Angular builder compiles and bundles every spec before Vitest loads it. Listing @uiverify/vitest under externalDependencies in the build configuration your tests use (development, unless the test target sets another buildTarget) leaves that import for Vitest to resolve, so the spec and the plugin share one copy of the SDK. takeSnapshot() relies on that shared copy to know which test is running.
  • Choose the viewport. browserViewport sets the page size the specs render into, and the archive records it, so each capture comes back at the width you picked instead of the browser default.
angular.json (the build target)
"build": {
  "configurations": {
    "development": {
      "externalDependencies": ["@uiverify/vitest"]
    }
  }
}
The test target, the Vitest config it points at, and the run they produced: Vitest 5.0.3 in headless Chromium, both TestBed specs green, one archived capture per spec.

Does TestBed work in Vitest browser mode?

Yes, with no changes. TestBed.createComponent attaches the component to the real document, fixture.componentRef.setInput() sets its signal inputs, and await fixture.whenStable() waits until a zoneless app has finished rendering. The visual part is a single call, placed where the component is in the state you want on record:

src/app/order-summary/order-summary.spec.ts
import { TestBed } from '@angular/core/testing';
import { takeSnapshot } from '@uiverify/vitest';
import { OrderSummary } from './order-summary';

const items = [
  { name: 'Linen shirt', variant: 'Sand / M', qty: 1, unitPrice: 59 },
  { name: 'Canvas tote', variant: 'Natural', qty: 2, unitPrice: 24.5 },
];

describe('OrderSummary', () => {
  it('renders a paid order', async () => {
    const fixture = TestBed.createComponent(OrderSummary);
    fixture.componentRef.setInput('orderId', '#10482');
    fixture.componentRef.setInput('items', items);
    fixture.componentRef.setInput('shipping', 4.9);
    await fixture.whenStable();

    expect(fixture.nativeElement.textContent).toContain('€112.90');
    await takeSnapshot('paid');
  });
});

takeSnapshot('paid') archives the DOM at that moment, along with the stylesheets and fonts it uses. A passing spec that never calls it is archived when it ends, which is how the untouched app.spec.ts from ng new produced captures of its own. Calling it explicitly is still the better habit: it names the capture, it fixes the moment after whenStable() and the assertion, and it lets one spec record several states, a menu closed and then open, under separate names.

Angular's emulated view encapsulation travels with the capture. Component styles are scoped with generated _ngcontent attributes and injected into the document head, and the archive carries them like any other stylesheet. The renders that came back from UI Verify had the green Paid badge and the amber Pending one, the same as in the test browser.

What does the app provide that a TestBed spec does not?

Whatever app.config.ts registers. bootstrapApplication never runs in a unit test, so providers the app sets up at bootstrap are absent from TestBed unless the spec adds them. A logic test rarely notices. A visual test does, because some of those providers decide what gets drawn. The scratch app sets DEFAULT_CURRENCY_CODE to EUR in app.config.ts, and the template uses a bare currency pipe, so the running app shows euros. The first capture of the paid order showed dollars. TestBed had fallen back to Angular's built-in USD, and an assertion that only checked the amount, 112.90, still passed.

The fix lives in the test target: providersFile, a file whose default export is an array of providers that every spec receives. Mirror the bootstrap providers that change rendering there, such as the currency, the locale, date formats, or a theme the template reads. Global styles need no extra step, because the unit-test builder brings in the build target's styles; the grey page behind the card in every capture comes from src/styles.css.

src/test-providers.ts
import { DEFAULT_CURRENCY_CODE, EnvironmentProviders, Provider } from '@angular/core';

const testProviders: (Provider | EnvironmentProviders)[] = [
  { provide: DEFAULT_CURRENCY_CODE, useValue: 'EUR' },
];

export default testProviders;
The same TestBed spec rendered by UI Verify twice, staged for this post. Without a providers file the currency pipe falls back to USD; with DEFAULT_CURRENCY_CODE provided to TestBed, the capture matches the euros the app shows.

How do you check an Angular component before the pull request?

Run uiverify check, the preview build for an edit loop, on the archive a scoped ng test wrote. It renders the archive on the same fleet a CI build uses and lists what changed, but it posts no GitHub check and never moves a CI baseline. Scoping the spec run scopes the check, so --include on ng test keeps the archive to the folder you are working in.

bash
CI=1 npx ng test --no-watch --include src/app/order-summary
UIVERIFY_API_KEY=uv_proj_xxx npx -y uiverify@latest check --static-dir ./uiverify-archive

These two specs had no baseline yet, so both came back awaiting review, which is the expected first result. Two checks run a few minutes apart on the same commit returned byte-identical PNGs for both captures. Once a build on your default branch sets the baselines, each later run is compared against those images and only a real difference comes back.

The check from the scratch project and the two PNGs it rendered from the archive, captured at 2x device scale for this post. Neither spec had a baseline yet, so both wait for review.

When a capture does change, review works as for any Vitest suite: the baseline, the new render and the diff side by side, an AI judge verdict on whether the change looks intended, and the same data over MCP, so the coding agent that edited the template can look at the pixels it moved. Triage with your agent walks through that loop, and checking visual changes covers scoping a preview to what you touched.

How do you run Angular visual tests in CI?

In CI the job runs ng test once to write the archive, then uploads it, on pull requests and on pushes to your default branch, which is where the baselines come from. Install Chromium on the runner first, and check out the full history so the baseline resolves against your branch:

.github/workflows/visual.yml (the steps)
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx ng test --no-watch
      - run: npx -y uiverify@latest upload --static-dir ./uiverify-archive
        env:
          UIVERIFY_API_KEY: ${{ secrets.UIVERIFY_API_KEY }}

The first run on your default branch has nothing to compare against, so every capture comes back awaiting review and the upload step fails. Accept that build once in the dashboard, or add --auto-accept-changes to the default-branch upload so it seeds the baselines on its own. The Vitest quickstart has the complete workflow file, plus how to make the UI Verify check required so an accepted change clears the job. Expect every Angular test to render on every build: skipping unchanged tests reads the Vite module graph, and the Angular builder bundles each spec before Vitest sees it, so @uiverify/vitest leaves those tests out of the graph rather than risk skipping one a change affected. Determinism carries over from plain Vitest too: feed components fixed inputs instead of live data, and freeze the clock for anything that prints a date. Flaky Vitest visual tests covers both, and the Vitest determinism skill hands the same checklist to your agent.

Is Storybook for Angular still an option?

Yes, if your team already keeps Angular stories. UI Verify captures a built Storybook rather than your source, so that route is to build the static Storybook and upload the output directory, as the Storybook quickstart shows. For a team without stories, the TestBed specs are the shorter road: they already exist, ng test already runs them, and with one plugin each of them can leave a picture behind. The same idea for other frameworks is in the Vue and React guides.

Visual test your Angular components

Turn the TestBed specs ng test already runs into visual tests: one Vitest plugin, a capture per spec, and every change reviewed on the pull request.

Start for free

No credit card required.

ShareXLinkedIn
Related skill

Deterministic Vitest captures

Stop component-test diffs that flake without a real change.