Testing Infrastructure in Angular 22: Vitest, the unit-test Builder, jsdom and What to Test at Which Level
Angular 22's unit-test builder runs Vitest with isolation off, so state kept in a module can carry over between test files depending on the worker count, and a suite that is green locally turns red in CI. In this post we map the test levels for Bookstore's Angular client, set up the angular.json test target with setupFiles, providersFile and runnerConfig, measure jsdom against happy-dom, and run and debug single tests.
Version note: The code in this post was built and run with Angular 22.2 (TypeScript 6.0), Vitest 5.1 and jsdom 30 on Node.js 24.
Guess First
In bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, the book detail page remembers the last three books the user looked at. The first version kept that state in an array inside a module:
// src/app/catalog/recently-viewed.ts
const limit = 3;
const recent: string[] = [];
export function markViewed(isbn: string): void {
const index = recent.indexOf(isbn);
if (index >= 0) {
recent.splice(index, 1);
}
recent.unshift(isbn);
recent.length = Math.min(recent.length, limit);
}
export function recentlyViewed(): readonly string[] {
return recent;
}
The tests were split into two files; one checks the order, the other the three-book limit:
// recently-viewed.spec.ts
it('puts the latest book first', () => {
markViewed('9780441013593'); // Dune
markViewed('9780141439587'); // Emma
expect(recentlyViewed()).toEqual(['9780141439587', '9780441013593']);
});
// recently-viewed-limit.spec.ts
it('keeps only the last three books', () => {
['9780261103344', '9780141439549', '9780141439587', '9780441013593'].forEach(markViewed);
expect(recentlyViewed()).toHaveLength(3);
expect(recentlyViewed()[0]).toBe('9780441013593');
});
On the developer's machine ng test passes all ten tests in four files. The same commit is red on a CI runner with 2 vCPUs. Which test failed, and why did it pass locally? Hint: do the two files run separately in Vitest, or do they share something?
Answer: Test Files Can Share a Module
This was the CI output:
❯ bookstore-web src/app/catalog/recently-viewed.spec.ts (1 test | 1 failed) 2ms
❯ recently viewed books (1)
× puts the latest book first 2ms
FAIL bookstore-web src/app/catalog/recently-viewed.spec.ts > recently viewed books > puts the latest book first
AssertionError: expected [ '9780141439587', …(2) ] to deeply equal [ '9780141439587', '9780441013593' ]
- Expected
+ Received
[
"9780141439587",
"9780441013593",
+ "9780141439549",
]
Test Files 1 failed | 3 passed (4)
Tests 1 failed | 9 passed (10)
Middlemarch at the end of the list is left over from the limit test in the other file. There are two reasons. First, Angular's @angular/build:unit-test builder runs Vitest with isolate: false. The builder's schema describes this as a default chosen to match the Karma/Jasmine experience; Karma also ran every spec in a single browser page. With isolation off, test files that land on the same worker do not reload modules, so the recent array carries over from file to file. Second, for a single run Vitest sets the worker count to the number of cores minus one. The ten-core developer machine allowed nine workers; each of the four files got its own worker and nothing was shared. On a two-core runner only one worker is left, and all four files ran one after another in the same process. To reproduce the CI environment locally I temporarily put maxWorkers: 1 in vitest-base.config.mts; the output above comes from that run.
The problem is not limited to small machines. When I added twenty copies of the same spec and ran the suite twice on ten cores, 14 files failed in one run and 13 in the other. Once there are more files than workers, files share workers, and which file runs after which changes from run to run. A suite whose number of failures changes between runs is the definition of a flaky suite.
ng test --isolate gives each file a clean environment, and the 23-file experiment passed every time. The price is time: averaged over three runs, 0.7 seconds without isolation and 1.75 seconds with it. The real fix is to remove state that lives across test files. In Angular, state that lives as long as the app belongs in a service on the root injector; TestBed resets the injector after each test, so the state does not leak between tests:
import { Injectable, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class RecentlyViewed {
private readonly isbns = signal<readonly string[]>([]);
readonly list = this.isbns.asReadonly();
mark(isbn: string): void {
this.isbns.update((list) => [isbn, ...list.filter((item) => item !== isbn)].slice(0, 3));
}
}
import { TestBed } from '@angular/core/testing';
import { RecentlyViewed } from './recently-viewed';
describe('RecentlyViewed', () => {
it('puts the latest book first', () => {
const recent = TestBed.inject(RecentlyViewed);
recent.mark('9780441013593'); // Dune
recent.mark('9780141439587'); // Emma
expect(recent.list()).toEqual(['9780141439587', '9780441013593']);
});
it('keeps only the last three books', () => {
const recent = TestBed.inject(RecentlyViewed);
['9780261103344', '9780141439549', '9780141439587', '9780441013593'].forEach((isbn) => recent.mark(isbn));
expect(recent.list()).toEqual(['9780441013593', '9780141439587', '9780141439549']);
});
});
I ran this version three times with maxWorkers: 1 and it passed every time. TestBed.inject and the rest of service testing are the topic of the next post. In this one we first settle where, how and with which settings the tests run.
Test Levels in an Angular App
The Test Strategy series covers the test types, the difference between the pyramid and the trophy, and how to decide at which level to test a behavior, in its posts "Test Pyramid, Testing Trophy and Honeycomb" and "What to Test at Which Level". Here we look at what those ideas mean in Angular. An Angular app has five levels, and each one uses a different tool:

- Pure function: code that never touches Angular. ISBN validation, cart totals, price rounding. No TestBed needed; the
isbn.spec.tstests in this post took 0–1 ms. - Service: classes that use
inject()or hold signals or anhttpResource. TestBed builds the injector and HTTP is faked withHttpTestingController. The topic of post 2. - Component: template, inputs, outputs and the DOM. With
TestBed.createComponentor Testing Library, inside jsdom. TheApptest in this post took 19 ms. The topic of posts 3 and 4. - Routed feature: the page behind a route, together with its real child components, guard and resolver; only HTTP is faked. With
RouterTestingHarnessand Mock Service Worker, in post 5. - E2E: the built app in a real browser, with Playwright. We fake the API with
page.routeand test only the UI flow (post 8). Full-stack E2E with a real API and database is the subject of the Integration and E2E Testing series.
Here is the test map for Bookstore:
| Behavior | Level | Why that level |
|---|---|---|
| The ISBN-13 check digit | pure function | a calculation, no DOM or dependency injection |
| Changing the category filter sends a request to the right URL | service | the shape of the HTTP request is the service's contract |
| The book card disables "Add to cart" when stock runs out | component | the behavior lives in the template |
| The checkout form shows its error messages | component (Testing Library) | the text the user sees and the accessible names |
| The order detail page does not open someone else's order | routed feature | guard, route param and HTTP work together |
| Search for a book, add it to the cart, check out | E2E | several pages and real browser behavior |
The rule of thumb is short: test at the lowest level that can see the behavior. A price rounding bug is also caught by a component test, but when it fails you cannot tell whether the cause is in the template or in the calculation. Conversely, a guard reading the wrong route param never shows up in a pure unit test; that bug only appears when the parts come together. In Angular, the integration layer that the trophy emphasizes is usually the component and routed feature tests: a real template, real child components and HTTP faked at the boundary.
What Does ng new Set Up?
A project created with npx @angular/cli@22.2.7 new bookstore-web has three test-related pieces. package.json gets vitest (^5.0.0) and jsdom (^30.0.0) as devDependencies, and typescript is ~6.0.2. Angular 22.2's peer dependency range is >=6.0 <6.1, so Angular projects use TypeScript 6.0; the TypeScript 7.0 on npm does not go into this project. The test target in angular.json is a single entry:
"test": {
"builder": "@angular/build:unit-test"
}
tsconfig.spec.json adds the vitest/globals types. That is why spec files use describe, it, expect and vi without importing them; the builder runs Vitest with globals: true. If you prefer explicit imports, import { describe, expect, it } from 'vitest'; works too.
The most reliable place to read the defaults of a builder with no options is its own schema (node_modules/@angular/build/src/builders/unit-test/schema.json). The default include is **/*.spec.ts and **/*.test.ts. The environment is happy-dom if happy-dom is installed, otherwise jsdom. isolate is off. watch is on in a terminal and off elsewhere. quiet is on locally and off in CI, which is why a CI log shows a chunk table on every run.
Opening Up the test Target in angular.json
At the end of this post Bookstore's test target looks like this:
"test": {
"builder": "@angular/build:unit-test",
"options": {
"setupFiles": ["src/test-setup.ts"],
"providersFile": "src/test-providers.ts",
"runnerConfig": "vitest-base.config.mts"
}
}
setupFiles are files that run before every test file. According to the builder's description, the app's polyfills and TestBed are initialized before them. Bookstore registers the Turkish locale data here; in the app the same line sits in main.ts:
// src/test-setup.ts
import { registerLocaleData } from '@angular/common';
import localeTr from '@angular/common/locales/tr';
registerLocaleData(localeTr);
providersFile is a file whose default export is an array of providers. These providers are added to TestBed in every test. We moved the app's locale providers to src/app/locale.ts and use them both in app.config.ts and here, so the tests run with the same LOCALE_ID as the app. The PricePipe tests in post 2 will rely on this:
// src/app/locale.ts
import { DEFAULT_CURRENCY_CODE, LOCALE_ID, Provider } from '@angular/core';
export const localeProviders: Provider[] = [
{ provide: LOCALE_ID, useValue: 'tr' },
{ provide: DEFAULT_CURRENCY_CODE, useValue: 'TRY' },
];
// src/test-providers.ts
import { localeProviders } from './app/locale';
export default localeProviders;
The Angular documentation says new setup files should also be listed in the include of tsconfig.spec.json. The tests ran without that, but then the editor and tsc -p tsconfig.spec.json do not type-check those files.
runnerConfig is Vitest's own config file. ng generate config vitest creates an empty vitest-base.config.mts and adds "runnerConfig": true to angular.json; with true the builder looks for the file itself and prints "Automatically searching for and using Vitest configuration file." at the start of every run. I gave the path explicitly. Bookstore's file has a single setting:
// vitest-base.config.mts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
restoreMocks: true,
},
});
restoreMocks calls vi.restoreAllMocks() before each test and puts back the original implementation of methods replaced with vi.spyOn. I checked that the setting gets through the builder with a two-test experiment: the first test ran vi.spyOn(clock, 'now').mockReturnValue(42), and in the second test clock.now() returned the original value with the setting on and 42 with it off. Spies show up a lot in post 5.
The file extension matters. When I first wrote the config as vitest-base.config.ts, Vite printed a warning that a file loaded as CommonJS used ESM syntax and suggested a .mjs extension or "type": "module". The .mts extension makes the warning go away.
The config file does not let you change every Vitest setting. The builder finds the test files itself; when the config contains include, it prints this warning and drops the setting:
[vite] warning: The "test.include" option in the Vitest configuration file is not supported. The Angular CLI Test system will manage test file discovery.
According to the builder's source, the same applies to projects, and watch is overridden by the CLI's --watch option. These settings belong in angular.json or on the command line.
Coverage needs the @vitest/coverage-v8 package. Without it, ng test --coverage stops with MISSING DEPENDENCY Cannot find dependency '@vitest/coverage-v8'. After adding it with npm i -D @vitest/coverage-v8@5.1.0:
$ npx ng test --no-watch --coverage --coverage-reporters text
=============================== Coverage summary ===============================
Statements : 100% ( 34/34 )
Branches : 100% ( 19/19 )
Functions : 100% ( 7/7 )
Lines : 100% ( 21/21 )
================================================================================
What this 100% does not tell you is covered in the Test Strategy series post "What Code Coverage Tells You and What It Doesn't"; here we only do the setup. The browsers option turns on Vitest browser mode, which runs tests in a real browser; we get to it in post 7.
jsdom or happy-dom?
The tests run inside Node.js, and the DOM is emulated by either jsdom or happy-dom. To see the difference I ran the same small test in both environments. The test fails on purpose so the values show up in the error message:
it('probes the DOM environment', () => {
const div = document.createElement('div');
div.innerHTML = '<p>Dune</p><p style="display:none">Hidden</p>';
document.body.appendChild(div);
const probe = [
div.innerText,
div.textContent,
div.getBoundingClientRect().height,
typeof globalThis.IntersectionObserver,
typeof window.matchMedia,
];
throw new Error(JSON.stringify(probe));
});
jsdom 30: Error: [null,"DuneHidden",0,"undefined","undefined"]
happy-dom 21: Error: ["Dune","DuneHidden",0,"function","function"]
In jsdom innerText is not implemented (it is undefined, which JSON shows as null), and neither are IntersectionObserver and matchMedia. In happy-dom innerText returned "Dune", skipping the hidden paragraph, and both APIs exist. textContent is "DuneHidden" in both, and the element's height is 0 in both: neither computes layout. There is a time difference too. Bookstore's three-file suite took 383–389 ms with jsdom and 261–264 ms with happy-dom over three runs. Most of the gap is environment setup; according to Vitest's summary line, 62% of the time went to setting up the environment with jsdom and 44% with happy-dom.
Bookstore stays on jsdom. The reasoning: the extra APIs happy-dom provides are an imitation, not the real behavior. IntersectionObserver exists in happy-dom, but it does not decide whether an element is visible based on real layout. When you need to test layout-dependent behavior such as infinite scrolling, the right tool is a real browser, which is browser mode in post 7. If you want to switch anyway, it is simple: install happy-dom and remove jsdom. The builder picks whichever is installed.
Anatomy of a Spec File
isbn.ts contains two pure functions: normalizeIsbn strips spaces and hyphens, and isValidIsbn13 checks the prefix and the check digit. Here is the test:
import { isValidIsbn13, normalizeIsbn } from './isbn';
describe('isValidIsbn13', () => {
it('accepts a valid ISBN-13 with hyphens', () => {
expect(isValidIsbn13('978-0-441-01359-3')).toBe(true);
});
it('rejects an ISBN-13 whose check digit is wrong', () => {
expect(isValidIsbn13('978-0-441-01359-4')).toBe(false);
});
it.each([
['too short', '978044101359'],
['letters', '978044101359X'],
['wrong prefix', '9770441013593'],
])('rejects input that is %s', (_case, input) => {
expect(isValidIsbn13(input)).toBe(false);
});
});
describe('normalizeIsbn', () => {
it('removes spaces and hyphens', () => {
expect(normalizeIsbn(' 978-0 441-01359-3 ')).toBe('9780441013593');
});
});
The structure is familiar from Jasmine: describe groups, it describes a single behavior, expect builds the assertion. it.each runs the same behavior with different inputs, and %s puts the first value of each row into the test name, so the report shows readable names like "rejects input that is letters". The test names state the behavior, not the method name; the name of a failing test should tell you what broke without opening it. The Unit Test series post "Anatomy of a Good Unit Test" covers this with .NET, and the same rules apply here.
Running Tests: Watch, CI, a Single File and Debugging
In a terminal ng test starts in watch mode and reruns the tests whenever you save a file. Most CI services set the CI=true environment variable, and when the builder sees it, it switches to a single run. If CI is not set, ng test --no-watch --no-progress, as the Angular documentation suggests, does the same; in Bookstore that is the npm run test:ci script.
--include is enough to run a single file. A nice detail: if you pass the implementation file, the builder finds the spec next to it.
$ npx ng test --no-watch --include src/app/catalog/isbn.ts
Test Files 1 passed (1)
Tests 6 passed (6)
To filter by test name, --filter takes a regular expression. With --reporters verbose the skipped tests show up in the list too:
$ npx ng test --no-watch --filter "rejects" --reporters verbose
↓ bookstore-web src/app/catalog/recently-viewed.spec.ts > RecentlyViewed > puts the latest book first
✓ bookstore-web src/app/catalog/isbn.spec.ts > isValidIsbn13 > rejects an ISBN-13 whose check digit is wrong 1ms
✓ bookstore-web src/app/catalog/isbn.spec.ts > isValidIsbn13 > rejects input that is too short 0ms
↓ bookstore-web src/app/app.spec.ts > App > shows the store name and the main navigation
Test Files 1 passed | 2 skipped (3)
Tests 4 passed | 6 skipped (10)
ng test --list-tests lists the discovered files without building; it is the quickest way to see which files are still in after changing the include pattern.
it.only lets you focus a single test in the editor, but if it is left in, CI breaks. When I tried it with CI=true, Vitest marked the test as failed: "Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error". That works in your favor: a forgotten only cannot silently disable the rest of the suite.
For debugging, ng test --debug opens the Node.js inspector and waits:
$ npx ng test --debug --include src/app/catalog/isbn.spec.ts
RUN v5.1.0 /home/user/bookstore-web
Debugger listening on ws://127.0.0.1:9229/<id>
For help, see: https://nodejs.org/learn/getting-started/debugging
You can attach VS Code's Node.js debugger to that port or open it from chrome://inspect in Chrome. Breakpoint types and working with source maps are covered in the Browser DevTools series post "JavaScript Debugging with the Sources Panel"; once the inspector is attached, everything works as described there.
In Production
Spell out the test command in CI: ng test --no-watch --no-progress or CI=true ng test. A test command left in watch mode keeps the CI job waiting until it times out. Uploading test reports and coverage in the pipeline and running Playwright alongside is the subject of the GitHub Actions series post "Angular CI with GitHub Actions".
Base the isolate decision on measurements. In Bookstore's experiment suite isolation made the run 2.5 times slower. As the suite grows, that gap turns into minutes. Keeping the default and making sure tests do not share state is cheaper. Use --isolate as a diagnostic tool rather than a permanent setting: if the suite passes with isolation and fails without it, your tests share something.
Do not assume local green means CI green. The worker count depends on the machine, and with isolation off that changes which files share a process. Temporarily adding maxWorkers: 1 to vitest-base.config.mts is a good way to imitate CI on a local machine.
Upgrade versions together. @angular/build 22.2's peer range is ^4.0.8 || ^5.0.0 for vitest and >=6.0 <6.1 for TypeScript. Check that range before upgrading Vitest's major version independently of Angular. @vitest/coverage-v8 must also have the same version as vitest.
Common Pitfalls
Trying to change CLI-managed settings in the Vitest config. include, projects and watch are overridden or removed by the builder, and the warning can get lost in the build output. Test file selection belongs in angular.json or on the command line.
Leaving mutable state at module level. The bug at the start of this post shows up, as long as isolation is off, depending only on the worker count. A let, an array or a Map inside a module can carry over between tests; a service tied to the injector is reset by TestBed for every test.
Grouping tests by the structure of the implementation file. Opening a describe for every private method means the tests have to be rewritten whenever the code is refactored. Build your describe blocks around the behavior the user or the caller sees: "rejects an ISBN-13 whose check digit is wrong" stays correct after a refactor.
Turning on happy-dom without noticing. The builder prefers an installed happy-dom over jsdom. When you add happy-dom as a devDependency for some other tool, the test environment changes silently; in the experiment above even innerText behaved differently.
A Small Experiment
★☆☆ Add an "accepts an ISBN-13 that starts with 979" test to isbn.spec.ts. Compute the check digit yourself to find a valid 979 ISBN, then run only this file with --include src/app/catalog/isbn.ts.
★★☆ Turn RecentlyViewed back into the module from the start of this post and run the tests ten times with and without --isolate. How does the result change when you add maxWorkers: 1 to vitest-base.config.mts? Which one shows you the bug every time?
★★★ Install happy-dom and remove jsdom. Measure the suite's duration three times, then add an assertion that uses innerText to the App test and compare the result in both environments.
The test infrastructure is ready; now we can move on to Bookstore's actual code. In the next post we write the CatalogService that loads the catalog, the PricePipe that formats prices with the Turkish locale, and the httpResource tests: catching requests with HttpTestingController, asserting signal and resource states, and fake timers for debouncing.