@constantant/openapi-resource-mocks
June 18, 2026 · View on GitHub
Mock bus for @constantant/openapi-resource-gen tokens.
Provides zero-HTTP, pure-DI mocks for Angular InjectionToken-based data-access libs — with a cross-boundary API so Playwright E2E tests and a future Chrome Extension devtools panel can observe and control every token's state from outside the Angular context.
Install
npm install -D @constantant/openapi-resource-mocks
Peer dependencies: @angular/core >=22, @angular/common >=22.
Core concept
Each mock token is registered in a MockResourceBus. The bus:
- Exposes
window.__openApiMocks__— a plain object keyed by token name, accessible from Playwright'spage.evaluate()or a Chrome Extension content script. - Emits DOM events (
openapi-mock-event) on every state change so the Chrome Extension DevTools panel can observe in real time. - Listens for DOM events (
openapi-mock-control) so the DevTools panel can push data into the app.
Setup
Add provideMockResourceBus() once, then one mock provider per token.
With --includeMocks (recommended) — the generator emits a typed provide{Operation}Mock() wrapper per endpoint. Import from the /mock subpath so mock utilities stay out of your production bundle:
// app.config.mock.ts (used in tests / E2E variant)
import { provideMockResourceBus } from '@constantant/openapi-resource-mocks';
import { provideFindPetsByStatusMock, provideUploadFileMock } from '@myapp/petstore-data-access/mock';
export const mockProviders = [
provideMockResourceBus(),
provideFindPetsByStatusMock(),
provideUploadFileMock(),
];
Without --includeMocks — use provideMockResource() directly with the raw token:
// app.config.mock.ts
import { provideMockResourceBus, provideMockResource } from '@constantant/openapi-resource-mocks';
import { FIND_PETS_BY_STATUS, UPLOAD_FILE } from '@myapp/petstore-data-access';
export const mockProviders = [
provideMockResourceBus(),
provideMockResource(FIND_PETS_BY_STATUS, 'FIND_PETS_BY_STATUS'),
provideMockResource(UPLOAD_FILE, 'UPLOAD_FILE'),
];
Unit tests / Storybook (in-process control)
import { TestBed } from '@angular/core/testing';
import { injectMockResource, provideMockResourceBus, provideMockResource } from '@constantant/openapi-resource-mocks';
TestBed.configureTestingModule({
imports: [PetsComponent],
providers: [
provideMockResourceBus(),
provideMockResource(FIND_PETS_BY_STATUS, 'FIND_PETS_BY_STATUS'),
],
});
// Render the component first — this calls the token factory and registers the ref
const fixture = TestBed.createComponent(PetsComponent);
fixture.detectChanges();
// Now the ref is in the bus
const mock = TestBed.runInInjectionContext(() =>
injectMockResource('FIND_PETS_BY_STATUS'),
);
mock.resolve([{ id: 1, name: 'Rex', status: 'available' }]);
fixture.detectChanges();
mock.setLoading();
mock.fail(new Error('network'));
mock.reset();
// Delayed response (loading → data after 500 ms)
mock.resolveAfter(500, []);
// Reload — keeps previous value visible while re-fetching (status → 'reloading')
mock.resolve([{ id: 1, name: 'Rex', status: 'available' }]);
mock.reload(); // status: 'reloading', value still [Rex], isLoading() → true
mock.resolve([{ id: 1, name: 'Rex', status: 'sold' }]); // complete the reload
// Request count — total factory invocations including reactive param changes and reload()
expect(mock.requestCount()).toBe(1); // checked after fixture.detectChanges()
// Reset history between scenarios without remounting the component
mock.clearHistory(); // via bus.get(key)
Types come from the generated lib — no hand-written interfaces needed.
Unit test helpers (/testing)
@constantant/openapi-resource-mocks/testing is a lightweight sub-entry for unit tests and
Storybook. It does not import MockResourceBus, register DOM events, or touch window —
it simply wraps a token with a MockResourceRef and drops it into TestBed as a
FactoryProvider.
import { mockResource } from '@constantant/openapi-resource-mocks/testing';
import { FIND_PETS_BY_STATUS } from '@myapp/petstore-data-access';
const petsMock = mockResource(FIND_PETS_BY_STATUS, { value: [] });
TestBed.configureTestingModule({
imports: [PetsComponent],
providers: [petsMock], // MockResourceHandle extends FactoryProvider — drop it in directly
});
fixture.detectChanges();
petsMock.ref.resolve([{ id: 1, name: 'Rex', status: 'available', photoUrls: [] }]);
fixture.detectChanges();
petsMock.expectCalled();
petsMock.expectCalledWith({ status: 'available' });
console.log(petsMock.calls); // readonly unknown[][] — all factory invocations and their args
mockResource<T>(token, behaviorOrOptions?)
Returns MockResourceHandle<T>, which extends FactoryProvider.
The second argument sets the initial mock state (same shapes as provideMockResource):
| Shape | Effect |
|---|---|
{ value: T } | Resolves immediately |
{ value: T, delay: ms } | Loading for ms ms, then resolves |
{ loading: true } | Stays loading indefinitely |
{ error: unknown } | Fails immediately |
{ error: unknown, delay: ms } | Loading for ms ms, then fails |
Response sequences
Pass { sequence: [...] } to advance through multiple states — each factory call (and
each reload() in resolved state) consumes the next entry; the last entry repeats when exhausted:
const mock = mockResource(FIND_PETS_BY_STATUS, {
sequence: [
{ loading: true },
{ error: new Error('timeout') },
{ value: [{ id: 1, name: 'Rex', status: 'available', photoUrls: [] }] },
],
});
MockResourceHandle<T>
| Member | Description |
|---|---|
.ref | MockResourceRef<T> — the underlying ref; call .resolve(), .fail(), etc. to change state mid-test |
.calls | readonly unknown[][] — all args the factory was invoked with |
.expectCalled() | Throws if the factory was never called |
.expectCalledWith(...args) | Throws if no call matches (deep equality via recursive comparison) |
File upload / download progress
In-process (unit tests / Storybook)
const mock = TestBed.runInInjectionContext(() =>
injectMockResource<UploadResponse>('UPLOAD_FILE'),
);
// Animate through 10 steps over 2 s, then resolve
mock.simulateProgress('upload', 4_000_000, 2000, { id: 'abc123' });
// Or drive progress manually for precise test control
mock.setProgress('upload', 1_000_000, 4_000_000); // 25 %
mock.setProgress('upload', 4_000_000, 4_000_000); // 100 %
mock.resolve({ id: 'abc123' });
// Download progress (total unknown — streaming)
mock.setProgress('download', 16_384);
// Simulate failure mid-upload — progress is preserved so the UI can show "failed at 25%"
mock.setProgress('upload', 1_000_000, 4_000_000);
mock.fail(new Error('connection reset'));
console.log(mock.progress()); // { type: 'upload', loaded: 1_000_000, total: 4_000_000 }
E2E (Playwright)
// Animate upload progress then resolve
await page.evaluate(() =>
openApiMock('UPLOAD_FILE').simulateProgress('upload', 4_000_000, 2000, { id: 'abc123' }),
);
await expect(page.locator('[data-testid="progress-bar"]')).toBeVisible();
// Manual steps
await page.evaluate(() =>
openApiMock('UPLOAD_FILE').setProgress('upload', 1_000_000, 4_000_000),
);
await expect(page.locator('[data-testid="progress-bar"]')).toHaveAttribute('aria-valuenow', '25');
// Fail mid-upload
await page.evaluate(() => openApiMock('UPLOAD_FILE').fail(new Error('timeout')));
const state = await page.evaluate(() => openApiMock('UPLOAD_FILE').getState());
console.log(state.progress); // { type: 'upload', loaded: 1_000_000, total: 4_000_000 }
Chrome Extension → app
// Animate upload
document.dispatchEvent(new CustomEvent('openapi-mock-control', {
detail: {
key: 'UPLOAD_FILE',
action: 'simulateProgress',
progressType: 'upload',
total: 4_000_000,
delayMs: 2000,
value: { id: 'abc123' },
steps: 20, // optional, default 10
},
}));
// Single progress step
document.dispatchEvent(new CustomEvent('openapi-mock-control', {
detail: { key: 'UPLOAD_FILE', action: 'setProgress', progressType: 'download', loaded: 512, total: 1024 },
}));
E2E tests (Playwright)
Setup
Serve the app with mock providers on a separate port so real and mock E2E suites never collide.
1. Mock app configuration — swap the real providers for mock ones:
// app.config.mock.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideMockResourceBus } from '@constantant/openapi-resource-mocks';
import { provideFindPetsByStatusMock } from '@myapp/petstore-data-access/mock';
import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(appRoutes),
provideMockResourceBus(),
provideFindPetsByStatusMock({
value: [{ id: 1, name: 'Rex', status: 'available', photoUrls: [] }],
delay: 500,
}),
],
};
If you didn't use --includeMocks, replace the wrapper with provideMockResource(FIND_PETS_BY_STATUS, 'FIND_PETS_BY_STATUS', { ... }) imported from @constantant/openapi-resource-mocks.
2. Separate Playwright config — points to port 4201 and scopes testDir to the mock specs folder:
// playwright.mock.config.ts
import { defineConfig, devices } from '@playwright/test';
import { nxE2EPreset } from '@nx/playwright/preset';
import { workspaceRoot } from '@nx/devkit';
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src/mock' }),
use: {
baseURL: 'http://localhost:4201',
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run myapp:serve --configuration=mock-e2e',
url: 'http://localhost:4201',
reuseExistingServer: true,
cwd: workspaceRoot,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
3. Exclude mock specs from the main config — prevents mock specs from running against the real app:
// playwright.config.ts (existing main config)
export default defineConfig({
...nxE2EPreset(__filename, { testDir: './src' }),
testIgnore: ['**/mock/**'],
// ...
});
4. TypeScript ambient declarations — required for openApiMock(key) to typecheck inside page.evaluate() function bodies:
// src/global.d.ts
interface MockEntry {
resolve(value: unknown): void;
resolveAfter(ms: number, value: unknown): void;
setLoading(): void;
fail(error: unknown): void;
reset(): void;
reload(): boolean;
setProgress(type: 'upload' | 'download', loaded: number, total?: number): void;
simulateProgress(type: 'upload' | 'download', totalBytes: number, durationMs: number, finalValue: unknown, steps?: number): void;
getState(): { status: string; value: unknown; error: unknown; progress: unknown; requestCount: number };
getHistory(): Array<{ type: string; ts: number; [key: string]: unknown }>;
clearHistory(): void;
onEvent(cb: (event: { type: string; [key: string]: unknown }) => void): () => void;
}
interface Window {
__openApiMocks__: Record<string, MockEntry>;
openApiMock: (key: string) => MockEntry;
}
// Needed for unqualified openApiMock('KEY') calls inside page.evaluate(() => ...)
declare function openApiMock(key: string): MockEntry;
Also add "types": ["node"] to tsconfig.json if you use __filename in the Playwright config.
Writing specs
Always wait for a page landmark in beforeEach before running assertions — this guarantees Angular has bootstrapped and the mock factories have been called:
test.beforeEach(async ({ page }) => {
await page.goto('/pets');
await expect(page.getByRole('heading', { name: 'Pets' })).toBeVisible();
});
Without this guard, assertions like toBeHidden() and toHaveCount(0) can pass immediately (before any elements render), and subsequent page.evaluate() calls will throw openApiMock is not defined.
Controlling state
// Resolve with data
await page.evaluate(() =>
openApiMock('FIND_PETS_BY_STATUS').resolve([{ id: 1, name: 'Rex', status: 'available' }]),
);
await expect(page.locator('mat-row')).toHaveCount(1);
// Test loading skeleton
await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').setLoading());
await expect(page.locator('mat-progress-bar')).toBeVisible();
// Simulate slow network
await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').resolveAfter(1000, []));
// Reload — keeps previous value visible while re-fetching
await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').reload());
// status → 'reloading', pets still shown, spinner visible
await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').resolve([{ id: 2, name: 'Luna', status: 'available' }]));
// Inspect current state (includes progress and requestCount)
const state = await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').getState());
// { status, value, error, progress, requestCount }
// Full event history (requests + responses + progress ticks)
// resolveAfter and simulateProgress now emit loading/progress/resolve entries here
const history = await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').getHistory());
// Reset history between test scenarios without remounting components
await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').clearHistory());
Asserting request params
getHistory() records every factory invocation as a request entry. Function args (reactive lambdas) are resolved at call time, so args contains plain JSON values:
const history = await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').getHistory());
const req = history.find((e) => e.type === 'request');
expect(req?.args[0]).toEqual({ status: 'available' });
For tokens whose factory receives a reactive lambda (e.g. () => query() ? { q: query() } : undefined), args[0] will be the resolved value at mount time — undefined when suppressed, or the plain params object when active:
// Confirms the resource was suppressed on init (query was empty)
expect(req?.args[0]).toBeUndefined();
Chrome Extension integration
Extension content script → app (control):
document.dispatchEvent(new CustomEvent('openapi-mock-control', {
detail: { key: 'FIND_PETS_BY_STATUS', action: 'resolve', value: [...] },
}));
// actions: resolve | resolveAfter | setLoading | fail | reset | reload
// setProgress | simulateProgress | clearHistory
// resolveAfter: delayMs: number
// setProgress: progressType: 'upload'|'download', loaded: number, total?: number
// simulateProgress: progressType, total: number, delayMs: number, value, steps?: number
// reload / clearHistory: no additional fields required
App → extension (observe):
document.addEventListener('openapi-mock-event', (e) => {
const { key, event } = e.detail;
// event.type: 'request' | 'resolve' | 'loading' | 'error' | 'reset' | 'progress'
devtoolsPanel.update(key, event);
});
API reference
provideMockResourceBus()
Returns EnvironmentProviders. Call once in your root providers or TestBed setup.
provideMockResource(token, key, initialBehavior?, meta?, options?)
Returns FactoryProvider. Each time a component invokes the factory function, a fresh ref is created, registered in the bus under key, and initialBehavior is applied — simulating the full request lifecycle on every mount.
The optional meta argument is a MockResourceMeta object. When provided, the DevTools panel uses it to look up response schemas and pre-populate the Respond tab's schema display. Generated .mock.ts files embed this automatically — you only need to pass it manually when using provideMockResource() directly.
The optional options argument is a MockProviderOptions object — see below.
initialBehavior controls how the mock behaves on each invocation:
| Shape | Effect |
|---|---|
{ value: T } | Resolves immediately with value |
{ value: T, delay: ms } | Loading for ms ms, then resolves |
{ loading: true } | Stays loading indefinitely |
{ error: unknown } | Fails immediately |
{ error: unknown, delay: ms } | Loading for ms ms, then fails |
MockProviderOptions
Passed as the fifth argument to provideMockResource() and as the second argument to generated provide{Operation}Mock() wrappers.
interface MockProviderOptions {
keyDiscriminator?: () => string;
}
keyDiscriminator — called inside the factory injection context to produce a suffix appended to the bus key as key:suffix. Use this when the same token appears in multiple component instances simultaneously (e.g. list rows) so each instance is independently addressable:
// List row component — each instance registers under a unique key
providers: [
providePetItemMock(undefined, { keyDiscriminator: () => inject(PET_ID).toString() }),
]
// Registers as 'GET_PET_BY_ID:42', 'GET_PET_BY_ID:43', etc.
// Control a specific instance from a Playwright test
await page.evaluate(() => openApiMock('GET_PET_BY_ID:42').resolve({ id: 42, name: 'Rex' }));
Without keyDiscriminator, the last-mounted instance overwrites earlier registrations under the same key.
injectMockResource<T>(key)
Must be called inside an injection context (e.g. TestBed.runInInjectionContext) and after the component has rendered — the ref is registered when the component first invokes the factory function, not at DI setup time. Returns MockResourceRef<T>.
ProviderInitialBehavior<T>
Union type accepted by provideMockResource() and generated provide{Operation}Mock() wrappers. Describes the mock's state immediately after each factory invocation:
| Shape | Effect |
|---|---|
{ value: DeepPartial<T> } | Resolves immediately with value |
{ value: DeepPartial<T>, delay: ms } | Loading for ms ms, then resolves |
{ loading: true } | Stays loading indefinitely |
{ error: unknown } | Fails immediately |
{ error: unknown, delay: ms } | Loading for ms ms, then fails |
MockResourceMeta
Metadata embedded in generated .mock.ts files and read by the DevTools panel to show response schemas, generate example payloads, and validate responses:
interface MockResourceMeta {
specId: string; // matches the --specId generator option (default: derived from baseUrlToken)
operationId: string; // OpenAPI operationId
path: string; // API path, e.g. '/pet/findByStatus'
method: string; // HTTP method, lowercase
tag?: string; // OpenAPI tag (omitted for untagged operations)
}
The DevTools panel resolves the response schema by looking up specId in its Specs store and then matching operationId. Import the spec (or its mocks.manifest.json) in the panel's Specs tab to enable schema-aware features.
DeepPartial<T>
Recursively marks all properties of T as optional. Used as the value type in ProviderInitialBehavior<T> so you can provide partial seed data without satisfying every nested field.
TokenValue<Token>
Utility type that extracts the response type T from a generated InjectionToken<(...args) => ResourceRef<T>>. Use it to type seed data without importing the response type by name:
import type { TokenValue } from '@constantant/openapi-resource-mocks';
import { FIND_PETS_BY_STATUS } from '@myapp/petstore-data-access';
// type is FindPetsByStatusResponse — inferred from the token
const mockPets: TokenValue<typeof FIND_PETS_BY_STATUS> = [
{ id: 1, name: 'Rex', status: 'available', photoUrls: [] },
];
createMockResourceRef<T>(initialState?)
Creates a standalone ref without the bus — useful for Storybook decorators, custom test harnesses, or any scenario where you need a MockResourceRef outside of Angular DI.
MockResourceRef<T>
| Member | Description |
|---|---|
value: Signal<T | undefined> | Current response data |
status: Signal<ResourceStatus> | 'idle' | 'loading' | 'reloading' | 'resolved' | 'error' | 'local' |
error: Signal<unknown> | Current error, if any |
isLoading: Signal<boolean> | true while status is 'loading' or 'reloading' |
progress: Signal<MockProgress | undefined> | Current transfer progress, if active |
requestCount: Signal<number> | Total factory invocations (increments on mount, reactive param change, and reload()) |
hasValue(): boolean | true when value is set |
resolve(value: T) | Set value, clear error and progress → 'resolved' |
resolveAfter(ms, value) | Set loading immediately, resolve after delay |
setLoading() | Clear error → 'loading' |
fail(error) | Set error → 'error' (progress preserved) |
reset() | Clear all including progress → 'idle' |
reload() | Keep value, clear error → 'reloading'; fires request event; returns true. Returns false if status is not 'resolved' or 'local' |
setProgress(type, loaded, total?) | Set progress and status → 'loading' |
simulateProgress(type, totalBytes, durationMs, finalValue, steps?) | Animate incremental progress over durationMs ms then resolve |
set(value) | Local mutation → 'local' (ResourceRef interface) |
update(fn) | Local update → 'local' (ResourceRef interface) |
onRequest(cb) | Subscribe to factory invocations; returns unsubscribe fn |
MockProgress
interface MockProgress {
type: 'upload' | 'download';
loaded: number; // bytes transferred
total?: number; // total bytes (undefined for streaming / unknown-length responses)
}
openApiMock(key) / window.__openApiMocks__[key]
openApiMock is a shorthand exposed on window by MockResourceBus — equivalent to window.__openApiMocks__[key] but terser in page.evaluate() calls:
// Playwright
const history = await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').getHistory());
await page.evaluate(() => openApiMock('FIND_PETS_BY_STATUS').resolve([...]));
window.__openApiMocks__[key]
| Member | Description |
|---|---|
resolve(value) | Set value (JSON-serializable) |
resolveAfter(ms, value) | Loading immediately, resolve after delay — emits loading + resolve events |
setLoading() | Start loading state |
fail(error) | Set error state |
reset() | Return to idle |
reload() | Keep value → 'reloading', fire request event; returns true if was resolved/local |
setProgress(type, loaded, total?) | Set transfer progress |
simulateProgress(type, totalBytes, durationMs, finalValue, steps?) | Animate progress then resolve — emits loading, one progress event per step, then resolve |
getState() | { status, value, error, progress, requestCount } snapshot |
getHistory() | Array of all MockEvent entries (requests, responses, progress ticks) |
clearHistory() | Empty the event log — useful for resetting between test scenarios |
onEvent(cb) | Subscribe to all events; returns unsubscribe fn |
MockEvent types
type MockEvent =
| { type: 'request'; args: unknown[]; ts: number } // factory called by component — function args are called and replaced with their return value
| { type: 'resolve'; value: unknown; ts: number }
| { type: 'loading'; ts: number }
| { type: 'error'; error: unknown; ts: number }
| { type: 'reset'; ts: number }
| { type: 'progress'; progressType: 'upload' | 'download'; loaded: number; total?: number; ts: number };
License
MIT