Angular Testing (Vitest)
Fast, deterministic unit and component tests through TestBed on a zoneless app. Assert
public behavior – signal outputs, rendered DOM, emitted outputs – never private fields. This is
the how-to for in-process tests; browser end-to-end work belongs to
create-e2e-tests. Before writing or editing any .spec.ts, read
style-guide/style-guide.spec.md – its Must/Should/Don't
rules win over anything here.
1. Detect the setup – gate before writing
Per AGENTS.md: if no Vitest setup is found, do not write or modify .spec.ts files. Confirm at
least one of:
angular.jsontesttarget uses@angular/build:unit-test(runner defaults tovitest), or- a
vitest.config.*exists, and vitestis indevDependencies.
None present → stop and report; do not scaffold a runner unprompted.
Done when Vitest is confirmed present, or you have refused with the reason.
2. Run commands
This repo: pnpm test → ng test (Vitest, jsdom). Use pnpm exec ng test to pass flags:
- Full suite once:
pnpm test --watch=false - One file:
pnpm exec ng test --watch=false --include=src/app/…/foo.component.spec.ts - By test name:
pnpm exec ng test --watch=false --filter="emits on submit" - Watch:
pnpm exec ng test --watch(already the TTY default) - Coverage:
pnpm exec ng test --watch=false --coverage
3. TestBed essentials – standalone + zoneless
Standalone components/pipes go in imports, not declarations. Zoneless is the Angular v22 default
(style-guide/style-guide.ts.md), so TestBed runs zoneless out of the box; add
provideZonelessChangeDetection() to the test setup only when a legacy exercise still boots the
application with Zone.js, so the tests stay zoneless regardless.
import { provideZonelessChangeDetection } from '@angular/core';
import { TestBed } from '@angular/core/testing';
TestBed.configureTestingModule({
imports: [ButtonComponent],
providers: [provideZonelessChangeDetection()],
});
const fixture = TestBed.createComponent(ButtonComponent);
fixture.componentRef.setInput('destructive', true); // set signal inputs, never assign
fixture.detectChanges(); // OnPush: one CD pass binds inputs, runs effects
expect(fixture.nativeElement.className).toContain('destructive');
- Set signal inputs with
fixture.componentRef.setInput(name, value). - For OnPush, assert after
fixture.detectChanges()(synchronous pass) orawait fixture.whenStable()(drives CD + drains async). Under zoneless the fixture auto-detects, sowhenStable()alone reflects async updates. - Read a component signal directly:
expect(fixture.componentInstance.count()).toBe(1).
4. Signals-first assertions
signal / computed / linkedSignal are read by calling them; computed recomputes lazily on
read once a dependency changes. effect() runs during change detection – flush pending effects with
TestBed.tick() (replaces the deprecated flushEffects()) or await fixture.whenStable().
Use Vitest fake timers for setTimeout/setInterval and RxJS time operators: enable them with
vi.useFakeTimers(), advance with await vi.advanceTimersByTimeAsync(ms), and restore with
vi.useRealTimers() during cleanup. Angular fakeAsync requires Zone.js and does not work with
this Vitest setup. Full timing rules and resource()/rxResource() patterns:
references/signals-and-async.md.
5. DI & HTTP mocking
Mock the HTTP backend with provideHttpClientTesting; assert requests via HttpTestingController.
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
providers: [provideZonelessChangeDetection(), provideHttpClient(), provideHttpClientTesting()];
const httpTesting = TestBed.inject(HttpTestingController);
service.load();
httpTesting.expectOne('/api/users').flush([{ id: 1 }]);
afterEach(() => httpTesting.verify()); // no unexpected/outstanding requests
Fake an injected service with a provider override: { provide: UserService, useValue: fakeUserService } –
override only the seam that isolates the unit; keep everything else real (§7). For
resource()/HttpClient-backed stores, cross-check
ng-data-access and ng-signal-store.
6. Component harnesses over raw DOM
Prefer a CDK component harness to querySelector chains: harnesses expose a behavior-level API,
survive DOM restructuring, and auto-run change detection. Get one via TestbedHarnessEnvironment:
const loader = TestbedHarnessEnvironment.loader(fixture);
const button = await loader.getHarness(ButtonHarness);
await button.click();
Writing a custom harness, and a spartan-ng example: references/harnesses.md.
7. Test-quality rules
- One behavior per
it– descriptivedescribe/it; no order dependence; no committed.only/.skip. - Real code over mocks – mock only true boundaries (HTTP, time, third parties); never test framework internals.
- Stable queries – role/label/text or harnesses; never
_ngcontent-*/ng-reflect-*.
These align with the anti-patterns catalogue in
test-driven-development/references/testing-anti-patterns.md –
read it there; for when/order discipline see test-driven-development.
8. Verify loop
- For new behavior, run the new spec alone and observe the expected failure (
--include=<file>). For existing behavior, keep a passing characterization baseline and prove red-capability with a scoped temporary mutation in an isolated copy. - Make it pass; keep assertions meaningful (never gut one to force green).
- Run the full suite once (
pnpm test --watch=false) to catch cross-test breakage.
A stubborn red that points at production code is a feedback loop for
diagnosing-bugs, not a reason to weaken the test.
Done when the spec went red→green and the full suite is green.
Checklist
- Vitest setup confirmed (else refused per
AGENTS.md) -
provideZonelessChangeDetection()in providers; inputs viasetInput - Signals asserted through public reads; Vitest fake timers used for timer-dependent behavior and restored
- HTTP mocked with
provideHttpClientTesting;httpTesting.verify()inafterEach - Harnesses (not brittle DOM queries) for component interaction
- Spec ran red→green; full suite green; no
.only/.skipleft