Angular Testing Guide
How to test MemberJunction Angular components. MJ runs Vitest everywhere (Jest is deprecated). For Angular there are now two complementary test styles:
- Class-level tests — instantiate the component class with
new, exercise pure logic (getters, navigation methods,@Outputemission), assert on state. Fast, no DOM. These already exist across the Angular packages and stay. - DOM-level tests — render the component into a real (headless, jsdom) DOM with Angular
TestBed+ComponentFixture, set@Inputs, dispatch DOM events, and assert on the rendered output and@Outputemissions. This is the half of a component’s contract that lives in the template (@ifgating, bindings,(click)→ handler wiring, conditional classes, a11y attributes) — class-level tests can’t see it.
There is also a third style this guide does not cover but tells you when to reach for: live tests (Playwright / e2e) for WebRTC and real-media paths.
Background / rollout plan:
plans/testing/angular-dom-testing-rollout.md.
1. Decision tree: which test do I write?
Section titled “1. Decision tree: which test do I write?”| You want to verify… | Use |
|---|---|
A pure getter / method / computed value, @Output payload shape | Class-level (new, assert) |
@if shows/hides the right element; binding renders the right attribute/text; (click) calls the handler and emits; conditional CSS class; aria-* | DOM-level (TestBed + ComponentFixture) |
Real camera/mic/getUserMedia, AudioContext, WebRTC, track.attach(), <video>/<audio> playback, audio metering, rAF-driven media | Live test (Playwright / e2e). Never mock these into a fake “pass.” |
A media-touching component gets both a DOM test (for its presentational, media-free surface, with the media client mocked at the seam) and a live test (for the actual media behavior). Document the split in the spec.
2. The harness (how it works)
Section titled “2. The harness (how it works)”DOM testing is a repo-level infrastructure addition; a package then opts in.
- Renderer:
@analogjs/vite-plugin-angularcompiles Angular templates/decorators for Vite/Vitest (AOT,jit: false). - Environment:
jsdom. - Change detection: zoneless — there is no
zone.jsanywhere in this path. The shared setup appliesprovideZonelessChangeDetection()to every spec via a globalbeforeEach, and you drive CD explicitly withfixture.detectChanges()/await fixture.whenStable(). - TestBed teardown: relied on implicitly — Angular’s default
destroyAfterEach: true(active wheneverplatformBrowserTestingis initialized, as our setup does) tears down each fixture and resets the testing module between specs, so the globalbeforeEachre-configures a clean module every test. We do not callTestBed.resetTestingModule()ourselves. If you ever see cross-test state leakage (a spec passing alone but failing in-suite), suspect module-level singletons or a provider that escaped the per-test module — not the teardown itself.
Two root files implement it (peers of the node preset vitest.shared.ts):
vitest.dom.shared.ts— the preset:angular()+tsconfigPaths()plugins,environment: 'jsdom', the setup file, and auto-detection of a per-packagetsconfig.spec.json.vitest.dom.setup.ts— initializes the Angular testing platform once (platformBrowserTesting), wires zoneless CD, imports@angular/compiler(JIT fallback for partial-compiled libraries), and installs the standard jsdom stubs (matchMedia,ResizeObserver,IntersectionObserver).
Required repo-level devDependencies (already in the root package.json):
@analogjs/vite-plugin-angular, @angular/build, jsdom. Run npm install at the repo root if
they aren’t installed.
3. Adding DOM tests to a package
Section titled “3. Adding DOM tests to a package”3a. Scaffold
Section titled “3a. Scaffold”node scripts/scaffold-tests.mjs packages/Angular/Generic/my-widget --domThis emits a DOM-preset vitest.config.ts, a tsconfig.spec.json, and a passing starter
ComponentFixture spec. Then replace the starter with specs for your real components.
3b. The two config shapes
Section titled “3b. The two config shapes”Single preset — use when the package has no class-level spec that
vi.mock('@angular/core'). The package’s existing class-level specs run fine under jsdom too.
Reference: ng-ui-components/vitest.config.ts.
import { defineProject, mergeConfig } from 'vitest/config';import domSharedConfig from '../../../../vitest.dom.shared';
export default mergeConfig( domSharedConfig, defineProject({ test: { name: '@memberjunction/ng-my-widget' } }),);Dual preset (vitest projects) — use when the package has a class-level spec that
vi.mock('@angular/core'). That mock breaks under the Angular compile path (the compiled
component calls ɵɵdefineComponent, which the mock doesn’t provide). Rather than rewrite a good
test, run the node specs on the node preset and the DOM specs on jsdom, with disjoint file
sets. Reference: ng-pagination/vitest.config.ts.
import { defineConfig, mergeConfig } from 'vitest/config';import nodeSharedConfig from '../../../../vitest.shared';import domSharedConfig from '../../../../vitest.dom.shared';
export default defineConfig({ test: { projects: [ mergeConfig(nodeSharedConfig, defineConfig({ test: { name: 'my-widget (node)', environment: 'node', exclude: ['**/*.dom.test.ts'] }, })), mergeConfig(domSharedConfig, defineConfig({ test: { name: 'my-widget (dom)', include: ['src/**/*.dom.test.ts'], exclude: ['**/__tests__/**'] }, })), ], },});
mergeConfigconcatenates the sharedinclude/excludearrays — so separate the two projects by excluding, not by trying to overrideinclude.
3c. tsconfig.spec.json
Section titled “3c. tsconfig.spec.json”The Angular compiler must see spec files in its TypeScript program to type-check templates, but
the build tsconfig.json usually excludes *.test.ts. So each DOM package adds a
tsconfig.spec.json that re-includes them; the preset auto-detects it:
{ "extends": "./tsconfig.json", "compilerOptions": { "types": ["vitest/globals", "node"] }, "include": ["src/**/*.ts"], "exclude": ["node_modules", "dist"]}⚠️ Enumerated-include packages (e.g.
conversations). Some packages can’t use a broadsrc/**/*.tsinclude — a heavy component (WebRTC/media/streaming) elsewhere in the tree breaks the AOT TS program — so theirtsconfig.spec.jsonlists files explicitly. In those packages you MUST add both the component.tsand its.dom.test.tsto theincludelist. If you don’t, the Angular AOT compiler processes the component without decorator metadata, so its constructor param types erase and DI fails at render withNG0202(only for components that inject a service — a dep-free component silently “passes,” masking the misconfig). The tell is a vitest warning: “…contains Angular decorators but is not in the TypeScript program.”
Also add the test:types gate script alongside it — this is what actually type-checks your
specs. Vitest/esbuild is transpile-only: it strips types and silently erases broken import type
statements, so a spec can be vitest-green while carrying real type errors (Phase 3 shipped a batch
this way; CI’s ngc build caught them the hard way). The gate closes that hole:
"scripts": { "test:types": "tsc --noEmit -p tsconfig.spec.json"}CI runs it automatically via the test:types turbo task (before the vitest run, on both the
affected and full-suite paths). Run it locally with npm run test:types — do this before pushing;
vitest run passing does NOT mean your spec type-checks.
3d. Naming & location
Section titled “3d. Naming & location”Name DOM specs *.component.dom.test.ts and put them next to the component
(src/lib/<feature>/x.component.dom.test.ts). The .dom.test.ts suffix is what the dual-preset
split keys off; it also reads as “this renders.”
Never place a *.dom.test.ts inside a __tests__/ directory. In a dual-preset package it
matches NEITHER vitest project (node excludes *.dom.test.ts; dom excludes __tests__/), so it
silently never runs — and passWithNoTests: true hides the silence. CI enforces this via
scripts/check-dom-spec-placement.mjs (a fast pre-build step in the Unit Tests workflow).
4. The fixture pattern
Section titled “4. The fixture pattern”import { describe, it, expect, vi } from 'vitest';import { ComponentFixture, TestBed } from '@angular/core/testing';import { MyWidgetComponent } from './my-widget.component';
describe('MyWidgetComponent (DOM)', () => { it('hides the action when disabled', () => { const fixture = TestBed.createComponent(MyWidgetComponent); fixture.componentRef.setInput('Disabled', true); // see §5 fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.action')).toBeNull(); });
it('emits Save when the button is clicked', () => { const fixture = TestBed.createComponent(MyWidgetComponent); const spy = vi.fn(); fixture.componentInstance.Save.subscribe(spy); fixture.detectChanges(); (fixture.nativeElement.querySelector('button.save') as HTMLButtonElement).click(); expect(spy).toHaveBeenCalled(); });});- Prefer the shared
@memberjunction/ng-test-utilshelpers over rawTestBedboilerplate:renderComponentFixture(Cmp, { inputs, providers, setup })applies@Inputs in the NG0100-safe order (§5) and accepts stubprovidersfor service-injected components; query the result withquery(f, sel)/queryAll(f, sel)/text(f, sel)/typeInto(f, sel, value). Compound / module-declared components userenderTemplate(...). Thegen-dom-stub.mjsgenerator scaffolds specs using exactly these. The rawTestBed+nativeElement.querySelectorform above still works and is fine for one-offs. - Standalone components need no
configureTestingModule—TestBed.createComponent(Cmp)(orrenderComponentFixture) just works. For module-declared components, userenderTemplate(or addTestBed.configureTestingModule({ imports: [...] })) before the firstcreateComponent— the global zoneless provider merges in automatically.
5. 🚨 Zoneless change-detection gotcha (read this)
Section titled “5. 🚨 Zoneless change-detection gotcha (read this)”In zoneless mode a programmatic state change does not mark the view dirty. If you mutate
state and then call fixture.detectChanges(), Angular’s dev-mode check-no-changes pass can throw
NG0100 ExpressionChangedAfterItHasBeenCheckedError, because the main CD pass rendered the old
value and the verification pass sees the new one.
Three reliable ways to avoid it:
- Set
@Inputs viafixture.componentRef.setInput('Name', value)— this marks the component dirty the zoneless-correct way. Prefer this over assigningfixture.componentInstance.Name = …. - Set programmatic (non-input) state BEFORE the first
detectChanges(), then render once. - Drive changes through real DOM events (
button.click()) — event handling marks the view dirty, so the subsequentdetectChanges()is clean.
// ✅ input via setInputfixture.componentRef.setInput('OffLabel', 'Off');fixture.detectChanges();
// ✅ internal state set before first CDconst fixture = TestBed.createComponent(MySwitch);fixture.componentInstance.setDisabledState(true);fixture.detectChanges();
// ❌ mutate-after-render — risks NG0100fixture.detectChanges();fixture.componentInstance.someState = true; // view not marked dirtyfixture.detectChanges(); // check-no-changes may throw6. Mocking recipes
Section titled “6. Mocking recipes”Data-bound components (provider / RunView)
Section titled “Data-bound components (provider / RunView)”Use createFakeProvider from @memberjunction/ng-test-utils — it builds a fake IMetadataProvider
whose RunView/RunViews return your canned rows (no backend, no vi.mock of @memberjunction/core).
How you get it into the component depends on how the component reads data:
A — Injectable [Provider] (preferred). If the component reads through this.ProviderToUse
(i.e. it extends BaseAngularComponent and calls RunView.FromMetadataProvider(this.ProviderToUse)),
pass the fake straight into the [Provider] input. Clean, no globals, no cleanup:
const f = renderComponentFixture(MyDataComponent, { inputs: { Provider: createFakeProvider({ runViewResults: ROWS }) },});B — Global provider (useFakeGlobalProvider). If the component loads through a bare
new RunView() (which reads the process-global RunView.Provider) and/or Metadata.Provider
(EntityByName / GetEntityObject) — and you do not want to refactor it — install a fake global.
useFakeGlobalProvider() registers beforeEach/afterEach to save and restore both globals
(no leaks) and returns an installer. Call it once at the top of the describe:
import { renderComponentFixture, useFakeGlobalProvider } from '@memberjunction/ng-test-utils';
describe('MyDataComponent (DOM)', () => { const installProvider = useFakeGlobalProvider(); // owns the save/restore + cast
it('renders the loaded rows', async () => { installProvider({ runViewResults: ROWS }); const f = renderComponentFixture(MyDataComponent); await new Promise((r) => setTimeout(r, 0)); // let the async ngOnInit load settle f.detectChanges(); expect(queryAll(f, '.row').length).toBe(ROWS.length); });});runViewResults may be a function of the params ((p) => p.EntityName === 'X' ? ROWS_X : ROWS_Y)
to serve a multi-entity RunViews call. Prefer A when the component supports it — B is a global
swap (a standard, save/restore-scoped pattern, but a global swap nonetheless) and doesn’t address the
multi-provider-correctness reason a component shouldn’t reach the global in the first place.
Async loads need a flush. Components that load in
ngOnInit/ngOnChangesresolve theirRunViewpromise on a microtask after the firstdetectChanges(). Flush it (await new Promise(r => setTimeout(r, 0))) and thendetectChanges()again before asserting, or you’ll see the loading/empty state.
Boundary — plain rows, not live entities.
createFakeProviderreturns plain objects, notBaseEntityinstances. It fits components that read result rows as data (row.Name,row.ID, spreads). It does not fit a component that callsBaseEntitymethods on the loaded results —.Get()/.Set()/.Fields/.EntityInfo(common withResultType: 'entity_object'plus amakeRow/field-validation step). Faking those would mean mocking the whole entity surface; defer that component’s data path instead (test its chrome), or cover it with a real provider in an integration/live test.
Other recipes
Section titled “Other recipes”- Container components that internally
newan engine/controller: refactor the dependency to be injectable (e.g.inject()a factory token), so the test injects a fake that drives the DOM. Leaf components that are already pure@Input/@Outputneed none of this. - Media APIs: stub the media client at the seam for the presentational assertions, and defer the real behavior to a live test (see §7). Do not assert media behavior via mocks.
7. The hard rule: media is live-tested, never faked
Section titled “7. The hard rule: media is live-tested, never faked”jsdom does not implement WebRTC, navigator.mediaDevices.getUserMedia, AudioContext,
MediaStreamTrack, real <video>/<audio> playback, or requestAnimationFrame-driven media. The
setup file deliberately does not stub these.
For a component that touches them:
- DOM-unit-test only its media-free surface — gating, labels, button enabled/disabled states,
@Outputemission on click — with the media client mocked at the seam. - Live-test the actual media behavior (capture,
track.attach(), metering) via the Playwright CLI workflow / e2e suite.
Never mock a MediaStream into a green DOM test and call the media path “covered.”
8. JIT / partial-compiled note
Section titled “8. JIT / partial-compiled note”MJ libraries publish partial-compiled (Ivy) output. The Analog plugin AOT-compiles sources for the
test bundle, and the setup file also import '@angular/compiler' so JIT is available as a fallback
(the long-standing repo convention in the class-level specs). You normally don’t have to think
about it.
9. Worked references (in this repo)
Section titled “9. Worked references (in this repo)”- Leaf components, single preset —
ng-ui-components:switch(CVA + click toggle),progress-bar(@if/@elsebranch),stat-badge(gating + host-class variants),view-toggle(@for+@Output). - Dual preset (node + dom), external template,
@Output—ng-pagination:pagination(self-hiding gating, disabled-button no-op,PageChangepayload) alongside an unchanged@angular/core-mocking class-level spec.
10. Running
Section titled “10. Running”# one packagecd packages/Angular/Generic/ui-components && npm run test
# watchnpm run test:watch
# changed packages only (from repo root)npx turbo run test --filter=...[HEAD~1]Build @memberjunction/ng-test-utils first for a bare package run. It resolves via
main: dist/public-api.js (no source path-mapping), so cd <pkg> && npm run test fails with an
unresolved import until its dist/ exists. npx turbo run test handles this automatically (the
test task dependsOn: ["build"], and ^build builds ng-test-utils first); a manual one-off run
does not. Build it once with cd packages/Angular/Generic/test-utils && npm run build (or
npx turbo run build --filter=@memberjunction/ng-test-utils). The same applies to any other
unbuilt MJ package a component-under-test imports — build its dependency graph before the DOM run.
11. Visibility: what’s tested? (dom-test-report.mjs)
Section titled “11. Visibility: what’s tested? (dom-test-report.mjs)”scripts/dom-test-report.mjs is a read-only report that scores how well each component is
DOM-tested — not just whether a spec file exists. It reuses the same component parser as the stub
generator (scripts/lib/component-surface.mjs), so it scores against exactly the surface a generated
stub would have asked you to cover.
node scripts/dom-test-report.mjs # default: packages/Angular/Genericnode scripts/dom-test-report.mjs packages/Angular/Generic/conversations # one packagenode scripts/dom-test-report.mjs packages/Angular/Generic/livekit-room --all # every component, not just gapsnode scripts/dom-test-report.mjs packages/Angular --top=100 # wider scope, more rowsnode scripts/dom-test-report.mjs packages/Angular/Generic --json # machine-readablePer component it reports:
- status —
solid/partial/stub/none:- none — no
*.component.dom.test.ts. - stub — a spec exists but is an unfilled generated starter (still has
// TODO:lines, or its only test is thetoBeTruthy()smoke check). - solid — ≥ 3
it()tests and (no name-checkable behaviors, or ≥ 60% of them referenced). - partial — real tests, but below that bar — a “look closer,” not a verdict.
- none — no
- COVERS
a/b— behavior coverage: of the component’s named contract bits —@Outputnames,[class.X]names,[attr.X]names — how many the spec references. (Gating@ifconditions and{{ }}interpolations are not counted: a spec asserts the rendered element/text, not the source expression, so they can’t be matched by name. SoCOVERSis a floor, not a ceiling.) - USED — how many places render the component (selector occurrences across
packages/Angular) — the “how much it matters” signal, so heavily-used gaps rank to the top.
Gaps are ranked by severity × usage. Skipped/deferred components still count as gaps, annotated with
the reason (e.g. media/WebRTC → e2e) — an intentional skip is surfaced, not hidden.
This is now also a CI gate. .github/workflows/test.yml runs it as a coverage ratchet on every PR:
node scripts/dom-test-report.mjs packages/Angular/Generic --max-none=134 # Generic ratchet (matches .github/workflows/test.yml)node scripts/dom-test-report.mjs packages/Angular/Bootstrap --max-none=0 # Bootstrap ratchet--max-none=N fails the build when more than N components have no spec and no deferral
annotation — an absolute cap that only ratchets down (lower the number as gaps close; never raise
it). Alongside it, scripts/classify-explorer-components.mjs --min 85 gates Explorer in-scope DOM
coverage at 85% (new Explorer components land as in-scope-uncovered and push the percentage down,
so the gate forces either a spec or a reviewed deferral — see
plans/testing/phase-3-explorer-deferral-register.md).