#The Angular testing setup
AddedMy notes stop before testing, so this whole chapter is new. Every example in it — and every claim in the routing chapter — was run with the setup a fresh Angular 22 project gets from ng new.
The idea: a unit test should exercise your code the way Angular would run it, but without a browser window, a server or a real backend. Angular splits that job in two. Vitest is the test runner — it finds *.spec.ts files, runs describe/it blocks, and gives you expect, vi.fn() and fake timers. TestBed is Angular's part — it builds a small, throwaway Angular application per test (an injector with your providers, the compiler, the change-detection scheduler), so you can create components and inject services exactly as the real app would.
The mental model is a fresh mini-app for every it. TestBed.configureTestingModule({ imports, providers }) is the test's appConfig; TestBed.createComponent(X) bootstraps one component into it; TestBed.inject(Y) asks its injector for a service. After each test the whole thing is destroyed (teardown.destroyAfterEach defaults to true), so no state leaks between tests.
What CLI 22 sets up for you:
angular.jsonhas"test": { "builder": "@angular/build:unit-test" }. Itsrunneroption defaults tovitest, and with nobrowsersconfigured the tests run in Node with jsdom, a JavaScript implementation of the DOM.- The builder compiles your app and specs with the same esbuild-based application builder as
ng build— including strict template type-checking, so a template error fails the test run before any test executes. - It initialises TestBed for you (the option docs say "the Angular TestBed are always initialized"); there's no
test.tsorkarma.conf.js. tsconfig.spec.jsonadds"types": ["vitest/globals"], which is whydescribe,it,expectandvineed no import.- The app is zoneless and components are OnPush by default — both matter for how tests wait (below).
The generated spec for ng generate component gen-demo:
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GenDemo } from './gen-demo';
describe('GenDemo', () => {
let component: GenDemo;
let fixture: ComponentFixture<GenDemo>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GenDemo],
}).compileComponents();
fixture = TestBed.createComponent(GenDemo);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});beforeEachruns before everyit, so each test gets its own TestBed configuration and its own component.imports: [GenDemo]— a standalone component goes inimports, the same place it would go in another component. In the NgModule era (techStatus, Angular 16) you'd seedeclarations: [GenDemo]and a list of modules instead.compileComponents()compiled external templates in the old JIT setup. With the application builder the templates are already compiled ahead of time; I removed the call from a test of the generatedAppcomponent (which usestemplateUrl) and it still rendered. The schematic keeps it, and it's harmless.TestBed.createComponent(GenDemo)creates the component and attaches its host element to the test document. The returned fixture is your handle on it:componentInstance(the class instance),nativeElement(its DOM element),componentRef(to set inputs),debugElement(Angular's wrapper for querying).await fixture.whenStable()waits until Angular has nothing pending — in particular the first change-detection pass that renders the template. In a zoneless app rendering is scheduled, not synchronous, so this await is how a test says "let Angular do its thing" (see Testing a component).
ng testRunning it:
npx ng test --watch=false # the whole suite once (CI)
npx ng test # watch mode, re-runs on save
npx ng test --include src/app/testing # only specs under a folder (a directory path is expanded to its specs)
npx ng test --filter 'UserApi' # only tests whose suite/test name matches the regex
npx ng test --watch=false --reporters=verbose # list every test
npx ng test --coverage # coverage report❯ Building...
✔ Building...
Application bundle generation complete. [1.092 seconds]
RUN v5.0.3 /Users/rohitvarma/AndroidStudioProjects/StudySites/.sandbox/angular/ch1316
Test Files 14 passed (14)
Tests 86 passed (86)
Start at 22:25:49
Duration 1.70s (environment 38%, setup 26%, tests 18%, transform 11%, import 7%, worker 1%)- Building is the Angular compiler step. A template type error stops here with the same NGxxxx error
ng buildwould print, e.g. "NG8001: 'router-outlet' is not a known element". - RUN v5.0.3 is Vitest. It runs each spec file in a jsdom environment; "environment 38%" of the 1.7 s was setting jsdom up.
- 86 tests (this chapter's plus the routing chapter's) ran in under two seconds. That speed — compared with booting a real Chrome for Karma — is a big part of why the CLI switched.
- One trap I hit:
--include 'src/app/testing/**'(a glob) also picked up non-spec files and reported 8 "No test suite found in file …/cart.ts" failures. Pass the directory itself, or a**/*.spec.tsglob.
A failing test shows the expected and received values and points at the line:
× ng-sandbox src/app/testing/fail.spec.ts > Counter > forgot to wait for change detection 18ms
→ expected 'Count: 0' to be 'Clicks: 0' // Object.is equality
AssertionError: expected 'Count: 0' to be 'Clicks: 0' // Object.is equality
Expected: "Clicks: 0"
Received: "Count: 0"
❯ src/app/testing/fail.spec.ts:9:71
8| fixture.componentRef.setInput('label', 'Clicks');
9| expect(fixture.nativeElement.querySelector('.label').textContent).…That one is the most common Angular test failure there is — the next topic explains it.
From my notes' techStatus world to now:
| Angular 16 (Karma + Jasmine, zone.js) | Angular 22 (Vitest, zoneless) |
|---|---|
ng test opens Chrome via Karma | ng test runs Vitest in Node + jsdom |
jasmine.createSpy('get') | vi.fn() |
spyOn(api, 'get').and.returnValue(x) | vi.spyOn(api, 'get').mockReturnValue(x) |
jasmine.createSpyObj('Api', ['get']) | { get: vi.fn() } as a useValue provider |
fakeAsync(() => { …; tick(300); }) | vi.useFakeTimers() + await vi.advanceTimersByTimeAsync(300) |
fixture.detectChanges() after every change | await fixture.whenStable() (or fixture.detectChanges()) |
declarations: [MyComponent] | imports: [MyComponent] |
fakeAsync and waitForAsync depend on zone.js. Under Vitest without it, calling fakeAsync(() => {})() threw: "zone-testing.js is needed for the fakeAsync() test helper but could not be found. Please make sure that your environment includes zone.js/testing". The .d.ts now labels them "IMPORTANT: This API requires Zone.js and cannot be used with the Vitest test runner".