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"]}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.”
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 a
team-visibility backlog tool, not a CI gate (gating is a Phase 4 conversation).