Skip to content

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, @Output emission), 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 @Output emissions. This is the half of a component’s contract that lives in the template (@if gating, 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.


You want to verify…Use
A pure getter / method / computed value, @Output payload shapeClass-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 mediaLive 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.


DOM testing is a repo-level infrastructure addition; a package then opts in.

  • Renderer: @analogjs/vite-plugin-angular compiles Angular templates/decorators for Vite/Vitest (AOT, jit: false).
  • Environment: jsdom.
  • Change detection: zoneless — there is no zone.js anywhere in this path. The shared setup applies provideZonelessChangeDetection() to every spec via a global beforeEach, and you drive CD explicitly with fixture.detectChanges() / await fixture.whenStable().
  • TestBed teardown: relied on implicitly — Angular’s default destroyAfterEach: true (active whenever platformBrowserTesting is initialized, as our setup does) tears down each fixture and resets the testing module between specs, so the global beforeEach re-configures a clean module every test. We do not call TestBed.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-package tsconfig.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.


Terminal window
node scripts/scaffold-tests.mjs packages/Angular/Generic/my-widget --dom

This 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.

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__/**'] },
})),
],
},
});

mergeConfig concatenates the shared include/exclude arrays — so separate the two projects by excluding, not by trying to override include.

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 broad src/**/*.ts include — a heavy component (WebRTC/media/streaming) elsewhere in the tree breaks the AOT TS program — so their tsconfig.spec.json lists files explicitly. In those packages you MUST add both the component .ts and its .dom.test.ts to the include list. 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 with NG0202 (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.

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).


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-utils helpers over raw TestBed boilerplate: renderComponentFixture(Cmp, { inputs, providers, setup }) applies @Inputs in the NG0100-safe order (§5) and accepts stub providers for service-injected components; query the result with query(f, sel) / queryAll(f, sel) / text(f, sel) / typeInto(f, sel, value). Compound / module-declared components use renderTemplate(...). The gen-dom-stub.mjs generator scaffolds specs using exactly these. The raw TestBed + nativeElement.querySelector form above still works and is fine for one-offs.
  • Standalone components need no configureTestingModuleTestBed.createComponent(Cmp) (or renderComponentFixture) just works. For module-declared components, use renderTemplate (or add TestBed.configureTestingModule({ imports: [...] })) before the first createComponent — 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:

  1. Set @Inputs via fixture.componentRef.setInput('Name', value) — this marks the component dirty the zoneless-correct way. Prefer this over assigning fixture.componentInstance.Name = ….
  2. Set programmatic (non-input) state BEFORE the first detectChanges(), then render once.
  3. Drive changes through real DOM events (button.click()) — event handling marks the view dirty, so the subsequent detectChanges() is clean.
// ✅ input via setInput
fixture.componentRef.setInput('OffLabel', 'Off');
fixture.detectChanges();
// ✅ internal state set before first CD
const fixture = TestBed.createComponent(MySwitch);
fixture.componentInstance.setDisabledState(true);
fixture.detectChanges();
// ❌ mutate-after-render — risks NG0100
fixture.detectChanges();
fixture.componentInstance.someState = true; // view not marked dirty
fixture.detectChanges(); // check-no-changes may throw

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/ngOnChanges resolve their RunView promise on a microtask after the first detectChanges(). Flush it (await new Promise(r => setTimeout(r, 0))) and then detectChanges() again before asserting, or you’ll see the loading/empty state.

Boundary — plain rows, not live entities. createFakeProvider returns plain objects, not BaseEntity instances. It fits components that read result rows as data (row.Name, row.ID, spreads). It does not fit a component that calls BaseEntity methods on the loaded results — .Get() / .Set() / .Fields / .EntityInfo (common with ResultType: 'entity_object' plus a makeRow/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.

  • Container components that internally new an 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/@Output need 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, @Output emission 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.”


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.


  • Leaf components, single presetng-ui-components: switch (CVA + click toggle), progress-bar (@if/@else branch), stat-badge (gating + host-class variants), view-toggle (@for + @Output).
  • Dual preset (node + dom), external template, @Outputng-pagination: pagination (self-hiding gating, disabled-button no-op, PageChange payload) alongside an unchanged @angular/core-mocking class-level spec.

Terminal window
# one package
cd packages/Angular/Generic/ui-components && npm run test
# watch
npm 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.

Terminal window
node scripts/dom-test-report.mjs # default: packages/Angular/Generic
node scripts/dom-test-report.mjs packages/Angular/Generic/conversations # one package
node scripts/dom-test-report.mjs packages/Angular/Generic/livekit-room --all # every component, not just gaps
node scripts/dom-test-report.mjs packages/Angular --top=100 # wider scope, more rows
node scripts/dom-test-report.mjs packages/Angular/Generic --json # machine-readable

Per component it reports:

  • statussolid / 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 the toBeTruthy() 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.
  • COVERS a/b — behavior coverage: of the component’s named contract bits — @Output names, [class.X] names, [attr.X] names — how many the spec references. (Gating @if conditions and {{ }} interpolations are not counted: a spec asserts the rendered element/text, not the source expression, so they can’t be matched by name. So COVERS is 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:

Terminal window
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).