Skip to content

Testing

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

Three test engines live in this repo and only one of them gates a pull request. Vitest runs on every PR; Playwright runs nightly against a real stack and is split into a project that can turn the build red and one that cannot; Cypress is present, largely unwired, and honest about it. The hub's Developer Setup has the cross-repo view of which suite needs what. This page is what you need to write and debug tests here.

Unit tests: Vitest on jsdom

npm test is ng test, which runs the @angular/build:unit-test builder over src/**/*.spec.ts in a jsdom environment. Specs are co-located with the code they cover. The conventions are unremarkable — services and pipes get unit tests, components get them when they carry real logic, trivial presentational components do not, and HTTP is mocked with provideHttpClientTesting.

The one thing that will cost you an hour is that there are two Vitest configs and the runner only reads one of them.

File Read by Not read by
vitest-base.config.ts ng test (the Angular unit-test builder)
vitest.config.ts a direct vitest invocation ng test
src/test.setup.ts vitest.config.ts's setupFiles ng test

The project standard is ng test, so vitest-base.config.ts is the canonical config. Three consequences:

  • setupFiles in vitest-base.config.ts would not take effect either. The Angular builder ignores user setup files unless they are listed under architect.test.options.setupFiles in angular.json, and it injects its own init-testbed.js (which calls initTestEnvironment) and vitest-mock-patch.js before any spec runs.
  • Global module mocks must go through resolve.alias, not vi.mock. vi.mock in a setup file collides with the injected mock patch, which breaks Vitest's stack-trace-based importer detection. The working pattern is in vitest-base.config.ts: @microsoft/signalr is aliased to src/testing/signalr.mock.ts so nothing in a unit test attempts a real negotiate. Per-spec vi.mock still works and overrides the alias for that file.
  • vitest.config.ts carries a Vite plugin that inlines templateUrl and blanks styleUrl at transform time, because jsdom cannot fetch local file URLs for Angular's JIT compiler. Under ng test the builder handles component resources itself, so that plugin is part of the fallback path only.

Worth flagging rather than trusting: src/test.setup.ts contains a window.matchMedia stub and a getTestBed().initTestEnvironment(..., { teardown: { destroyAfterEach: false } }) workaround for an NG0205: Injector has already been destroyed teardown race. Both files' own comments say the builder ignores that setup file, so neither the stub nor the teardown flag applies to a standard ng test run. If you hit that NG0205 race, the fix is not "it's already handled".

src/testing/ holds the doubles that are meant to be shared: the SignalR mock above, and signal-input-harness.ts for driving signal-based component inputs.

Playwright: assumes the stack is already up

e2e/playwright.config.ts has no webServer block. Nothing is started for you. Bring up the UI at http://localhost:4200 and the API at http://localhost:5000 yourself — the containerised dev loop, or compose-e2e.yml from forge-deploy, which is what CI uses — then npm run e2e.

Sign-in does not go through the login screen. e2e/helpers/auth.helper.ts posts to the API, then writes the token and user into localStorage under the keys AuthService reads on init. It is fast and it is also the reason a change to how the SPA persists a session breaks every spec at once. Seed credentials come from SEED_USER_PASSWORD; base URLs are overridable through environment variables. Chromium only, one worker, no retries — set PLAYWRIGHT_CHANNEL=chrome on a host where Playwright ships no bundled browser build.

e2e/lib/ is the part worth reading before writing a spec: small libraries for data tables, dialogs, forms, navigation, detail panels, snackbars and entity links, built around a data-testid convention that the application templates carry throughout. Reach for page.locator('[data-testid=…]') through those helpers rather than CSS or text selectors.

Two projects, only one gates

Project Contains Effect on the build
functional everything else — behaviour assertions The gate. Red here is red.
docgen generate-ui-*, *-audit, *screenshot*, *-verify, and a handful of named specs Non-gating. Runs as its own continue-on-error job.

The split is not a quality judgement. The docgen specs exist to produce artifacts — documentation, screenshot sets, visual audits — and full-page captures of complex screens are memory-hungry enough to flake on a small runner while passing on a well-resourced box. Letting them redden the nightly meant the nightly was permanently red and therefore ignored.

How the nightly runs it

.github/workflows/nightly.yml shards the functional project across parallel runners, each with its own freshly seeded stack, and merges the per-shard blob reports into one HTML report at the end. Playwright shards at file granularity, so a describe.serial block stays intact on one shard and within-file fixtures are safe. The hazard is the one to remember when writing specs: a spec that depends on data created by a different spec file will fail unpredictably under sharding. Every spec must create what it needs.

One inconsistency in that setup: playwright.config.ts sets a globalTimeout and its comment explains the value as sitting below the nightly job's ceiling so the suite stops itself gracefully with a readable report instead of being killed mid-test. The sharded job's timeout-minutes is now lower than that globalTimeout, so for the functional shards the graceful inner cap can no longer fire first. The comment describes an arrangement that the workflow has since moved away from.

Contract drift: the most valuable spec in the repo

e2e/tests/smoke/contract-drift.spec.ts runs no browser. It parses forge-api's controller sources from a sibling ../forge-api checkout, extracting every [Route] and method-level HTTP attribute (correctly pairing each method with the nearest preceding class route, because one .cs file can hold several controllers), then extracts every URL the SPA's services call and compares the two sets.

  • A frontend URL with no matching backend route fails. That is a call that will 404 in production.
  • A backend route with no frontend caller warns. That is usually fine.

This is the answer to the fact that there is no generated client — see The Surfaces § The seam with forge-api, and forge-api for the routing conventions the parser assumes.

And it carries the lesson worth taking from this whole page. Both this spec and api-smoke.spec.ts read that sibling path, actions/checkout will not place a repo outside the workspace, and so for a long stretch after the nightly workflow was written both specs threw "Controllers directory not found" on every run — the two most valuable gates in the suite were dead in CI while appearing to run. The fix was to check forge-api out inside the workspace and symlink it to the expected sibling path. A check that silently degrades when an input is missing is worse than one that fails: lint:i18n's cross-repo scan has the same conditional shape and the same exposure (i18n).

Run the three smoke specs together with npm run test:smoke (test:api-smoke, test:contract-drift, test:critical-flows).

The other Playwright configurations

Not gates — tools, each with its own config:

Command What it is
npm run scenario An interactive, headed scenario tree (01-foundation then a branch), run sequentially with long timeouts because the steps pause for a human. Also the way to populate a stack with realistic data.
npm run simulate The week-simulation framework under e2e/simulation/.
npm run screenshots Screenshot capture with its own config.
npm run stress A tsx orchestrator under e2e/stress/, plus screenshot auditing and review tooling.
npm run e2e:gen-docs / gen-landmarks The doc-generation specs, invoked directly by grep.
npm run training:verify Screenshots each training module's page and has a vision model compare it against the module's written content, producing a discrepancy report (--fix rewrites the seed data and re-verifies).

Cypress: present, mostly unwired

cypress/e2e/ holds a per-feature spec suite, and cypress/support/commands.ts registers a login command — which takes an email, not a role, whatever CLAUDE.md says. Nothing in CI runs any of it. The only wired script is npm run test:a11y, which runs the axe accessibility spec; npm run cy:run runs the whole suite if you want it. Treat the Cypress suite as a historical artifact that still executes rather than as coverage you can rely on, and add new end-to-end coverage in Playwright.

What actually gates

Suite When Blocks a merge
npm run lint, lint:i18n, lint:standards, build, test Every PR and push Yes
CodeQL Every PR, push to main, weekly Findings surface in the Security tab
Playwright functional Nightly, on demand, on release/* No — but a red nightly is a real failure
Playwright docgen Nightly, own job No, by design
Cypress Manually No