- Test Stack
- Project Testing Infrastructure
- Test File Structure
- TestBed Configuration
- Mocking Strategies
- Store Mocking
- Router & Route Mocking
- Service Mocking
- Signal-Based Testing
- Async Operations
- Form Testing
- Dialog Testing
- Edge Cases
- Testing Angular Services (HTTP)
- Testing NGXS State
- Test Data
- Coverage Enforcement
- Best Practices
- Appendix: Assertion Patterns
| Tool | Purpose |
|---|---|
| Jest | Test runner & assertion library |
| Angular TestBed | Component / service compilation |
| ng-mocks | MockComponents, MockModule, MockProvider |
| NGXS | State management — mocked via provideMockStore() for components, real store for state tests |
| RxJS | Observable / Subject-based async testing |
| HttpTestingController | HTTP interception for service and state integration tests |
| Custom utilities | src/testing/ — builders, factories, mock data |
src/testing/
├── osf.testing.provider.ts ← provideOSFCore(), provideOSFHttp()
├── providers/ ← Builder-pattern mocks for services
│ ├── store-provider.mock.ts
│ ├── route-provider.mock.ts
│ ├── router-provider.mock.ts
│ ├── toast-provider.mock.ts
│ ├── custom-confirmation-provider.mock.ts
│ ├── custom-dialog-provider.mock.ts
│ ├── component-provider.mock.ts
│ ├── loader-service.mock.ts
│ └── dialog-provider.mock.ts
├── mocks/ ← Mock domain models (89+ files)
│ ├── registries.mock.ts
│ ├── draft-registration.mock.ts
│ └── ...
└── data/ ← JSON API response fixtures
├── dashboard/
├── addons/
└── files/
Every component test must include provideOSFCore(). It configures translations and environment tokens.
export function provideOSFCore() {
return [provideTranslation, TranslateServiceMock, EnvironmentTokenMock];
}- Prefer a single flat
describeblock per file to keep tests searchable and prevent state leakage. Use nesteddescribeblocks when it significantly simplifies setup or groups logically distinct behaviors. - For specs where all tests share a single configuration, use
beforeEachwithTestBed.configureTestingModuledirectly. Use asetup()helper when tests need different selector values, route configs, or other overrides. - No
TestBed.resetTestingModule()inafterEach— Angular auto-resets. - Use actual interfaces/types for mock data instead of
any. - Co-locate unit tests with components using
*.spec.ts.
describe('MyComponent', () => {
let component: MyComponent;
let fixture: ComponentFixture<MyComponent>;
let store: Store;
beforeEach(() => {
TestBed.configureTestingModule({ ... });
store = TestBed.inject(Store);
fixture = TestBed.createComponent(MyComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});Use when tests need different selector values or route configs. Avoids duplicating TestBed configuration across tests.
Extend BaseSetupOverrides from @testing/providers/store-provider.mock when the spec only needs standard route/selector overrides. Add component-specific fields as needed.
Use mergeSignalOverrides from @testing/providers/store-provider.mock to apply selector overrides on top of default signal values.
Use withNoParent() on ActivatedRouteMockBuilder when testing components that guard against a missing parent route.
import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';
interface SetupOverrides extends BaseSetupOverrides {
routerUrl?: string;
}
function setup(overrides: SetupOverrides = {}) {
const routeBuilder = ActivatedRouteMockBuilder.create().withParams(overrides.routeParams ?? { id: 'draft-1' });
if (overrides.hasParent === false) routeBuilder.withNoParent();
const mockRoute = routeBuilder.build();
const mockRouter = RouterMockBuilder.create()
.withUrl(overrides.routerUrl ?? '/registries/drafts/reg-1/1')
.build();
const defaultSignals = [{ selector: MySelectors.getData, value: mockData }];
const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides);
TestBed.configureTestingModule({
imports: [MyComponent],
providers: [
provideOSFCore(),
MockProvider(ActivatedRoute, mockRoute),
MockProvider(Router, mockRouter),
provideMockStore({ signals }),
],
});
const store = TestBed.inject(Store);
const fixture = TestBed.createComponent(MyComponent);
return { fixture, component: fixture.componentInstance, store };
}
// Usage
it('should handle missing data', () => {
const { component } = setup({
selectorOverrides: [{ selector: MySelectors.getData, value: null }],
});
expect(component.hasData()).toBe(false);
});
it('should not dispatch when parent route is absent', () => {
const { store } = setup({ hasParent: false });
expect(store.dispatch).not.toHaveBeenCalled();
});TestBed.configureTestingModule({
imports: [
ComponentUnderTest,
...MockComponents(ChildA, ChildB),
MockModule(PrimeNGModule),
],
providers: [
provideOSFCore(),
MockProvider(ActivatedRoute, mockRoute),
MockProvider(Router, mockRouter),
MockProvider(ToastService, ToastServiceMock.simple()),
provideMockStore({ signals: [...] }),
],
});Use overrideComponent when a child uses Angular signal viewChild and MockComponents cannot stub it correctly.
TestBed.configureTestingModule({ ... })
.overrideComponent(FilesControlComponent, {
remove: { imports: [FilesTreeComponent] },
add: {
imports: [
MockComponentWithSignal('osf-files-tree', [
'files',
'selectionMode',
'totalCount',
'storage',
'currentFolder',
'isLoading',
'scrollHeight',
'viewOnly',
'resourceId',
'provider',
'selectedFiles',
]),
],
},
});Always check @testing/ before writing inline mocks. Builders and factories almost certainly exist.
- Use existing builders/factories from
@testing/providers/ - Use
MockProviderwith an explicit mock object - Use
MockComponents/MockModulefrom ng-mocks - Use
MockComponentWithSignalfor signal-input children - Inline
jest.fn()mocks as a last resort
| Need | Use |
|---|---|
| Store selectors / dispatch | provideMockStore() |
| Router | RouterMockBuilder |
| ActivatedRoute | ActivatedRouteMockBuilder |
| ToastService | ToastServiceMock.simple() |
| CustomConfirmationService | CustomConfirmationServiceMock.simple() |
| CustomDialogService | CustomDialogServiceMockBuilder |
| LoaderService | new LoaderServiceMock() |
| Child components | MockComponents(...) or MockComponentWithSignal(...) |
| PrimeNG modules | MockModule(...) |
Rule: Bare
MockProvider(Service)creates ng-mocks stubs, notjest.fn(). When you need.mockImplementation,.mockClear, or assertion checking, always pass an explicit mock as the second argument.
| Config key | Maps to | Use case |
|---|---|---|
signals |
store.selectSignal() |
Signal-based selectors (most common) |
selectors |
store.select() / selectSnapshot() |
Observable-based selectors |
actions |
store.dispatch() return value |
When component reads dispatch result |
provideMockStore({
signals: [
{ selector: RegistriesSelectors.getDraftRegistration, value: mockDraft },
{ selector: RegistriesSelectors.getStepsState, value: stepsStateSignal },
],
actions: [
{ action: new CreateDraft({ ... }), value: { id: 'new-draft' } },
],
})Use mergeSignalOverrides(defaults, overrides) from @testing/providers/store-provider.mock instead of inlining the merge logic. It replaces matching selectors and preserves the rest.
import { mergeSignalOverrides } from '@testing/providers/store-provider.mock';
const defaultSignals = [
{ selector: MySelectors.getData, value: [] },
{ selector: MySelectors.isLoading, value: false },
];
const signals = mergeSignalOverrides(defaultSignals, overrides.selectorOverrides);expect(store.dispatch).toHaveBeenCalledWith(new MyAction('id'));
expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(MyAction));
// Filter by action type across multiple dispatches
const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects);
expect(calls.length).toBe(1);When ngOnInit dispatches and you need isolated per-test assertions:
(store.dispatch as jest.Mock).mockClear();
component.doSomething();
expect(store.dispatch).toHaveBeenCalledWith(new SpecificAction());const mockRoute = ActivatedRouteMockBuilder.create()
.withParams({ id: 'draft-1' })
.withQueryParams({ projectId: 'proj-1' })
.withData({ feature: 'registries' })
.build();
// Nested child routes
const mockRoute = ActivatedRouteMockBuilder.create()
.withParams({ id: 'reg-1' })
.withFirstChild((child) => child.withParams({ step: '2' }))
.build();
// No parent route (for testing components that guard against missing parent)
const mockRoute = ActivatedRouteMockBuilder.create().withParams({ id: 'reg-1' }).withNoParent().build();const mockRouter = RouterMockBuilder.create().withUrl('/registries/drafts/reg-1/metadata').build();
expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], expect.objectContaining({ relativeTo: expect.anything() }));
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/registries/prov-1/new');const toastService = ToastServiceMock.simple();
const confirmationService = CustomConfirmationServiceMock.simple();
// Returns plain objects with jest.fn() methods — safe to assert on directlyconst mockDialog = CustomDialogServiceMockBuilder.create()
.withOpen(
jest.fn().mockReturnValue({
onClose: dialogClose$.pipe(),
close: jest.fn(),
})
)
.build();const mockFilesService = {
uploadFile: jest.fn(),
getFileGuid: jest.fn(),
};
MockProvider(FilesService, mockFilesService);Pass a WritableSignal as the selector value to change state mid-test. The mock store detects isSignal(value) and returns it as-is, so updates propagate automatically.
let stepsStateSignal: WritableSignal<{ invalid: boolean }[]>;
beforeEach(() => {
stepsStateSignal = signal([{ invalid: true }]);
provideMockStore({
signals: [{ selector: RegistriesSelectors.getStepsState, value: stepsStateSignal }],
});
});
it('should react to signal changes', () => {
expect(component.isDraftInvalid()).toBe(true);
stepsStateSignal.set([{ invalid: false }]);
expect(component.isDraftInvalid()).toBe(false);
});fixture.componentRef.setInput('attachedFiles', []);
fixture.componentRef.setInput('projectId', 'project-1');
fixture.detectChanges();
// Never use direct property assignment for signal inputsit('should dispatch after debounce', fakeAsync(() => {
(store.dispatch as jest.Mock).mockClear();
component.onProjectFilter('abc');
tick(300);
expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc'));
}));
// Deduplication — only the last value dispatches
it('should debounce rapid calls', fakeAsync(() => {
(store.dispatch as jest.Mock).mockClear();
component.onProjectFilter('a');
component.onProjectFilter('ab');
component.onProjectFilter('abc');
tick(300);
const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects);
expect(calls.length).toBe(1);
}));it('should emit attachFile', (done) => {
component.attachFile.subscribe((f) => {
expect(f).toEqual({ id: 'file-1' });
done();
});
component.selectFile({ id: 'file-1' } as FileModel);
});it('should be invalid when title is empty', () => {
component.metadataForm.patchValue({ title: '' });
expect(component.metadataForm.get('title')?.valid).toBe(false);
});
it('should trim values on submit', () => {
component.metadataForm.patchValue({
title: ' Padded Title ',
description: ' Padded Desc ',
});
(store.dispatch as jest.Mock).mockClear();
component.submitMetadata();
expect(store.dispatch).toHaveBeenCalledWith(
new UpdateDraft('draft-1', expect.objectContaining({ title: 'Padded Title' }))
);
});it('should toggle validator', () => {
component.toggleFromProject();
expect(component.draftForm.get('project')?.validator).toBeTruthy();
component.toggleFromProject();
expect(component.draftForm.get('project')?.validator).toBeNull();
});
it('should mark form touched on init when invalid', () => {
expect(component.metadataForm.touched).toBe(true);
});Always use a real Subject for onClose — MockProvider cannot auto-generate reactive streams. Use provideDynamicDialogRefMock() where applicable.
const dialogClose$ = new Subject<any>();
const mockDialog = CustomDialogServiceMockBuilder.create()
.withOpen(
jest.fn().mockReturnValue({
onClose: dialogClose$.pipe(),
close: jest.fn(),
})
)
.build();
it('should navigate on confirm', () => {
component.openConfirmDialog();
dialogClose$.next(true);
expect(mockRouter.navigate).toHaveBeenCalledWith(['/new-reg-1/overview']);
});
it('should not navigate on cancel', () => {
component.openConfirmDialog();
dialogClose$.next(false);
expect(mockRouter.navigate).not.toHaveBeenCalled();
});it('should pass data between dialogs', () => {
const selectClose$ = new Subject<any>();
const confirmClose$ = new Subject<any>();
let callCount = 0;
(dialog.open as jest.Mock).mockImplementation(() => {
callCount++;
const subj = callCount === 1 ? selectClose$ : confirmClose$;
return { onClose: subj.pipe(), close: jest.fn() };
});
component.openSelectComponentsDialog();
selectClose$.next(['comp-1']);
expect(dialog.open).toHaveBeenCalledTimes(2);
const secondArgs = (dialog.open as jest.Mock).mock.calls[1];
expect(secondArgs[1].data.components).toEqual(['comp-1']);
});it('should dispatch on confirm', () => {
mockConfirmation.confirmDelete.mockImplementation(({ onConfirm }: any) => onConfirm());
(store.dispatch as jest.Mock).mockClear();
component.deleteDraft();
expect(store.dispatch).toHaveBeenCalledWith(new DeleteDraft('draft-1'));
});Components that auto-save on destroy must skip saves when the resource was already deleted. Test both paths.
it('should skip updates on destroy when draft was deleted', () => {
(store.dispatch as jest.Mock).mockClear();
component.isDraftDeleted = true;
component.ngOnDestroy();
expect(store.dispatch).not.toHaveBeenCalled();
});
it('should dispatch update on destroy when fields changed', () => {
component.metadataForm.patchValue({ title: 'Changed Title' });
(store.dispatch as jest.Mock).mockClear();
component.ngOnDestroy();
expect(store.dispatch).toHaveBeenCalledWith(
new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed Title' }))
);
});
it('should not dispatch update on destroy when fields are unchanged', () => {
(store.dispatch as jest.Mock).mockClear();
component.ngOnDestroy();
expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(UpdateDraft));
});it('should handle null draft', () => {
const { component } = setup({
selectorOverrides: [{ selector: Selectors.getDraft, value: null }],
});
expect(component).toBeTruthy();
});it('should mark invalid when required field has empty array', () => {
const { component } = setup({
selectorOverrides: [{ selector: Selectors.getStepsData, value: { field1: [] } }],
});
expect(component.steps()[1].invalid).toBe(true);
});
it('should not mark invalid with non-empty array', () => {
const { component } = setup({
selectorOverrides: [{ selector: Selectors.getStepsData, value: { field1: ['item'] } }],
});
expect(component.steps()[1].invalid).toBe(false);
});it('should not upload when no upload link', () => {
currentFolderSignal.set({ links: {} } as FileFolderModel);
component.uploadFiles(file);
expect(mockFilesService.uploadFile).not.toHaveBeenCalled();
});it('should warn on oversized file', () => {
const oversizedFile = new File([''], 'big.bin');
Object.defineProperty(oversizedFile, 'size', { value: FILE_SIZE_LIMIT });
component.onFileSelected({ target: { files: [oversizedFile] } } as unknown as Event);
expect(toastService.showWarn).toHaveBeenCalledWith('shared.files.limitText');
});it('should deduplicate file selection', () => {
const file = { id: 'file-1' } as FileModel;
component.onFileTreeSelected(file);
component.onFileTreeSelected(file);
expect(component.filesSelection).toEqual([file]);
});it('should not dispatch when submitting', () => {
const { store } = setup({
selectorOverrides: [
{ selector: Selectors.isDraftSubmitting, value: true },
{ selector: Selectors.getDraft, value: { ...DEFAULT_DRAFT, hasProject: true } },
],
});
expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(FetchProjectChildren));
});All services that make HTTP requests must be tested using HttpClientTestingModule and HttpTestingController. Only use data from @testing/data mocks when flushing requests — never hardcode response values inline.
import { HttpTestingController } from '@angular/common/http/testing';
import { provideOSFCore, provideOSFHttp } from '@testing/osf.testing.provider';
let service: YourService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [provideOSFCore(), provideOSFHttp(), YourService],
});
service = TestBed.inject(YourService);
});it('should call correct endpoint and return expected data', () => {
const httpMock = TestBed.inject(HttpTestingController);
service.getSomething().subscribe((data) => {
expect(data).toEqual(mockData);
});
const req = httpMock.expectOne('/api/endpoint');
expect(req.request.method).toBe('GET');
req.flush(getMockDataFromTestingData());
httpMock.verify();
});- Use
provideOSFCore() + provideOSFHttp()to isolate the service - Always call
httpMock.expectOne()to verify the URL and method - Always call
req.flush()with data from@testing/data— never hardcode responses inline - Add
httpMock.verify()at the end of each test to catch unflushed requests - Error handling paths must also be tested
The OSF Angular strategy for NGXS state testing is to create small integration test scenarios rather than isolated unit tests. This is a deliberate design decision.
- Actions tested in isolation are hard to mock and produce garbage-in/garbage-out tests
- Selectors tested in isolation are easy to mock but equally produce false positives
- States tested in isolation are easy to invoke but provide no meaningful validation
- Mocking service calls during state tests introduces false positives — mocked responses may not reflect actual backend behaviour
- Dispatch the primary action — kick off the state logic under test
- Dispatch any dependent actions — include secondary actions that rely on the primary action's outcome
- Verify the loading selector is
true— ensure loading state activates during the async flow - Flush HTTP requests with
@testing/datamocks — confirm correct requests are made and flushed with known data - Verify the loading selector is
false— ensure loading deactivates after the response is handled - Verify the primary data selector — check the core selector returns expected state
- Verify additional selectors — assert derived selectors relevant to the action
- Call
httpMock.verify()— confirm no HTTP requests remain unhandled
it('should test action, state and selectors', () => {
const httpMock = TestBed.inject(HttpTestingController);
let result: any[] = [];
// 1. Dispatch dependent action first
store.dispatch(new GetAuthorizedStorageAddons('reference-id')).subscribe();
// 2. Dispatch primary action
store.dispatch(new GetAuthorizedStorageOauthToken('account-id')).subscribe(() => {
result = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddons);
});
// 3. Loading selector is true
const loading = store.selectSignal(AddonsSelectors.getAuthorizedStorageAddonsLoading);
expect(loading()).toBeTruthy();
// 4a. Flush dependent action HTTP request
let req = httpMock.expectOne('api/path/dependency/action');
expect(req.request.method).toBe('GET');
req.flush(getAddonsAuthorizedStorageData());
// 4b. Flush primary action HTTP request
req = httpMock.expectOne('api/path/primary/action');
expect(req.request.method).toBe('PATCH');
const addonWithToken = getAddonsAuthorizedStorageData(1);
addonWithToken.data.attributes.oauth_token = 'ya2.34234324534';
req.flush(addonWithToken);
// 5. Loading selector is false
expect(loading()).toBeFalsy();
// 6. Primary selector — verify only the targeted record was updated
const oauthToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[0].id));
expect(oauthToken).toBe('ya29.A0AS3H6NzDCKgrUx');
// 7. Other selector — verify untargeted record is unchanged
const otherToken = store.selectSnapshot(AddonsSelectors.getAuthorizedStorageAddonOauthToken(result[1].id));
expect(otherToken).toBe(result[1].oauthToken);
// 8. No outstanding requests
httpMock.verify();
});Test data lives in two directories under src/testing/. Always use these — never hardcode response values inline in tests.
Pre-built mock objects for domain models used directly in component tests. Imported via @testing/mocks/*.
| File | Purpose |
|---|---|
registries.mock.ts |
MOCK_DRAFT_REGISTRATION, MOCK_PAGES_SCHEMA |
draft-registration.mock.ts |
MOCK_DRAFT_REGISTRATION with full shape |
schema-response.mock.ts |
Schema response fixtures |
contributors.mock.ts |
Contributor model mocks |
project.mock.ts |
Project model mocks |
Centralised raw JSON API responses used for HTTP flush in service and state integration tests. Imported via @testing/data/*.
| File | Purpose |
|---|---|
addons.authorized-storage.data.ts |
Authorised storage addon fixtures |
addons.external-storage.data.ts |
External storage addon fixtures |
addons.configured.data.ts |
Configured addon state fixtures |
addons.operation-invocation.data.ts |
Operation invocation fixtures |
- Any change to an underlying data model produces cascading test failures, exposing the full scope of a refactor
- Hardcoded inline values lead to false positives and missed regressions
- Consistent data across tests makes selector and state assertions directly comparable
- Include enough data to cover all relevant permutations required by the test suite
- Ensure data reflects all possible states of the model
This project strictly enforces 90%+ test coverage through GitHub Actions CI.
| File type | Requirement | Notes |
|---|---|---|
*.ts |
90%+ line & branch | Zero exceptions |
| Services | 90%+ | Must mock HTTP via HttpTestingController |
| Components | 90%+ | DOM + Input + Output event coverage |
| Pipes / utils | 90%+ | All edge cases tested |
| NGXS state | 90%+ | Integration test approach required |
- GitHub Actions CI: runs on every PR and push — build fails if a single uncovered branch, line, or function exists
Tip: Use
npm run test:watchduring development to maintain coverage incrementally rather than discovering gaps at push time.
- Always use
provideOSFCore(). - Always use
provideMockStore()— never mockcomponent.actionsviaObject.defineProperty. - Always pass explicit mocks to
MockProviderwhen you needjest.fn()assertions. BareMockProvider(Service)creates ng-mocks stubs. - Check
@testing/before creating inline mocks — builders and factories almost certainly exist. - Prefer a single flat
describeblock per file to keep tests searchable and prevent state leakage. Use nesteddescribeblocks when it significantly simplifies setup or groups logically distinct behaviors. NoafterEach. - No redundant tests — merge tests that cover the same code path.
- Use
(store.dispatch as jest.Mock).mockClear()whenngOnInitdispatches and you need isolated per-test assertions. - Use
WritableSignalfor dynamic state — passsignal()values toprovideMockStorewhen tests need to mutate state mid-test. - Use
Subjectfor dialogonClose— gives explicit control over dialog result timing. UseprovideDynamicDialogRefMock()where applicable. - Use
fakeAsync+tickfor debounced operations — specify the exact debounce duration. - Use
fixture.componentRef.setInput()for signal inputs — never direct property assignment. - Use
ngMocks.faster()when all tests in a file share identicalTestBedconfig — reuses the compiled module for speed. Do not use if any test requires a different config: shared state will cause subtle test pollution. - Use typed mock interfaces (
ToastServiceMockType,RouterMockType, etc.) — avoidany. - Test both positive and negative paths — confirm an action fires AND confirm it does not fire when conditions are not met.
- Only use
@testing/datafixtures in HTTP flushes — never hardcode response values inline in service or state tests. - Each test should highlight the most critical aspect of the code — if a test fails during a refactor, it should clearly signal that a core feature was impacted.
expect(store.dispatch).toHaveBeenCalledWith(new MyAction('id'));
expect(store.dispatch).not.toHaveBeenCalledWith(expect.any(MyAction));
expect(store.dispatch).toHaveBeenCalledWith(new UpdateDraft('draft-1', expect.objectContaining({ title: 'Changed' })));expect(mockRouter.navigate).toHaveBeenCalledWith(['../1'], expect.objectContaining({ relativeTo: expect.anything() }));
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('/target');expect(mockDialog.open).toHaveBeenCalled();
const callArgs = (mockDialog.open as jest.Mock).mock.calls[0];
expect(callArgs[1].header).toBe('expected.title');
expect(callArgs[1].data.draftId).toBe('draft-1');const calls = (store.dispatch as jest.Mock).mock.calls.filter(([a]: [any]) => a instanceof GetProjects);
expect(calls.length).toBe(1);
expect(calls[0][0]).toEqual(new GetProjects('user-1', 'abc'));