- 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 |
|---|---|
| Vitest | Test runner & assertion library |
| Angular unit-test builder | Integrates Vitest with Angular compilation, test discovery, and coverage include/exclude |
| 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: [...] }),
],
});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 - Inline
vi.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(...) |
| PrimeNG modules | MockModule(...) |
Rule: Bare
MockProvider(Service)creates ng-mocks stubs, notvi.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 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 Mock).mockClear();
component.doSomething();
expect(store.dispatch).toHaveBeenCalledWith(new SpecificAction());Use this checklist:
- Use when the template/component needs router infrastructure (
routerLink,routerLinkActive, router directives/providers). - Keep it local to the spec.
- Skip it for pure logic tests without router directive usage.
- Use when code reads route state (
snapshot.paramMap,params,queryParams,parent, and similar route inputs). - Use it for deterministic route inputs in unit tests.
- Works with or without
provideRouter([])depending on template needs.
- Use when you assert navigation calls (
navigate,navigateByUrl,url/events usage). - Best for behavior assertions, not real router integration.
- If you mock
Router, you test whether navigation was requested, not real routing execution. - Do not mock
Routerin specs that validate real routing behavior viaprovideRouter(...).
- Params only:
ActivatedRouteMockBuilder - Params + navigation assertions:
ActivatedRouteMockBuilder+RouterMockBuilder - Template has
routerLink+ params + navigation assertions:provideRouter([])+ActivatedRouteMockBuilder+RouterMockBuilder - Need real router behavior:
provideRouter(...)+ActivatedRoutesetup, avoid mockingRouter
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 vi.fn() methods — safe to assert on directlyconst mockDialog = CustomDialogServiceMockBuilder.create()
.withOpen(
vi.fn().mockReturnValue({
onClose: dialogClose$.pipe(),
close: vi.fn(),
})
)
.build();const mockFilesService = {
uploadFile: vi.fn(),
getFileGuid: vi.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 inputsIn a zoneless environment, fixture.detectChanges() is used for immediate synchronous rendering. For signal updates and other async logic, use await fixture.whenStable() before DOM assertions so the scheduler can finish.
it('should update UI after signal change', async () => {
mySignal.set(newVal);
await fixture.whenStable();
expect(fixture.nativeElement.textContent).toContain(newVal);
});it('should dispatch after debounce', () => {
vi.useFakeTimers();
(store.dispatch as Mock).mockClear();
component.onProjectFilter('abc');
vi.advanceTimersByTime(300);
expect(store.dispatch).toHaveBeenCalledWith(new GetProjects('user-1', 'abc'));
vi.useRealTimers();
});
it('should debounce rapid calls', () => {
vi.useFakeTimers();
(store.dispatch as Mock).mockClear();
component.onProjectFilter('a');
component.onProjectFilter('ab');
component.onProjectFilter('abc');
vi.advanceTimersByTime(300);
const calls = (store.dispatch as Mock).mock.calls.filter(([a]: [unknown]) => a instanceof GetProjects);
expect(calls.length).toBe(1);
vi.useRealTimers();
});it('should emit attachFile', async () => {
const emitted = new Promise<FileModel>((resolve) => {
component.attachFile.subscribe((file) => resolve(file));
});
component.selectFile({ id: 'file-1' } as FileModel);
await expect(emitted).resolves.toEqual({ id: 'file-1' });
});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 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(
vi.fn().mockReturnValue({
onClose: dialogClose$.pipe(),
close: vi.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 Mock).mockImplementation(() => {
callCount++;
const subj = callCount === 1 ? selectClose$ : confirmClose$;
return { onClose: subj.pipe(), close: vi.fn() };
});
component.openSelectComponentsDialog();
selectClose$.next(['comp-1']);
expect(dialog.open).toHaveBeenCalledTimes(2);
const secondArgs = (dialog.open as 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 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 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 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 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
Coverage is configured in two layers:
angular.json(test.options) controls coverage file selection (coverageInclude/coverageExclude)vitest.config.ts(test.coverage) controls provider, reporters, output location, and thresholds
This split avoids path mismatches in Angular+Vitest runs and prevents 0% coverage issues caused by filtering transformed module ids in Vitest config.
Coverage for statements must be 90%+.
Thresholds are defined in vitest.config.ts under test.coverage.thresholds. Use those values as the source of truth.
- GitHub Actions CI: runs
ng test --no-watch --coverageand validates thresholds - Local command:
npm run test:check-coverage-thresholds
- Use
npm run test:no-coveragefor the fastest feedback loop - Use
npm run test:one -- "src/path/to/file.spec.ts"for targeted validation - Use
npm run test:coveragewhen checking final coverage impact
- Always use
provideOSFCore(). - Always use
provideMockStore()— never mockcomponent.actionsviaObject.defineProperty. - Always pass explicit mocks to
MockProviderwhen you needvi.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 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 Vitest fake timers for debounced operations —
vi.useFakeTimers(),vi.advanceTimersByTime(ms), andvi.useRealTimers(). - Use
fixture.componentRef.setInput()for signal inputs — never direct property assignment. - 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 Mock).mock.calls[0];
expect(callArgs[1].header).toBe('expected.title');
expect(callArgs[1].data.draftId).toBe('draft-1');const calls = (store.dispatch as Mock).mock.calls.filter(([a]: [unknown]) => a instanceof GetProjects);
expect(calls.length).toBe(1);
expect(calls[0][0]).toEqual(new GetProjects('user-1', 'abc'));