Add MockAlertApi and MockFeatureFlagsApi to frontend-test-utils

Introduces comprehensive mock implementations for AlertApi and FeatureFlagsApi in @backstage/frontend-test-utils, following the 3-pattern approach (function, factory, mock) for consistency with existing test utilities.

The new mocks include useful testing methods:
- MockAlertApi: clearAlerts(), waitForAlert(), getAlerts()
- MockFeatureFlagsApi: getState(), setState(), clearState()

Also adds a mockApis namespace that provides these new mocks and re-exports existing mockApis from @backstage/test-utils for backwards compatibility.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-04 22:42:24 +01:00
parent be7c1d73b2
commit d2ac2ec49d
13 changed files with 929 additions and 3 deletions
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': minor
---
Added `MockAlertApi` and `MockFeatureFlagsApi` implementations to the `mockApis` namespace. The mock implementations include useful testing methods like `clearAlerts()`, `waitForAlert()`, `getState()`, `setState()`, and `clearState()` for better test ergonomics.
@@ -39,12 +39,14 @@
"@backstage/test-utils": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/version-bridge": "workspace:^",
"zen-observable": "^0.10.0",
"zod": "^3.25.76"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@types/react": "^18.0.0",
"@types/zen-observable": "^0.8.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router-dom": "^6.30.2"
+96 -2
View File
@@ -3,6 +3,8 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AlertApi } from '@backstage/frontend-plugin-api';
import { AlertMessage } from '@backstage/frontend-plugin-api';
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
import { ApiHolder } from '@backstage/frontend-plugin-api';
@@ -14,10 +16,14 @@ import { ErrorWithContext } from '@backstage/test-utils';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionDefinitionParameters } from '@backstage/frontend-plugin-api';
import { FeatureFlag } from '@backstage/frontend-plugin-api';
import { FeatureFlagsApi } from '@backstage/frontend-plugin-api';
import { FeatureFlagsSaveOptions } from '@backstage/frontend-plugin-api';
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { mockApis } from '@backstage/test-utils';
import { mockApis as mockApis_2 } from '@backstage/test-utils';
import { MockConfigApi } from '@backstage/test-utils';
import { MockErrorApi } from '@backstage/test-utils';
import { MockErrorApiOptions } from '@backstage/test-utils';
@@ -26,6 +32,7 @@ import { MockFetchApiOptions } from '@backstage/test-utils';
import { MockPermissionApi } from '@backstage/test-utils';
import { MockStorageApi } from '@backstage/test-utils';
import { MockStorageBucket } from '@backstage/test-utils';
import { Observable } from '@backstage/types';
import { ReactNode } from 'react';
import { registerMswTestHooks } from '@backstage/test-utils';
import { RenderResult } from '@testing-library/react';
@@ -100,6 +107,20 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
snapshot(): ExtensionSnapshotNode;
}
// @public
export class MockAlertApi implements AlertApi {
// (undocumented)
alert$(): Observable<AlertMessage>;
clearAlerts(): void;
getAlerts(): AlertMessage[];
// (undocumented)
post(alert: AlertMessage): void;
waitForAlert(
predicate: (alert: AlertMessage) => boolean,
timeoutMs?: number,
): Promise<AlertMessage>;
}
// @public
export class MockAnalyticsApi implements AnalyticsApi {
// (undocumented)
@@ -108,7 +129,59 @@ export class MockAnalyticsApi implements AnalyticsApi {
getEvents(): AnalyticsEvent[];
}
export { mockApis };
// @public
export namespace mockApis {
export function alert(): MockAlertApi;
export namespace alert {
const factory: () => any;
const mock: (
partialImpl?:
| Partial<{
post: jest.Mock<any, any, any>;
alert$: jest.Mock<any, any, any>;
}>
| undefined,
) => ApiMock<{
post: jest.Mock<any, any, any>;
alert$: jest.Mock<any, any, any>;
}>;
}
export function featureFlags(
options?: MockFeatureFlagsApiOptions,
): MockFeatureFlagsApi;
export namespace featureFlags {
const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any;
const mock: (
partialImpl?:
| Partial<{
registerFlag: jest.Mock<any, any, any>;
getRegisteredFlags: jest.Mock<any, any, any>;
isActive: jest.Mock<any, any, any>;
save: jest.Mock<any, any, any>;
}>
| undefined,
) => ApiMock<{
registerFlag: jest.Mock<any, any, any>;
getRegisteredFlags: jest.Mock<any, any, any>;
isActive: jest.Mock<any, any, any>;
save: jest.Mock<any, any, any>;
}>;
}
const // (undocumented)
analytics: typeof mockApis_2.analytics;
const // (undocumented)
config: typeof mockApis_2.config;
const // (undocumented)
discovery: typeof mockApis_2.discovery;
const // (undocumented)
identity: typeof mockApis_2.identity;
const // (undocumented)
permission: typeof mockApis_2.permission;
const // (undocumented)
storage: typeof mockApis_2.storage;
const // (undocumented)
translation: typeof mockApis_2.translation;
}
export { MockConfigApi };
@@ -116,6 +189,27 @@ export { MockErrorApi };
export { MockErrorApiOptions };
// @public
export class MockFeatureFlagsApi implements FeatureFlagsApi {
constructor(options?: MockFeatureFlagsApiOptions);
clearState(): void;
// (undocumented)
getRegisteredFlags(): FeatureFlag[];
getState(): Record<string, FeatureFlagState>;
// (undocumented)
isActive(name: string): boolean;
// (undocumented)
registerFlag(flag: FeatureFlag): void;
// (undocumented)
save(options: FeatureFlagsSaveOptions): void;
setState(states: Record<string, FeatureFlagState>): void;
}
// @public
export interface MockFeatureFlagsApiOptions {
initialStates?: Record<string, FeatureFlagState>;
}
export { MockFetchApi };
export { MockFetchApiOptions };
@@ -0,0 +1,103 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MockAlertApi } from './MockAlertApi';
describe('MockAlertApi', () => {
it('should collect alerts', () => {
const api = new MockAlertApi();
api.post({ message: 'Test alert 1' });
api.post({ message: 'Test alert 2', severity: 'error' });
api.post({
message: 'Test alert 3',
severity: 'warning',
display: 'permanent',
});
expect(api.getAlerts()).toHaveLength(3);
expect(api.getAlerts()[0]).toMatchObject({ message: 'Test alert 1' });
expect(api.getAlerts()[1]).toMatchObject({
message: 'Test alert 2',
severity: 'error',
});
});
it('should clear alerts', () => {
const api = new MockAlertApi();
api.post({ message: 'Test alert' });
expect(api.getAlerts()).toHaveLength(1);
api.clearAlerts();
expect(api.getAlerts()).toHaveLength(0);
});
it('should notify observers', done => {
const api = new MockAlertApi();
const messages: string[] = [];
api.alert$().subscribe({
next: alert => {
messages.push(alert.message);
if (messages.length === 2) {
expect(messages).toEqual(['First', 'Second']);
done();
}
},
});
api.post({ message: 'First' });
api.post({ message: 'Second' });
});
it('should wait for matching alert', async () => {
const api = new MockAlertApi();
setTimeout(() => {
api.post({ message: 'Wrong alert' });
api.post({ message: 'Right alert', severity: 'error' });
}, 10);
const alert = await api.waitForAlert(
a => a.message === 'Right alert',
1000,
);
expect(alert).toMatchObject({ message: 'Right alert', severity: 'error' });
});
it('should timeout if alert never appears', async () => {
const api = new MockAlertApi();
await expect(
api.waitForAlert(a => a.message === 'Never posted', 100),
).rejects.toThrow('Timed out waiting for alert');
});
it('should resolve immediately if alert already exists', async () => {
const api = new MockAlertApi();
api.post({ message: 'Already posted' });
const alert = await api.waitForAlert(
a => a.message === 'Already posted',
1000,
);
expect(alert).toMatchObject({ message: 'Already posted' });
});
});
@@ -0,0 +1,102 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AlertMessage } from '@backstage/frontend-plugin-api';
import { AlertApi } from '@backstage/frontend-plugin-api';
import { Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
/**
* Mock implementation of {@link @backstage/frontend-plugin-api#AlertApi} for testing alert behavior.
*
* @public
* @example
* ```ts
* const alertApi = new MockAlertApi();
* alertApi.post({ message: 'Test alert' });
* expect(alertApi.getAlerts()).toHaveLength(1);
* ```
*/
export class MockAlertApi implements AlertApi {
private alerts: AlertMessage[] = [];
private observers = new Set<(alert: AlertMessage) => void>();
post(alert: AlertMessage) {
this.alerts.push(alert);
this.observers.forEach(observer => observer(alert));
}
alert$(): Observable<AlertMessage> {
return new ObservableImpl(subscriber => {
const observer = (alert: AlertMessage) => {
subscriber.next(alert);
};
this.observers.add(observer);
return () => {
this.observers.delete(observer);
};
});
}
/**
* Get all alerts that have been posted.
*/
getAlerts(): AlertMessage[] {
return this.alerts;
}
/**
* Clear all collected alerts.
*/
clearAlerts(): void {
this.alerts = [];
}
/**
* Wait for an alert matching the given predicate.
*
* @param predicate - Function to test each alert
* @param timeoutMs - Maximum time to wait in milliseconds
* @returns Promise that resolves with the matching alert
*/
async waitForAlert(
predicate: (alert: AlertMessage) => boolean,
timeoutMs: number = 2000,
): Promise<AlertMessage> {
const existing = this.alerts.find(predicate);
if (existing) {
return existing;
}
const observers = this.observers;
return new Promise<AlertMessage>((resolve, reject) => {
const timeoutId = setTimeout(() => {
observers.delete(observer);
reject(new Error('Timed out waiting for alert'));
}, timeoutMs);
function observer(alert: AlertMessage) {
if (predicate(alert)) {
clearTimeout(timeoutId);
observers.delete(observer);
resolve(alert);
}
}
observers.add(observer);
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { MockAlertApi } from './MockAlertApi';
@@ -0,0 +1,143 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
import { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
describe('MockFeatureFlagsApi', () => {
it('should register flags', () => {
const api = new MockFeatureFlagsApi();
api.registerFlag({
name: 'test-flag-1',
pluginId: 'test-plugin',
description: 'Test flag 1',
});
api.registerFlag({
name: 'test-flag-2',
pluginId: 'test-plugin',
description: 'Test flag 2',
});
expect(api.getRegisteredFlags()).toHaveLength(2);
expect(api.getRegisteredFlags()[0].name).toBe('test-flag-1');
});
it('should not register duplicate flags', () => {
const api = new MockFeatureFlagsApi();
api.registerFlag({
name: 'test-flag',
pluginId: 'test-plugin',
description: 'Test flag',
});
api.registerFlag({
name: 'test-flag',
pluginId: 'test-plugin',
description: 'Test flag duplicate',
});
expect(api.getRegisteredFlags()).toHaveLength(1);
});
it('should handle feature flag states', () => {
const api = new MockFeatureFlagsApi();
expect(api.isActive('test-flag')).toBe(false);
api.save({ states: { 'test-flag': FeatureFlagState.Active } });
expect(api.isActive('test-flag')).toBe(true);
api.save({ states: { 'test-flag': FeatureFlagState.None } });
expect(api.isActive('test-flag')).toBe(false);
});
it('should initialize with states', () => {
const api = new MockFeatureFlagsApi({
initialStates: {
'flag-1': FeatureFlagState.Active,
'flag-2': FeatureFlagState.None,
},
});
expect(api.isActive('flag-1')).toBe(true);
expect(api.isActive('flag-2')).toBe(false);
});
it('should save and replace states', () => {
const api = new MockFeatureFlagsApi({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
expect(api.isActive('flag-1')).toBe(true);
api.save({
states: {
'flag-2': FeatureFlagState.Active,
},
});
expect(api.isActive('flag-1')).toBe(false);
expect(api.isActive('flag-2')).toBe(true);
});
it('should save and merge states', () => {
const api = new MockFeatureFlagsApi({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
expect(api.isActive('flag-1')).toBe(true);
api.save({
states: {
'flag-2': FeatureFlagState.Active,
},
merge: true,
});
expect(api.isActive('flag-1')).toBe(true);
expect(api.isActive('flag-2')).toBe(true);
});
it('should get and set state', () => {
const api = new MockFeatureFlagsApi();
api.setState({
'flag-1': FeatureFlagState.Active,
'flag-2': FeatureFlagState.None,
});
const state = api.getState();
expect(state).toEqual({
'flag-1': FeatureFlagState.Active,
'flag-2': FeatureFlagState.None,
});
});
it('should clear state', () => {
const api = new MockFeatureFlagsApi({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
expect(api.isActive('flag-1')).toBe(true);
api.clearState();
expect(api.isActive('flag-1')).toBe(false);
expect(api.getState()).toEqual({});
});
});
@@ -0,0 +1,102 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
FeatureFlag,
FeatureFlagsApi,
FeatureFlagsSaveOptions,
FeatureFlagState,
} from '@backstage/frontend-plugin-api';
/**
* Options for configuring {@link MockFeatureFlagsApi}.
*
* @public
*/
export interface MockFeatureFlagsApiOptions {
/**
* Initial feature flag states.
*/
initialStates?: Record<string, FeatureFlagState>;
}
/**
* Mock implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi} for testing feature flag behavior.
*
* @public
* @example
* ```ts
* const api = new MockFeatureFlagsApi({
* initialStates: { 'my-feature': FeatureFlagState.Active }
* });
* expect(api.isActive('my-feature')).toBe(true);
* ```
*/
export class MockFeatureFlagsApi implements FeatureFlagsApi {
private registeredFlags: FeatureFlag[] = [];
private states: Map<string, FeatureFlagState>;
constructor(options?: MockFeatureFlagsApiOptions) {
this.states = new Map(Object.entries(options?.initialStates ?? {}));
}
registerFlag(flag: FeatureFlag): void {
if (!this.registeredFlags.some(f => f.name === flag.name)) {
this.registeredFlags.push(flag);
}
}
getRegisteredFlags(): FeatureFlag[] {
return this.registeredFlags;
}
isActive(name: string): boolean {
return this.states.get(name) === FeatureFlagState.Active;
}
save(options: FeatureFlagsSaveOptions): void {
if (options.merge) {
for (const [name, state] of Object.entries(options.states)) {
this.states.set(name, state);
}
} else {
this.states = new Map(Object.entries(options.states));
}
}
/**
* Get the current state of all feature flags as a record.
*/
getState(): Record<string, FeatureFlagState> {
return Object.fromEntries(this.states);
}
/**
* Set the state of multiple feature flags.
*/
setState(states: Record<string, FeatureFlagState>): void {
for (const [name, state] of Object.entries(states)) {
this.states.set(name, state);
}
}
/**
* Clear all feature flag states.
*/
clearState(): void {
this.states.clear();
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
export type { MockFeatureFlagsApiOptions } from './MockFeatureFlagsApi';
+11 -1
View File
@@ -24,8 +24,18 @@ export {
MockPermissionApi,
MockStorageApi,
type MockStorageBucket,
mockApis,
type ApiMock,
} from '@backstage/test-utils';
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
export { mockApis } from './mockApis';
/**
* @public
*/
export type { MockAlertApi } from './AlertApi';
/**
* @public
*/
export type { MockFeatureFlagsApi, MockFeatureFlagsApiOptions } from './FeatureFlagsApi';
@@ -0,0 +1,119 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
import { mockApis } from './mockApis';
describe('mockApis', () => {
describe('alert', () => {
it('can create an instance', () => {
const alert = mockApis.alert();
alert.post({ message: 'test alert' });
expect(alert.getAlerts()).toHaveLength(1);
expect(alert.getAlerts()[0]).toMatchObject({ message: 'test alert' });
});
it('can clear alerts', () => {
const alert = mockApis.alert();
alert.post({ message: 'test' });
expect(alert.getAlerts()).toHaveLength(1);
alert.clearAlerts();
expect(alert.getAlerts()).toHaveLength(0);
});
it('can create a mock and make assertions on it', () => {
const alert = mockApis.alert.mock({
post: jest.fn(msg => {
expect(msg).toMatchObject({ message: 'test' });
}),
});
alert.post({ message: 'test' });
expect(alert.post).toHaveBeenCalledTimes(1);
});
});
describe('featureFlags', () => {
it('can create an instance', () => {
const featureFlags = mockApis.featureFlags({
initialStates: { 'test-flag': FeatureFlagState.Active },
});
expect(featureFlags.isActive('test-flag')).toBe(true);
expect(featureFlags.isActive('other-flag')).toBe(false);
});
it('can save and merge state', () => {
const featureFlags = mockApis.featureFlags({
initialStates: { 'flag-1': FeatureFlagState.Active },
});
featureFlags.save({
states: { 'flag-2': FeatureFlagState.Active },
merge: true,
});
expect(featureFlags.isActive('flag-1')).toBe(true);
expect(featureFlags.isActive('flag-2')).toBe(true);
});
it('can set and clear state using helper methods', () => {
const featureFlags = mockApis.featureFlags();
featureFlags.setState({ 'test-flag': FeatureFlagState.Active });
expect(featureFlags.getState()).toEqual({
'test-flag': FeatureFlagState.Active,
});
featureFlags.clearState();
expect(featureFlags.getState()).toEqual({});
});
it('can create a mock and make assertions on it', () => {
const featureFlags = mockApis.featureFlags.mock({
isActive: jest.fn(() => true),
});
expect(featureFlags.isActive('test')).toBe(true);
expect(featureFlags.isActive).toHaveBeenCalledTimes(1);
});
});
describe('re-exported APIs from test-utils', () => {
it('should have analytics', () => {
expect(mockApis.analytics).toBeDefined();
});
it('should have config', () => {
expect(mockApis.config).toBeDefined();
});
it('should have discovery', () => {
expect(mockApis.discovery).toBeDefined();
});
it('should have identity', () => {
expect(mockApis.identity).toBeDefined();
});
it('should have permission', () => {
expect(mockApis.permission).toBeDefined();
});
it('should have storage', () => {
expect(mockApis.storage).toBeDefined();
});
it('should have translation', () => {
expect(mockApis.translation).toBeDefined();
});
});
});
@@ -0,0 +1,209 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
alertApiRef,
createApiFactory,
featureFlagsApiRef,
} from '@backstage/frontend-plugin-api';
import {
mockApis as testUtilsMockApis,
type ApiMock,
} from '@backstage/test-utils';
import { MockAlertApi } from './AlertApi';
import {
MockFeatureFlagsApi,
MockFeatureFlagsApiOptions,
} from './FeatureFlagsApi';
/** @internal */
function simpleMock<TApi>(
ref: any,
mockFactory: () => jest.Mocked<TApi>,
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
return partialImpl => {
const mock = mockFactory();
if (partialImpl) {
for (const [key, impl] of Object.entries(partialImpl)) {
if (typeof impl === 'function') {
(mock as any)[key].mockImplementation(impl);
} else {
(mock as any)[key] = impl;
}
}
}
return Object.assign(mock, {
factory: createApiFactory({
api: ref,
deps: {},
factory: () => mock,
}),
}) as ApiMock<TApi>;
};
}
/** @internal */
function simpleFactory<TApi, TArgs extends unknown[]>(
ref: any,
factory: (...args: TArgs) => TApi,
): (...args: TArgs) => any {
return (...args) =>
createApiFactory({
api: ref,
deps: {},
factory: () => factory(...args),
});
}
/**
* Mock implementations of the core utility APIs, to be used in tests.
*
* @public
* @remarks
*
* There are some variations among the APIs depending on what needs tests
* might have, but overall there are three main usage patterns:
*
* 1: Creating an actual fake API instance, often with a simplified version
* of functionality, by calling the mock API itself as a function.
*
* ```ts
* // The function often accepts parameters that control its behavior
* const foo = mockApis.foo();
* ```
*
* 2: Creating a mock API, where all methods are replaced with jest mocks, by
* calling the API's `mock` function.
*
* ```ts
* // You can optionally supply a subset of its methods to implement
* const foo = mockApis.foo.mock({
* someMethod: () => 'mocked result',
* });
* // After exercising your test, you can make assertions on the mock:
* expect(foo.someMethod).toHaveBeenCalledTimes(2);
* expect(foo.otherMethod).toHaveBeenCalledWith(testData);
* ```
*
* 3: Creating an API factory that behaves similarly to the mock as per above.
*
* ```ts
* const factory = mockApis.foo.factory({
* someMethod: () => 'mocked result',
* });
* ```
*/
export namespace mockApis {
/**
* Fake implementation of {@link @backstage/frontend-plugin-api#AlertApi}.
*
* @public
* @example
*
* ```tsx
* const alertApi = mockApis.alert();
* alertApi.post({ message: 'Test alert' });
* expect(alertApi.getAlerts()).toHaveLength(1);
* ```
*/
export function alert(): MockAlertApi {
return new MockAlertApi();
}
/**
* Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
*
* @see {@link @backstage/frontend-plugin-api#mockApis.alert}
* @public
*/
export namespace alert {
/**
* Creates a factory for a fake implementation of
* {@link @backstage/frontend-plugin-api#AlertApi}.
*
* @public
*/
export const factory = simpleFactory(alertApiRef, alert);
/**
* Creates a mock implementation of
* {@link @backstage/frontend-plugin-api#AlertApi}. All methods are
* replaced with jest mock functions, and you can optionally pass in a
* subset of methods with an explicit implementation.
*
* @public
*/
export const mock = simpleMock(alertApiRef, () => ({
post: jest.fn(),
alert$: jest.fn(),
}));
}
/**
* Fake implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
*
* @public
* @example
*
* ```tsx
* const featureFlagsApi = mockApis.featureFlags({
* initialStates: { 'my-feature': FeatureFlagState.Active },
* });
* expect(featureFlagsApi.isActive('my-feature')).toBe(true);
* ```
*/
export function featureFlags(
options?: MockFeatureFlagsApiOptions,
): MockFeatureFlagsApi {
return new MockFeatureFlagsApi(options);
}
/**
* Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
*
* @see {@link @backstage/frontend-plugin-api#mockApis.featureFlags}
* @public
*/
export namespace featureFlags {
/**
* Creates a factory for a fake implementation of
* {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
*
* @public
*/
export const factory = simpleFactory(featureFlagsApiRef, featureFlags);
/**
* Creates a mock implementation of
* {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. All methods are
* replaced with jest mock functions, and you can optionally pass in a
* subset of methods with an explicit implementation.
*
* @public
*/
export const mock = simpleMock(featureFlagsApiRef, () => ({
registerFlag: jest.fn(),
getRegisteredFlags: jest.fn(),
isActive: jest.fn(),
save: jest.fn(),
}));
}
// Re-export all mockApis from test-utils
export const analytics = testUtilsMockApis.analytics;
export const config = testUtilsMockApis.config;
export const discovery = testUtilsMockApis.discovery;
export const identity = testUtilsMockApis.identity;
export const permission = testUtilsMockApis.permission;
export const storage = testUtilsMockApis.storage;
export const translation = testUtilsMockApis.translation;
}
+2
View File
@@ -3961,9 +3961,11 @@ __metadata:
"@backstage/version-bridge": "workspace:^"
"@testing-library/jest-dom": "npm:^6.0.0"
"@types/react": "npm:^18.0.0"
"@types/zen-observable": "npm:^0.8.0"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.30.2"
zen-observable: "npm:^0.10.0"
zod: "npm:^3.25.76"
peerDependencies:
"@testing-library/react": ^16.0.0