Related: Frontend Architecture · Frontend State
Tests run with Vitest + happy-dom, via Angular's @angular/build:unit-test builder (npm test). Specs use Jasmine-style describe/it (globals enabled), but mocking is Vitest's (vi.fn(), vi.mock(), vi.mocked()), not Jasmine spies.
npm test # full suite
npx ng test --watch=false --include="path/to/*.spec.ts" # a subsetA root vitest-base.config.ts sets test.isolate: true. The Angular builder auto-discovers this file (angular.json's unit-test runnerConfig: true triggers @angular/build's findVitestBaseConfig lookup) and merges it on top of its own internal default of isolate: false, so the external file wins — each spec file gets its own fresh module registry today. This wasn't always the case, and it's the reason the Firebase mocking pattern below looks the way it does — see "Why this must stay centralized" for the history and why the pattern remains correct even under isolate: true.
No coverage configuration exists — not in angular.json, vitest-base.config.ts or package.json (@vitest/coverage-v8 is installed as a devDependency but nothing wires it up). There are no specs under libs/ui/**: it's the Spartan/Helm component library (Brain headless primitives + Helm styling layer, 44+ components) — largely unmodified UI building blocks, not app logic. If a libs/ui component needs verification, exercise it indirectly through the feature component that uses it. Specs live under src/app/**.
src/test-setup.ts is registered once via angular.json's test.options.setupFiles. It globally mocks five @angular/fire/* modules: functions, firestore, storage, remote-config, auth. Every spec that touches one of these gets the mocked version automatically — do not add a per-spec-file vi.mock('@angular/fire/firestore', ...) (or functions/storage/remote-config/auth) for the same module.
This pattern was born from a real cross-file collision, back when the builder was still running with its internal default of test.isolate: false (one shared module registry per worker, mirroring the old Karma/Jasmine model — see the note above about vitest-base.config.ts now overriding this to true). When multiple spec files each declared their own vi.mock('@angular/fire/firestore', factory), the mocks collided:
- Symptom 1:
NG0201: No provider for Functionsin unrelated spec files (which is whyfunctionswas centralized first). - Symptom 2:
ReferenceError: Cannot access '...' before initialization, thrown from inside the mock factory, in a handful of spec files that happened to land in the same worker as another file locally mocking the same module (whether that other file mockedfirestore,storage, orremote-config— any per-filevi.mockof an already-globally-mocked module could trigger it).
Both symptoms were non-deterministic with respect to which files failed — it depended on how Vitest distributed spec files across workers, not on anything wrong with an individual test. The fix was the same in both cases: one global mock registration per module, in setupFiles, and nowhere else.
Under today's test.isolate: true, each spec file gets a fresh module registry, so that specific cross-file collision shouldn't reproduce anymore — but the centralized pattern is still the right one, for reasons independent of the isolation setting:
setupFilesexecute for every spec file regardless of isolation mode, so centralizing is what keeps the ~20+ service specs from each re-declaring an identicalvi.mock('@angular/fire/firestore', ...)boilerplate block.- It keeps exactly one source of truth for default mock behavior (e.g.
updateDocresolving toundefined) instead of specs silently disagreeing on defaults. - It's a safety net against regression — if isolation is ever turned back off (
vitest-base.config.tsremoved or edited), there's nothing left to collide because there's only one registration per module.
If you add a new spec file that needs Firestore/Storage/Remote Config/Functions/Auth: just import the (already-mocked) functions from @angular/fire/* — do not add your own vi.mock block for those specifiers.
Each global mock spreads await vi.importActual(...) first, then overrides only the functions that perform I/O or need call-assertion support (doc, collection, addDoc, updateDoc, deleteDoc, setDoc, writeBatch (returns a batch with set/update/delete mocks and a commit resolving to undefined), docData, collectionData, collectionCount, query, orderBy, where, limit, documentId, storage's ref/uploadBytes*/getDownloadURL, remote-config's getAllChanges). Pure value/sentinel helpers — serverTimestamp, arrayUnion, arrayRemove, deleteField, Firestore, WithFieldValue, etc. — are left real, since tests compare against their actual output (e.g. expect(updatedFields).toMatchObject({ locales: arrayRemove(entity) })).
The global mocks are shared vi.fn() instances with sane defaults (writes resolve to undefined; doc/collection return a fixed { path: 'mock-doc-ref' } / { path: 'mock-collection-ref' }). Configure return values per test, not per file:
import { collectionData, doc, Firestore, updateDoc } from '@angular/fire/firestore';
import { vi } from 'vitest';
describe('SomeService', () => {
afterEach(() => {
vi.clearAllMocks(); // clears call history; does NOT remove the default mockResolvedValue/mockReturnValue
});
function setup() {
TestBed.configureTestingModule({ providers: [{ provide: Firestore, useValue: {} }] });
return TestBed.inject(SomeService);
}
it('reads a collection', async () => {
const service = setup();
vi.mocked(collectionData).mockReturnValue(of([{ id: '1' }]));
const result = await firstValueFrom(service.findAll('space-1'));
expect(result).toEqual([{ id: '1' }]);
});
});Use vi.clearAllMocks() (not resetAllMocks()) in afterEach — reset would also wipe the global defaults (like updateDoc's mockResolvedValue(undefined)), forcing every test to re-supply them.
Firestore/Storage/Functions/RemoteConfig are provided as empty objects ({ provide: Firestore, useValue: {} }) purely as DI tokens — the mocked functions from @angular/fire/firestore etc. don't actually use the injected instance, so an empty object is enough. Assertions on doc/collection calls check against this same {}: expect(doc).toHaveBeenCalledWith({}, 'spaces/space-1/schemas/s1').
Not everything needs module-level mocking — prefer the narrowest option:
| Dependency | Pattern | Example |
|---|---|---|
Firestore, Storage, Functions, RemoteConfig |
{ provide: X, useValue: {} } (empty stub token) |
see above |
Auth |
{ provide: Auth, useValue: { currentUser } } — set currentUser per test to exercise both branches |
translation.service.spec.ts, me.service.spec.ts |
Auth's free functions (updateProfile, updateEmail, updatePassword, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup, signOut) |
Globally mocked in test-setup.ts, same pattern as firestore/storage/remote-config — import directly, do not add a local vi.mock('@angular/fire/auth', ...) |
me.service.spec.ts |
HttpClient |
{ provide: HttpClient, useValue: { get: vi.fn() } } |
asset.service.spec.ts (importFile()) |
A real service used only as a DI token in another spec (e.g. a store test stubbing SpaceService) |
{ provide: SpaceService, useValue: { findAll: () => of(spaces) } } — do not add a vi.mock for the service's own module just to stub it |
space.store.spec.ts |
@angular/fire/auth's free functions are now globally mocked in test-setup.ts too (they were originally left local to me.service.spec.ts since it was the only consumer, but any spec referencing MeService — even just as a useValue-stubbed DI token — loaded the real module and reintroduced the same collision risk, so it was centralized following the same pattern as firestore/storage/remote-config). Don't add a local vi.mock('@angular/fire/auth', ...) for it.
Follow the shape already used across src/app/shared/services/*.spec.ts (e.g. webhook.service.spec.ts, token.service.spec.ts):
- No local
vi.mockfor@angular/fire/{firestore,functions,storage,remote-config,auth}— import directly, they're already mocked. - A
setup()helper that callsTestBed.configureTestingModule({ providers: [...] })with empty-object stubs for injected Firebase tokens, thenTestBed.inject(YourService). afterEach(() => vi.clearAllMocks()).- Per test:
vi.mocked(fn).mockReturnValue(...)/.mockResolvedValue(...), call the service method, assert on both the emitted value and the call args passed to the mocked Firestore function (e.g. the second arg toupdateDoc/addDoc/setDocviatoMatchObject). - Run the new spec in isolation first (
--include="path/to/new.spec.ts"), then run the full suite — a spec that passes alone but not in the full run usually means a stray localvi.mockof an already-globally-mocked@angular/fire/*module, not a bug in the new test.