frontend-test-utils: new utility for passing mock APIs directly

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-05 21:41:41 +01:00
parent 36fb574fff
commit a20cbb2514
9 changed files with 269 additions and 46 deletions
+32 -8
View File
@@ -7,8 +7,8 @@ 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 { ApiFactory } from '@backstage/frontend-plugin-api';
import { ApiHolder } from '@backstage/frontend-plugin-api';
import { ApiMock } from '@backstage/test-utils';
import { ApiRef } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppNodeInstance } from '@backstage/frontend-plugin-api';
@@ -40,7 +40,15 @@ import { RouteRef } from '@backstage/frontend-plugin-api';
import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries';
import { withLogCollector } from '@backstage/test-utils';
export { ApiMock };
// @public
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
// @public (undocumented)
export function createExtensionTester<
@@ -129,9 +137,15 @@ export class MockAnalyticsApi implements AnalyticsApi {
getEvents(): AnalyticsEvent[];
}
// @public
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
// @public
export const mockApiFactorySymbol: unique symbol;
// @public
export namespace mockApis {
export function alert(): MockAlertApi;
export function alert(): MockWithApiFactory<MockAlertApi>;
export namespace alert {
const factory: () => any;
const mock: (
@@ -148,7 +162,7 @@ export namespace mockApis {
}
export function featureFlags(
options?: MockFeatureFlagsApiOptions,
): MockFeatureFlagsApi;
): MockWithApiFactory<MockFeatureFlagsApi>;
export namespace featureFlags {
const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any;
const mock: (
@@ -220,6 +234,11 @@ export { MockStorageApi };
export { MockStorageBucket };
// @public
export type MockWithApiFactory<TApi> = TApi & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
export { registerMswTestHooks };
// @public
@@ -253,9 +272,16 @@ export const TestApiProvider: <T extends any[]>(
props: TestApiProviderProps<T>,
) => JSX_2.Element;
// @public
export type TestApiProviderEntry =
| readonly [ApiRef<any>, any]
| MockWithApiFactory<any>;
// @public
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
apis: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
children: ReactNode;
};
@@ -271,9 +297,7 @@ export type TestApiProviderPropsApiPairs<TApiPairs> = {
// @public
export class TestApiRegistry implements ApiHolder {
static from<TApiPairs extends any[]>(
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
): TestApiRegistry;
static from(...apis: readonly TestApiProviderEntry[]): TestApiRegistry;
get<T>(api: ApiRef<T>): T | undefined;
}
@@ -0,0 +1,34 @@
/*
* Copyright 2024 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 { ApiFactory } from '@backstage/frontend-plugin-api';
import { mockApiFactorySymbol } from './utils';
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
+10 -2
View File
@@ -24,11 +24,16 @@ export {
MockPermissionApi,
MockStorageApi,
type MockStorageBucket,
type ApiMock,
} from '@backstage/test-utils';
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
export { mockApis } from './mockApis';
export {
type MockApiFactorySymbol,
type ApiMock,
type MockWithApiFactory,
mockApiFactorySymbol,
} from './utils';
/**
* @public
@@ -38,4 +43,7 @@ export type { MockAlertApi } from './AlertApi';
/**
* @public
*/
export type { MockFeatureFlagsApi, MockFeatureFlagsApiOptions } from './FeatureFlagsApi';
export type {
MockFeatureFlagsApi,
MockFeatureFlagsApiOptions,
} from './FeatureFlagsApi';
@@ -19,15 +19,18 @@ import {
createApiFactory,
featureFlagsApiRef,
} from '@backstage/frontend-plugin-api';
import {
mockApis as testUtilsMockApis,
type ApiMock,
} from '@backstage/test-utils';
import { mockApis as testUtilsMockApis } from '@backstage/test-utils';
import { MockAlertApi } from './AlertApi';
import {
MockFeatureFlagsApi,
MockFeatureFlagsApiOptions,
} from './FeatureFlagsApi';
import {
ApiMock,
mockWithApiFactory,
mockApiFactorySymbol,
type MockWithApiFactory,
} from './utils';
/** @internal */
function simpleMock<TApi>(
@@ -45,13 +48,15 @@ function simpleMock<TApi>(
}
}
}
return Object.assign(mock, {
factory: createApiFactory({
api: ref,
deps: {},
factory: () => mock,
}),
}) as ApiMock<TApi>;
const factory = createApiFactory({
api: ref,
deps: {},
factory: () => mock,
});
const apiMock = Object.assign(mock, { factory }) as ApiMock<TApi>;
// Set the mock API symbol to the same factory
(apiMock as any)[mockApiFactorySymbol] = factory;
return apiMock;
};
}
@@ -119,8 +124,12 @@ export namespace mockApis {
* expect(alertApi.getAlerts()).toHaveLength(1);
* ```
*/
export function alert(): MockAlertApi {
return new MockAlertApi();
export function alert(): MockWithApiFactory<MockAlertApi> {
const instance = new MockAlertApi();
return mockWithApiFactory(
alertApiRef,
instance,
) as MockWithApiFactory<MockAlertApi>;
}
/**
* Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
@@ -165,8 +174,12 @@ export namespace mockApis {
*/
export function featureFlags(
options?: MockFeatureFlagsApiOptions,
): MockFeatureFlagsApi {
return new MockFeatureFlagsApi(options);
): MockWithApiFactory<MockFeatureFlagsApi> {
const instance = new MockFeatureFlagsApi(options);
return mockWithApiFactory(
featureFlagsApiRef,
instance,
) as MockWithApiFactory<MockFeatureFlagsApi>;
}
/**
* Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
@@ -0,0 +1,97 @@
/*
* 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 { ApiRef } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
/**
* Symbol used to mark mock API instances with their corresponding API factory.
*
* @public
*/
export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api');
/**
* Symbol used to mark mock API instances with their corresponding API factory.
* This allows mock APIs to be passed directly to test utilities without
* needing to explicitly provide the [apiRef, implementation] tuple.
*
* @public
*/
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
/**
* Type for an API instance that has been marked as a mock API.
*
* @public
*/
export type MockWithApiFactory<TApi> = TApi & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
/**
* Helper to attach mock API metadata to an instance.
*
* @internal
*/
export function mockWithApiFactory<TApi>(
apiRef: ApiRef<TApi>,
implementation: TApi,
): MockWithApiFactory<TApi> {
const marked = implementation as MockWithApiFactory<TApi>;
(marked as any)[mockApiFactorySymbol] = {
api: apiRef,
deps: {},
factory: () => implementation,
};
return marked;
}
/**
* Retrieves the API factory from a mock API instance.
* Returns undefined if the value is not a mock API instance.
*
* @internal
*/
export function getMockApiFactory(
value: unknown,
): ApiFactory<any, any, {}> | undefined {
if (
typeof value === 'object' &&
value !== null &&
mockApiFactorySymbol in value &&
typeof (value as any)[mockApiFactorySymbol] === 'object'
) {
return (value as any)[mockApiFactorySymbol];
}
return undefined;
}
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiProvider } from '../../../core-app-api/src/apis/system';
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils';
/**
* Helper type for representing an API reference paired with a partial implementation.
@@ -41,13 +42,26 @@ export type TestApiProviderPropsApiPairs<TApiPairs> = {
*/
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
/**
* Type for entries that can be passed to TestApiProvider/TestApiRegistry.
* Can be either a traditional [apiRef, implementation] tuple or a mock API instance
* marked with the MockApiSymbol.
*
* @public
*/
export type TestApiProviderEntry =
| readonly [ApiRef<any>, any]
| MockWithApiFactory<any>;
/**
* Properties for the {@link TestApiProvider} component.
*
* @public
*/
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
apis: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
children: ReactNode;
};
@@ -75,20 +89,43 @@ export class TestApiRegistry implements ApiHolder {
* import { identityApiRef } from '@backstage/frontend-plugin-api';
* import { mockApis } from '@backstage/frontend-test-utils';
*
* const apis = TestApiRegistry.from(
* // Traditional tuple syntax
* const apis1 = TestApiRegistry.from(
* [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })],
* );
*
* // Direct mock API instance (no tuple needed)
* const apis2 = TestApiRegistry.from(
* mockApis.identity({ userEntityRef: 'user:default/guest' }),
* mockApis.alert(),
* );
* ```
*
* @public
* @param apis - A list of pairs mapping an ApiRef to its respective implementation.
* @param apis - A list of pairs mapping an ApiRef to its respective implementation,
* or mock API instances marked with the mockApiSymbol.
*/
static from<TApiPairs extends any[]>(
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
) {
return new TestApiRegistry(
new Map(apis.map(([api, impl]) => [api.id, impl])),
);
static from(...apis: readonly TestApiProviderEntry[]) {
const apiMap = new Map<string, unknown>();
for (const entry of apis) {
const mockFactory = getMockApiFactory(entry);
if (mockFactory) {
// Handle mock API instances marked with the symbol
const impl = mockFactory.factory({});
apiMap.set(mockFactory.api.id, impl);
} else if (Array.isArray(entry)) {
// Handle traditional [apiRef, impl] tuples
const [apiRef, impl] = entry;
apiMap.set(apiRef.id, impl);
} else {
throw new Error(
`Invalid API entry provided to TestApiRegistry.from(). Expected either [apiRef, impl] tuple or a mock API instance.`,
);
}
}
return new TestApiRegistry(apiMap);
}
private constructor(private readonly apis: Map<string, unknown>) {}
@@ -121,6 +158,7 @@ export class TestApiRegistry implements ApiHolder {
* import { identityApiRef } from '\@backstage/frontend-plugin-api';
* import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils';
*
* // Traditional tuple syntax
* render(
* <TestApiProvider
* apis={[[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]]}
@@ -128,6 +166,18 @@ export class TestApiRegistry implements ApiHolder {
* <MyComponent />
* </TestApiProvider>
* );
*
* // Direct mock API instances (no tuples needed)
* render(
* <TestApiProvider
* apis={[
* mockApis.identity({ userEntityRef: 'user:default/guest' }),
* mockApis.alert(),
* ]}
* >
* <MyComponent />
* </TestApiProvider>
* );
* ```
*
* @public
@@ -20,5 +20,6 @@ export {
type TestApiProviderPropsApiPair,
type TestApiProviderPropsApiPairs,
type TestApiPairs,
type TestApiProviderEntry,
} from './TestApiProvider';
export type { TestApiProviderProps } from './TestApiProvider';