Testing & coverage
Tests are the part of a codebase you trust without reading. This page shows how this app earns that trust: real Vitest specs — surfaced verbatim, the same way every demo shows its source — covering the patterns that actually break in Angular apps, plus an honest account of what the coverage numbers mean. The goal was never a round number on a report. It was assertions worth believing.
1 · Arrange, act, assert
A test reads best when its three beats are visible: set up the world, do the one thing under test, then check exactly what changed. In the SignalStore spec below, each case arranges a fresh store, acts by advancing time or calling a method, and asserts on a single derived signal — never two behaviours tangled in one it. When a test like this fails, the failure names the cause, because the test only gave it one thing to be about.
View the spec (arrange-act-assert)accounts.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { AccountsStore } from './accounts.store';
/*
* This setup is zoneless, so we drive the rxMethod's `timer(400)` with vitest's fake
* timers (which intercept setTimeout) rather than zone.js `fakeAsync`/`tick`.
*/
describe('AccountsStore', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function createStore(): AccountsStore {
return TestBed.runInInjectionContext(() => new AccountsStore());
}
it('starts empty and loading before the timer resolves', () => {
const store = createStore();
// onInit fires load(), so status is already 'loading' but accounts are still empty.
expect(store.status()).toBe('loading');
expect(store.accounts()).toEqual([]);
expect(store.count()).toBe(0);
expect(store.totalBalance()).toBe(0);
});
it('load populates accounts after the timer elapses', async () => {
const store = createStore();
expect(store.status()).toBe('loading');
await vi.advanceTimersByTimeAsync(400);
expect(store.status()).toBe('loaded');
expect(store.count()).toBe(28);
expect(store.accounts().length).toBe(28);
});
it('setQuery filters the derived list', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
const all = store.filtered().length;
expect(all).toBe(28);
const firstName = store.accounts()[0].name;
store.setQuery(firstName);
expect(store.filtered().length).toBeGreaterThan(0);
expect(store.filtered().length).toBeLessThanOrEqual(all);
expect(
store.filtered().every((acc) =>
[acc.name, acc.type, acc.status, acc.region]
.join(' ')
.toLowerCase()
.includes(firstName.toLowerCase()),
),
).toBe(true);
});
it('totalBalance sums every loaded account balance', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
const expected = store.accounts().reduce((sum, acc) => sum + acc.balance, 0);
expect(store.totalBalance()).toBe(expected);
expect(store.totalBalance()).toBeGreaterThan(0);
});
it('setSort reorders the filtered list by balance', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
store.setSort('balance');
const balances = store.filtered().map((acc) => acc.balance);
const sorted = [...balances].sort((a, b) => a - b);
expect(balances).toEqual(sorted);
});
it('setSort by name uses a locale string comparison, not numeric', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
store.setSort('name');
const names = store.filtered().map((acc) => acc.name);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
it('setSort by status orders the string field too', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
store.setSort('status');
const statuses = store.filtered().map((acc) => acc.status);
const sorted = [...statuses].sort((a, b) => a.localeCompare(b));
expect(statuses).toEqual(sorted);
});
it('activeCount counts only active accounts and matches a manual tally', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
const expected = store.accounts().filter((acc) => acc.status === 'active').length;
expect(store.activeCount()).toBe(expected);
/*
* The seeded data weights toward active, so this is a real, non-zero count — not a
* vacuous assertion that would pass on an empty list.
*/
expect(store.activeCount()).toBeGreaterThan(0);
expect(store.activeCount()).toBeLessThanOrEqual(store.count());
});
});
2 · Signals & computed
Signals make derived state easy to test: read the signal, assert the value — no subscription to manage, no tick to flush. The same accounts.store.spec.ts drives the store's methods (setQuery, setSort) and then reads the computed signals (filtered, count, totalBalance) straight back. The point of the assertions is the relationship: a filter never widens the list, and totalBalance equals the sum of what loaded — properties that stay true as the data changes, not a snapshot that rots.
View the spec (signals & computed)accounts.store.spec.ts
import { TestBed } from '@angular/core/testing';
import { AccountsStore } from './accounts.store';
/*
* This setup is zoneless, so we drive the rxMethod's `timer(400)` with vitest's fake
* timers (which intercept setTimeout) rather than zone.js `fakeAsync`/`tick`.
*/
describe('AccountsStore', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
function createStore(): AccountsStore {
return TestBed.runInInjectionContext(() => new AccountsStore());
}
it('starts empty and loading before the timer resolves', () => {
const store = createStore();
// onInit fires load(), so status is already 'loading' but accounts are still empty.
expect(store.status()).toBe('loading');
expect(store.accounts()).toEqual([]);
expect(store.count()).toBe(0);
expect(store.totalBalance()).toBe(0);
});
it('load populates accounts after the timer elapses', async () => {
const store = createStore();
expect(store.status()).toBe('loading');
await vi.advanceTimersByTimeAsync(400);
expect(store.status()).toBe('loaded');
expect(store.count()).toBe(28);
expect(store.accounts().length).toBe(28);
});
it('setQuery filters the derived list', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
const all = store.filtered().length;
expect(all).toBe(28);
const firstName = store.accounts()[0].name;
store.setQuery(firstName);
expect(store.filtered().length).toBeGreaterThan(0);
expect(store.filtered().length).toBeLessThanOrEqual(all);
expect(
store.filtered().every((acc) =>
[acc.name, acc.type, acc.status, acc.region]
.join(' ')
.toLowerCase()
.includes(firstName.toLowerCase()),
),
).toBe(true);
});
it('totalBalance sums every loaded account balance', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
const expected = store.accounts().reduce((sum, acc) => sum + acc.balance, 0);
expect(store.totalBalance()).toBe(expected);
expect(store.totalBalance()).toBeGreaterThan(0);
});
it('setSort reorders the filtered list by balance', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
store.setSort('balance');
const balances = store.filtered().map((acc) => acc.balance);
const sorted = [...balances].sort((a, b) => a - b);
expect(balances).toEqual(sorted);
});
it('setSort by name uses a locale string comparison, not numeric', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
store.setSort('name');
const names = store.filtered().map((acc) => acc.name);
const sorted = [...names].sort((a, b) => a.localeCompare(b));
expect(names).toEqual(sorted);
});
it('setSort by status orders the string field too', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
store.setSort('status');
const statuses = store.filtered().map((acc) => acc.status);
const sorted = [...statuses].sort((a, b) => a.localeCompare(b));
expect(statuses).toEqual(sorted);
});
it('activeCount counts only active accounts and matches a manual tally', async () => {
const store = createStore();
await vi.advanceTimersByTimeAsync(400);
const expected = store.accounts().filter((acc) => acc.status === 'active').length;
expect(store.activeCount()).toBe(expected);
/*
* The seeded data weights toward active, so this is a real, non-zero count — not a
* vacuous assertion that would pass on an empty list.
*/
expect(store.activeCount()).toBeGreaterThan(0);
expect(store.activeCount()).toBeLessThanOrEqual(store.count());
});
});
3 · Component tests with TestBed (zoneless)
The source-viewer spec mounts the real component inside a tiny host, drives it through the DOM the way a user would — click the copy button, switch a tab — and asserts on rendered output, not internals. It runs zoneless: there is no zone.js auto-detection, so the test calls fixture.detectChanges() at the exact moment it wants the view to settle. That is more honest than magic change detection — the test states when work happens, which is precisely the discipline a zoneless app needs in production too.
View the spec (TestBed component test)source-viewer.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SourceViewer, type SourceFile } from './source-viewer';
@Component({
imports: [SourceViewer],
template: `<foo-source
[snippet]="snippet"
[snippets]="snippets"
[note]="note"
[open]="open"
[label]="label"
/>`,
})
class Host {
snippet: SourceFile | null = { file: 'x.ts', lang: 'ts', code: 'const x = 1;' };
snippets: readonly SourceFile[] | null = null;
note = '';
open = false;
label = 'View source';
}
function render(setup?: (h: Host) => void) {
const fixture = TestBed.createComponent(Host);
setup?.(fixture.componentInstance);
fixture.detectChanges();
const el = fixture.debugElement.query(By.directive(SourceViewer)).nativeElement as HTMLElement;
return { fixture, el };
}
describe('SourceViewer', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('renders pre-highlighted html when provided', () => {
const { el } = render(
(h) =>
(h.snippet = {
file: 'a.ts',
lang: 'ts',
code: 'const answer',
html: '<span class="hljs-keyword">const</span> answer',
}),
);
const code = el.querySelector('.foo-source__pre code') as HTMLElement;
expect(code.querySelector('.hljs-keyword')?.textContent).toBe('const');
expect(code.textContent).toContain('answer');
});
it('falls back to escaped code when there is no html (renders as text, not markup)', () => {
const { el } = render((h) => (h.snippet = { file: 'a.ts', lang: 'ts', code: 'a < b && c > d' }));
const code = el.querySelector('.foo-source__pre code') as HTMLElement;
expect(code.children.length).toBe(0);
expect(code.textContent).toContain('a < b && c > d');
});
it('shows the file label and language', () => {
const { el } = render(
(h) => (h.snippet = { file: 'accounts.store.ts', lang: 'ts', code: 'x' }),
);
expect(el.querySelector('.foo-source__file')?.textContent).toContain('accounts.store.ts');
expect(el.querySelector('.foo-source__lang')?.textContent?.trim()).toBe('ts');
});
it('opens when [open] is true and labels the scrollable region', () => {
const { el } = render((h) => {
h.open = true;
h.snippet = { file: 'x.ts', lang: 'ts', code: 'x' };
});
expect((el.querySelector('details') as HTMLDetailsElement).open).toBe(true);
expect(el.querySelector('.foo-source__pre')?.getAttribute('aria-label')).toBe('Source of x.ts');
});
it('groups multiple files as tabs with a note, and switches the active file', () => {
const files: SourceFile[] = [
{ file: 'actions.ts', lang: 'ts', code: 'A' },
{ file: 'effects.ts', lang: 'ts', code: 'B' },
];
const { fixture, el } = render((h) => {
h.snippet = null;
h.snippets = files;
h.note = 'one feature, three files';
});
expect(el.querySelector('.foo-source__note')?.textContent).toContain('one feature');
const tabs = el.querySelectorAll('.foo-source__tab');
expect(tabs.length).toBe(2);
expect(el.querySelector('.foo-source__pre code')?.textContent).toContain('A');
(tabs[1] as HTMLButtonElement).click();
fixture.detectChanges();
expect(el.querySelector('.foo-source__pre code')?.textContent).toContain('B');
expect(tabs[1].getAttribute('aria-pressed')).toBe('true');
});
it('renders empty when given no snippet', () => {
const { el } = render((h) => {
h.snippet = null;
h.snippets = null;
});
expect(el.querySelector('.foo-source__pre code')?.textContent).toBe('');
expect(el.querySelector('.foo-source__file')).toBeNull();
});
it('copies the active file, then flips the label back', async () => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const { fixture, el } = render((h) => (h.snippet = { file: 'a.ts', lang: 'ts', code: 'copy me' }));
const btn = el.querySelector('.foo-source__copy') as HTMLButtonElement;
btn.click();
await vi.advanceTimersByTimeAsync(0);
fixture.detectChanges();
expect(writeText).toHaveBeenCalledWith('copy me');
expect(btn.textContent?.trim()).toBe('Copied');
await vi.advanceTimersByTimeAsync(1500);
fixture.detectChanges();
expect(btn.textContent?.trim()).toBe('Copy');
vi.useRealTimers();
});
it('leaves the label unchanged when the copy is denied', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
vi.stubGlobal('navigator', { clipboard: { writeText } });
const { fixture, el } = render();
const btn = el.querySelector('.foo-source__copy') as HTMLButtonElement;
btn.click();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(btn.textContent?.trim()).toBe('Copy');
});
it('no-ops when the clipboard API is unavailable', () => {
vi.stubGlobal('navigator', {});
const { el } = render();
const btn = el.querySelector('.foo-source__copy') as HTMLButtonElement;
expect(() => btn.click()).not.toThrow();
});
});
4 · Component test harnesses
A harness is the test-facing API for a component: a spec asks "what does this button say?" or "click it" without ever naming .foo-button__label or the disabled attribute. The ButtonHarness below extends the CDK's ComponentHarness; the spec drives the real component through TestbedHarnessEnvironment and asserts on text, variant, and disabled/busy state. The payoff is durability — re-skin the button's DOM, rename a class, move the spinner markup, and the harness absorbs it while every spec that leans on it keeps passing. The contract is the methods, not the markup.
View source — the Button harness & its spec
One concept, two files: the harness exposes a stable, test-facing API for the Button, and the spec drives the real component through it via the CDK TestbedHarnessEnvironment — so the tests never touch the DOM the component is free to change.
import { ComponentHarness } from '@angular/cdk/testing';
/*
* Test harness for the {@link Button} component.
*
* A harness is the test-facing API for a component: callers ask it "what does this
* button say?" or "click it" without reaching into the DOM, classes, or attributes.
* When the implementation changes (a class is renamed, the spinner markup moves), the
* harness absorbs it and the specs that depend on it keep passing — the contract is the
* methods below, not the internals.
*/
export class ButtonHarness extends ComponentHarness {
/*
* The host attribute selector the Button component binds to. CDK uses this to find the
* element(s) the harness wraps.
*/
static hostSelector = 'button[foo-button]';
private readonly label = this.locatorFor('.foo-button__label');
// Visible label text, trimmed.
async getText(): Promise<string> {
return (await this.label()).text();
}
// Whether the button is disabled — covers both the `disabled` and `loading` states.
async isDisabled(): Promise<boolean> {
const host = await this.host();
return host.getAttribute('disabled').then((value) => value !== null);
}
// Whether the button is announcing in-progress work to assistive tech.
async isBusy(): Promise<boolean> {
const host = await this.host();
return (await host.getAttribute('aria-busy')) === 'true';
}
// The resolved `variant` modifier (primary, secondary, ghost, danger).
async getVariant(): Promise<string | null> {
const host = await this.host();
const classes = (await host.getAttribute('class')) ?? '';
return classes.match(/foo-button--(primary|secondary|ghost|danger)/)?.[1] ?? null;
}
// Click the button. No-op-safe wrapper over the host element.
async click(): Promise<void> {
return (await this.host()).click();
}
}
5 · RxJS with fake timers
Time is the hard part of testing RxJS, and the cheap way out — a real setTimeout and a prayer — gives you slow, flaky tests. These don't wait. vi.useFakeTimers() intercepts the setTimeout/setInterval that RxJS schedulers run on, and the spec advances virtual time with advanceTimersByTimeAsync to prove the behaviour exactly: a typeahead cancels a stale in-flight search, polling stops on unsubscribe, backoff retries on a real schedule. Because the project is zoneless, this is plain Vitest fake timers — no fakeAsync/tick from zone.js.
View the spec (RxJS & fake timers)patterns.spec.ts
import {
Subject,
defer,
firstValueFrom,
lastValueFrom,
map,
of,
throwError,
timer,
} from 'rxjs';
import { poll, runWithStrategy, retryWithBackoff, typeahead, type SimTask } from './patterns';
/*
* Zoneless project: virtual time comes from vitest's fake timers, which intercept the
* setTimeout/setInterval that RxJS schedulers (timer, debounceTime, delay) run on. No
* zone.js / fakeAsync involved.
*/
describe('rxjs patterns', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('typeahead', () => {
it('switches to the latest term, cancelling the stale in-flight search', async () => {
const term$ = new Subject<string>();
const out: string[] = [];
// "slow" resolves in 300ms, anything else in 10ms.
const search = (t: string) => timer(t === 'slow' ? 300 : 10).pipe(map(() => `R:${t}`));
typeahead(term$, search, 100).subscribe((v) => out.push(v));
term$.next('slow');
await vi.advanceTimersByTimeAsync(100); // debounce elapses → search('slow') starts (300ms)
term$.next('fast');
await vi.advanceTimersByTimeAsync(100); // debounce elapses → switchMap cancels 'slow', starts 'fast'
await vi.advanceTimersByTimeAsync(300); // let everything settle
expect(out).toEqual(['R:fast']); // the stale 'slow' result never arrives
});
it('dedupes a run of identical terms', async () => {
const term$ = new Subject<string>();
const out: string[] = [];
const search = (t: string) => of(`R:${t}`);
typeahead(term$, search, 50).subscribe((v) => out.push(v));
term$.next('ng');
await vi.advanceTimersByTimeAsync(50);
term$.next('ng'); // identical → distinctUntilChanged drops it
await vi.advanceTimersByTimeAsync(50);
expect(out).toEqual(['R:ng']);
});
});
describe('poll', () => {
it('fetches immediately and then on every interval, and stops on unsubscribe', async () => {
const seen: number[] = [];
let n = 0;
const sub = poll(50, () => of(++n)).subscribe((v) => seen.push(v));
await vi.advanceTimersByTimeAsync(0); // flush the leading 0ms tick
expect(seen).toEqual([1]);
await vi.advanceTimersByTimeAsync(50);
expect(seen).toEqual([1, 2]);
await vi.advanceTimersByTimeAsync(50);
expect(seen).toEqual([1, 2, 3]);
sub.unsubscribe();
await vi.advanceTimersByTimeAsync(200);
expect(seen).toEqual([1, 2, 3]); // no further ticks after unsubscribe
});
});
describe('runWithStrategy', () => {
const tasks: SimTask[] = [
{ id: 1, delayMs: 100 },
{ id: 2, delayMs: 50 },
];
async function collect(strategy: 'merge' | 'concat' | 'exhaust'): Promise<number[]> {
const promise = lastValueFrom(runWithStrategy(tasks, strategy));
await vi.advanceTimersByTimeAsync(500);
return promise;
}
it('merge — results arrive fastest-first regardless of start order', async () => {
expect(await collect('merge')).toEqual([2, 1]);
});
it('concat — results preserve task order however long each takes', async () => {
expect(await collect('concat')).toEqual([1, 2]);
});
it('exhaust — drops tasks emitted while the first is still running', async () => {
expect(await collect('exhaust')).toEqual([1]);
});
});
describe('retryWithBackoff', () => {
it('retries with exponential backoff and eventually succeeds', async () => {
let attempts = 0;
const source = defer(() => {
attempts += 1;
return attempts < 3 ? throwError(() => new Error('flaky')) : of('ok');
});
const promise = firstValueFrom(source.pipe(retryWithBackoff(3, 100)));
await vi.advanceTimersByTimeAsync(100 + 200 + 50); // 1st + 2nd backoff windows
await expect(promise).resolves.toBe('ok');
expect(attempts).toBe(3); // initial try + two retries
});
it('rethrows after the retry budget is exhausted', async () => {
let attempts = 0;
const source = defer(() => {
attempts += 1;
return throwError(() => new Error('boom'));
});
/*
* Capture the rejection up front so advancing the timers can't surface it as an
* unhandled rejection before the assertion attaches.
*/
const settled = firstValueFrom(source.pipe(retryWithBackoff(2, 100))).then(
() => null,
(err: Error) => err,
);
await vi.advanceTimersByTimeAsync(1000);
const error = await settled;
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('boom');
expect(attempts).toBe(3); // initial try + two retries, then gives up
});
});
});
6 · Spies, mocks & async assertions
The copy button reaches for the clipboard, a browser API you neither want nor have in jsdom. The source-viewer spec stands in for it: vi.fn() makes a spy you can assert was called with the right text, vi.stubGlobal('navigator', …) swaps the whole API for the duration of a test, and the resolved/rejected variants let one suite prove both the happy path and the denied-permission path. The assertions are async — they await the microtask and the fake-timer revert — and an afterEach calls vi.unstubAllGlobals() and vi.restoreAllMocks() so no stub leaks into the next test. A spy that outlives its test is a future false failure.
View the spec (spies, mocks & async)source-viewer.spec.ts
import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { SourceViewer, type SourceFile } from './source-viewer';
@Component({
imports: [SourceViewer],
template: `<foo-source
[snippet]="snippet"
[snippets]="snippets"
[note]="note"
[open]="open"
[label]="label"
/>`,
})
class Host {
snippet: SourceFile | null = { file: 'x.ts', lang: 'ts', code: 'const x = 1;' };
snippets: readonly SourceFile[] | null = null;
note = '';
open = false;
label = 'View source';
}
function render(setup?: (h: Host) => void) {
const fixture = TestBed.createComponent(Host);
setup?.(fixture.componentInstance);
fixture.detectChanges();
const el = fixture.debugElement.query(By.directive(SourceViewer)).nativeElement as HTMLElement;
return { fixture, el };
}
describe('SourceViewer', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('renders pre-highlighted html when provided', () => {
const { el } = render(
(h) =>
(h.snippet = {
file: 'a.ts',
lang: 'ts',
code: 'const answer',
html: '<span class="hljs-keyword">const</span> answer',
}),
);
const code = el.querySelector('.foo-source__pre code') as HTMLElement;
expect(code.querySelector('.hljs-keyword')?.textContent).toBe('const');
expect(code.textContent).toContain('answer');
});
it('falls back to escaped code when there is no html (renders as text, not markup)', () => {
const { el } = render((h) => (h.snippet = { file: 'a.ts', lang: 'ts', code: 'a < b && c > d' }));
const code = el.querySelector('.foo-source__pre code') as HTMLElement;
expect(code.children.length).toBe(0);
expect(code.textContent).toContain('a < b && c > d');
});
it('shows the file label and language', () => {
const { el } = render(
(h) => (h.snippet = { file: 'accounts.store.ts', lang: 'ts', code: 'x' }),
);
expect(el.querySelector('.foo-source__file')?.textContent).toContain('accounts.store.ts');
expect(el.querySelector('.foo-source__lang')?.textContent?.trim()).toBe('ts');
});
it('opens when [open] is true and labels the scrollable region', () => {
const { el } = render((h) => {
h.open = true;
h.snippet = { file: 'x.ts', lang: 'ts', code: 'x' };
});
expect((el.querySelector('details') as HTMLDetailsElement).open).toBe(true);
expect(el.querySelector('.foo-source__pre')?.getAttribute('aria-label')).toBe('Source of x.ts');
});
it('groups multiple files as tabs with a note, and switches the active file', () => {
const files: SourceFile[] = [
{ file: 'actions.ts', lang: 'ts', code: 'A' },
{ file: 'effects.ts', lang: 'ts', code: 'B' },
];
const { fixture, el } = render((h) => {
h.snippet = null;
h.snippets = files;
h.note = 'one feature, three files';
});
expect(el.querySelector('.foo-source__note')?.textContent).toContain('one feature');
const tabs = el.querySelectorAll('.foo-source__tab');
expect(tabs.length).toBe(2);
expect(el.querySelector('.foo-source__pre code')?.textContent).toContain('A');
(tabs[1] as HTMLButtonElement).click();
fixture.detectChanges();
expect(el.querySelector('.foo-source__pre code')?.textContent).toContain('B');
expect(tabs[1].getAttribute('aria-pressed')).toBe('true');
});
it('renders empty when given no snippet', () => {
const { el } = render((h) => {
h.snippet = null;
h.snippets = null;
});
expect(el.querySelector('.foo-source__pre code')?.textContent).toBe('');
expect(el.querySelector('.foo-source__file')).toBeNull();
});
it('copies the active file, then flips the label back', async () => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const { fixture, el } = render((h) => (h.snippet = { file: 'a.ts', lang: 'ts', code: 'copy me' }));
const btn = el.querySelector('.foo-source__copy') as HTMLButtonElement;
btn.click();
await vi.advanceTimersByTimeAsync(0);
fixture.detectChanges();
expect(writeText).toHaveBeenCalledWith('copy me');
expect(btn.textContent?.trim()).toBe('Copied');
await vi.advanceTimersByTimeAsync(1500);
fixture.detectChanges();
expect(btn.textContent?.trim()).toBe('Copy');
vi.useRealTimers();
});
it('leaves the label unchanged when the copy is denied', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'));
vi.stubGlobal('navigator', { clipboard: { writeText } });
const { fixture, el } = render();
const btn = el.querySelector('.foo-source__copy') as HTMLButtonElement;
btn.click();
await Promise.resolve();
await Promise.resolve();
fixture.detectChanges();
expect(btn.textContent?.trim()).toBe('Copy');
});
it('no-ops when the clipboard API is unavailable', () => {
vi.stubGlobal('navigator', {});
const { el } = render();
const btn = el.querySelector('.foo-source__copy') as HTMLButtonElement;
expect(() => btn.click()).not.toThrow();
});
});
7 · The Vitest setup, in three files
Every spec above runs on Vitest through the AnalogJS Angular plugin, zoneless. The three files below are the whole story: the root config composes per-project configs so each library and app runs as its own Vitest project; a representative project config turns on globals, the jsdom environment, the v8 coverage provider, and watch: false (CI runs once and exits — you flip it to watch locally); and the test-setup.ts wires the AnalogJS TestBed and stubs the browser APIs jsdom lacks, so a @defer trigger renders the same in a test as in a real browser.
View the Vitest config
Three files, one runner. The root config fans out into per-project configs; a project config sets the environment, coverage provider, and watch-vs-CI behaviour; test-setup.ts boots the zoneless TestBed and stubs the APIs jsdom doesn't ship.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
projects: [
'**/vite.config.{mjs,js,ts,mts}',
'**/vitest.config.{mjs,js,ts,mts}',
],
},
});
8 · Coverage, stated honestly
A coverage percentage measures which lines ran, not whether anything was checked — a suite can execute every line and assert nothing. So the number is a floor, not a goal. Here is the real shape of it: the design system is held to the bar because it is the most reused code, the shared libraries sit high because they carry logic worth pinning, and the demo pages are lighter on purpose where they are only markup.
| Area | Coverage | Why |
|---|---|---|
| Design system (@foo/ui) | ~100% / high | The bar. Components are the most reused code here, so they hold roughly 100% line and branch coverage — every variant, every keyboard path, every guarded browser-only branch. |
| Shared libraries (state, RxJS, utils) | ~100% / high | Stores, patterns, and helpers sit high. They carry logic worth asserting — derived state, concurrency, formatting — and almost nothing that only a framework can reach. |
| Demo pages | lighter, on purpose | Presentational. They wire library pieces into a layout, so they are covered where there is behaviour to pin and left lighter where they are just markup — chasing 100% here would test Angular, not this code. |