Testing
One command runs every test of the website, the Cloud Functions, the scripts and the docs. The security rules have a second command, because they need the Firebase emulators.
Run the tests
From the repository root:
npm run test
In a terminal, Vitest keeps watching and reruns the tests you touch; press q to stop. To run everything once and stop, for example in a script:
npx vitest run
The functions folder has no test script of its own: its specs run from the root with everything else.
Run one file or one test
npx vitest run functions/src/__tests__/adminCreateUser.spec.ts
A path that contains brackets or parentheses, such as a page in a (group) folder, needs quotes in the shell. To run only the tests whose name contains some words, add -t:
npx vitest run src/app/core/features -t "resolveFeatures"
What runs
vitest.config.ts sets up one run for everything:
| Setting | Value |
|---|---|
| Specs | src/**/*.spec.ts, functions/src/**/*.spec.ts, scripts/**/*.spec.ts, and an app's custom/**/*.spec.ts |
| Environment | jsdom, with Vitest globals |
| Setup for every spec | src/test/setup.ts (Angular) and functions/src/__tests__/setup.ts (Cloud Functions) |
| Once before the run | scripts/vitest-global-setup.ts writes the functions' generated feature files, which a fresh clone does not have |
The rules tests in tests/rules/ are left out of this run (see Rules tests).
Type check
Vitest strips the types from each file without checking them, so code with a type error passes its specs and only fails when npm run build or a deploy compiles it. The suite closes that gap: scripts/__tests__/typecheck.spec.ts fails on any type error in the app or the functions. On its own, in about 15 seconds:
npm run typecheck
| Checks | With | Like |
|---|---|---|
The app: src/main.ts, every *.page.ts in src/app/pages and src/custom/pages, and everything they import, templates included | ngc -p tsconfig.app.json --noEmit | npm run build |
The functions: all of functions/src, their specs and functions/src/custom included | tsc -p functions/tsconfig.json --noEmit | npm run build --prefix functions |
The frontend specs (src/**/*.spec.ts) are not type-checked. A file that nothing imports and that is not a page is not either; the build leaves it out too.
Write a component spec
Component specs use Angular's TestBed with Vitest (vi.fn(), vi.mock()). src/test/setup.ts already starts the Angular test environment and gives every spec Transloco with the real English strings, so a rendered template shows the text a person sees. Code that builds a message with a key, outside a template, can be checked against the same English with english(key, params) from src/test/english.ts, which also stands in for a component's translate function on a mock. To prove a screen has no English left, render it with pseudoTestingModule() from src/test/pseudo-language.ts, a pseudo language of the member keys, and expect textNotInPseudo(fixture.nativeElement) to be empty (see Member languages).
The part to plan for is Firebase. Many services reach it indirectly, and a spec must mock whatever would:
| Your component uses | It pulls in | Do this |
|---|---|---|
<arc-page-header> (every admin and member page) | The notification bell, the admin search box and AuthState | Spread ...headerTestProviders() first in providers |
AuthService | DbService, which injects Firestore | Provide a mock with the methods the component calls |
AuthState (the auth store) | Firebase Auth and AuthService | Provide a mock |
| A signal store | Its data service and Firestore | .overrideProvider(TheStore, { useValue: mockStore }), with signals for its state |
headerTestProviders() is in src/test/header-test-providers.ts. Put it first, so a provider your spec declares for the same token wins. A page spec, following src/app/pages/admin/(transactions)/transactions.page.spec.ts:
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { provideRouter } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import TransactionsPageComponent from './transactions.page';
import { TransactionsStore } from './transactions.store';
import { headerTestProviders } from '../../../../test/header-test-providers';
describe('TransactionsPageComponent', () => {
const mockStore = { items: signal([]), isLoading: signal(false), getAll: vi.fn() };
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TransactionsPageComponent, NoopAnimationsModule],
providers: [...headerTestProviders(), provideRouter([])],
})
.overrideProvider(TransactionsStore, { useValue: mockStore })
.compileComponents();
});
it('loads the transactions', () => {
const fixture = TestBed.createComponent(TransactionsPageComponent);
fixture.detectChanges();
expect(mockStore.getAll).toHaveBeenCalled();
});
});
A page that extends BaseComponent also needs an ActivatedRoute mock, as that spec shows. To render in another admin language, import translocoTestingModule({ lang: 'hi' }) from src/test/transloco-test-providers.ts.
Write a Cloud Function spec
Function specs live in functions/src/__tests__/ or beside the code. functions/src/__tests__/setup.ts mocks the Firebase Admin SDK for every spec. In your spec:
Mock
../init, the module that hands outdbandowner(the Admin SDK Auth), with fakes that record what the function writes. Build the fakes invi.hoisted(), becausevi.mock()runs before the imports.Mock the
firebase-functionsmodule the function registers with, so the export is the handler itself. For a callable,onCall: (...args) => args[args.length - 1].Import the function after the mocks and call it with a request (
{ auth, data }) or an event.
vi.mock('../init', () => ({ db: m.db, owner: m.owner }));
vi.mock('firebase-functions/v2/https', () => ({
onCall: (...args: any[]) => args[args.length - 1],
HttpsError: class HttpsError extends Error { constructor(public code: string, msg: string) { super(msg); } },
}));
import { adminCreateUser } from '../users/adminCreateUser.js';
For a Firestore trigger, mock firebase-functions/v2/firestore so the trigger records its handler, and take the handler once, at the top of the file: vi.mocked(onDocumentDeleted).mock.calls.at(-1)?.[1]. functions/src/__tests__/onUserDelete.spec.ts shows the whole pattern.
Take a trigger's handler at the top of the file, not inside a test. vi.clearAllMocks() in a beforeEach empties the mock's call list, so a handler read inside a test is missing, and a test that returns early when it is missing passes without checking anything.
Code that writes claims (mergeAppClaims(), createAppAccount() and Arc CMS's own claims writes) takes a short lease in Firestore first, so a spec whose fake db has no transactions mocks the lease to simply run the work, as functions/src/__tests__/appKit.spec.ts does:
vi.mock('../users/claimLock.js', () => ({ withClaimLock: (_uid: string, work: () => Promise<unknown>) => work() }));
The custom space in specs
Core specs test Arc CMS as it ships, whatever an app puts in the custom space's starter files, so an app can run the whole suite with its own routes, menu items, home pages, member languages and feature choices:
src/test/setup.tsmocks each starter file insrc/custom/with the value Arc CMS ships: every feature is on except the installable app (PWA), there are no app routes, menu items or member languages, and every role lands on Arc CMS's own home page.functions/src/__tests__/setup.tsdoes the same forfunctions/src/custom/(no app functions, no extra search sources), and mocks the functions' list of features with all of them on.- The starter files and their shipped values are listed once, in
scripts/custom-starters.mjs. A test fails if a starter file is missing from the list or from its setup file. The same script records a hash of every file shipped in the custom space; in Arc CMS itself a test fails when one changed or was deleted (Keep core generic). - An app's own specs, in
src/custom/,functions/src/custom/andcustom/, are not mocked: they see the app's real files. - The member language check (
src/test/member-language-parity.spec.ts) is a core spec that reads the app's realsrc/custom/languages.tson purpose, withvi.importActual(): it checks the app's own translations. - The menu checks in
src/app/custom-space.spec.tsread the app's realsrc/custom/nav.tsandsrc/custom/nav-changes.tsthe same way: every idCUSTOM_NAV_CHANGESnames must exist, every route must be a path and every outside address https, and every menu label key must be in an English file (What the test suite checks). The menus themselves are tested with the shipped, empty file, and a spec about a change setsCUSTOM_NAV_CHANGES.adminor.member(fromsrc/custom/nav-changes.ts, ornav.tsto test that it is still read) and deletes it afterwards.
A spec about a particular choice mocks the file itself, and its mock wins:
vi.mock('../../../custom/features', () => ({ CUSTOM_FEATURES: { on: ['pwa'] } }));
const featuresOff = new Set<string>();
vi.mock('../feature-flags.js', () => ({ isFeatureOn: (id: string) => !featuresOff.has(id) }));
The feature ids are listed in Feature ids.
Specs that meet an app's pages
The same suite runs in an app. The starter files read as shipped (above), but the rest of the app is real, so a core spec must not assume it is empty. Where a spec touches something an app can change, it reads the app's answer instead of a fixed one:
- Where a role lands after sign-in: compare with
homeFor(role), not with/user/dashboard, becausesrc/custom/home.tsmay send people elsewhere. - The route list: a route may group
childrenwithout a component of its own, and the app's routes insrc/custom/routes.tsare left out of the check that gives every URL to a feature or to core. - The admin menu: an item that links to one of the app's routes opens the app's page, so the menu check accepts it whatever feature the item names.
explicitRouteUrls()andisServedBy()insrc/test/route-urls.tsread the app's routes for both checks. - A file the check should not find: use a throwaway folder as the repo root, because an app has files such as
firestore.app.rulesthat Arc CMS does not. - The website: a spec about Arc CMS's own header, footer, sign-in panel, placeholder home page or templates reads them from
public/_site/, never fromvirtual:arc-site,readSiteFile()or the builtsiteManifest(), which show the app's site whensrc/custom/site/has one. A spec about pages the app may supply (the legal notice's terms and privacy links) passes its own answer, such aslegalNoticeUrls(() => false), or sets one withsetSiteManifestForTesting().
To check a change against an app, fill in a few starter files (routes with userGuard, a menu item, a member language, features turned off) and put a few files in src/custom/site/ (a header without a search box, a home page, a terms page), then run the suite: only the checks that Arc CMS ships its starter files empty and unchanged may fail, and they are skipped in an app.
Rules tests
The security rules are tested against the Firestore and Storage emulators:
npm run test:rules
It combines the core and app rules, starts the emulators with the Firebase CLI, and runs tests/rules/ with vitest.rules.config.ts. It needs the Firebase CLI and Java. Run it whenever a rules file changed. How to write a rules test is in Security rules.
Docs checks
The docs have tests too. They run with npm run test, and on their own with:
npm run check:docs
They fail on a broken link, a path or npm script that does not exist, a missing feature page, or a writing rule broken. See Writing docs.
Before you finish
A change is done when the whole suite passes, with the rules tests when rules changed, and the docs agree with the code: see Docs and tests are part of done.