From a20cbb2514db29545e9b9f03f004423f2e0a5e28 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 21:41:41 +0100 Subject: [PATCH] frontend-test-utils: new utility for passing mock APIs directly Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/report.api.md | 40 ++++++-- .../frontend-test-utils/src/apis/ApiMock.ts | 34 +++++++ .../frontend-test-utils/src/apis/index.ts | 12 ++- .../frontend-test-utils/src/apis/mockApis.ts | 43 +++++--- .../frontend-test-utils/src/apis/utils.ts | 97 +++++++++++++++++++ .../src/utils/TestApiProvider.tsx | 68 +++++++++++-- .../frontend-test-utils/src/utils/index.ts | 1 + .../SelectedKindsFilter.test.tsx | 11 +-- .../EntitySwitch/EntitySwitch.test.tsx | 9 +- 9 files changed, 269 insertions(+), 46 deletions(-) create mode 100644 packages/frontend-test-utils/src/apis/ApiMock.ts create mode 100644 packages/frontend-test-utils/src/apis/utils.ts diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 4621f84387..7c5f0fcd87 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : 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; export namespace alert { const factory: () => any; const mock: ( @@ -148,7 +162,7 @@ export namespace mockApis { } export function featureFlags( options?: MockFeatureFlagsApiOptions, - ): MockFeatureFlagsApi; + ): MockWithApiFactory; export namespace featureFlags { const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any; const mock: ( @@ -220,6 +234,11 @@ export { MockStorageApi }; export { MockStorageBucket }; +// @public +export type MockWithApiFactory = TApi & { + [mockApiFactorySymbol]: ApiFactory; +}; + export { registerMswTestHooks }; // @public @@ -253,9 +272,16 @@ export const TestApiProvider: ( props: TestApiProviderProps, ) => JSX_2.Element; +// @public +export type TestApiProviderEntry = + | readonly [ApiRef, any] + | MockWithApiFactory; + // @public export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; + apis: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; children: ReactNode; }; @@ -271,9 +297,7 @@ export type TestApiProviderPropsApiPairs = { // @public export class TestApiRegistry implements ApiHolder { - static from( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ): TestApiRegistry; + static from(...apis: readonly TestApiProviderEntry[]): TestApiRegistry; get(api: ApiRef): T | undefined; } diff --git a/packages/frontend-test-utils/src/apis/ApiMock.ts b/packages/frontend-test-utils/src/apis/ApiMock.ts new file mode 100644 index 0000000000..b5ee6f242b --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ApiMock.ts @@ -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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 09996209a6..4ac50392a1 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 1cc965c0b8..92f1981d2f 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -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( @@ -45,13 +48,15 @@ function simpleMock( } } } - return Object.assign(mock, { - factory: createApiFactory({ - api: ref, - deps: {}, - factory: () => mock, - }), - }) as ApiMock; + const factory = createApiFactory({ + api: ref, + deps: {}, + factory: () => mock, + }); + const apiMock = Object.assign(mock, { factory }) as ApiMock; + // 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 { + const instance = new MockAlertApi(); + return mockWithApiFactory( + alertApiRef, + instance, + ) as MockWithApiFactory; } /** * 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 { + const instance = new MockFeatureFlagsApi(options); + return mockWithApiFactory( + featureFlagsApiRef, + instance, + ) as MockWithApiFactory; } /** * Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. diff --git a/packages/frontend-test-utils/src/apis/utils.ts b/packages/frontend-test-utils/src/apis/utils.ts new file mode 100644 index 0000000000..aca3cc67a1 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/utils.ts @@ -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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; + +/** + * Type for an API instance that has been marked as a mock API. + * + * @public + */ +export type MockWithApiFactory = TApi & { + [mockApiFactorySymbol]: ApiFactory; +}; + +/** + * Helper to attach mock API metadata to an instance. + * + * @internal + */ +export function mockWithApiFactory( + apiRef: ApiRef, + implementation: TApi, +): MockWithApiFactory { + const marked = implementation as MockWithApiFactory; + (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 | undefined { + if ( + typeof value === 'object' && + value !== null && + mockApiFactorySymbol in value && + typeof (value as any)[mockApiFactorySymbol] === 'object' + ) { + return (value as any)[mockApiFactorySymbol]; + } + return undefined; +} diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx index 97529033d0..3022f96f9c 100644 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx @@ -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 = { */ export type TestApiPairs = TestApiProviderPropsApiPairs; +/** + * 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] + | MockWithApiFactory; + /** * Properties for the {@link TestApiProvider} component. * * @public */ export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; + apis: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; 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( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ) { - return new TestApiRegistry( - new Map(apis.map(([api, impl]) => [api.id, impl])), - ); + static from(...apis: readonly TestApiProviderEntry[]) { + const apiMap = new Map(); + + 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) {} @@ -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( * * * ); + * + * // Direct mock API instances (no tuples needed) + * render( + * + * + * + * ); * ``` * * @public diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/utils/index.ts index 2f6bfb5f9c..32724fabba 100644 --- a/packages/frontend-test-utils/src/utils/index.ts +++ b/packages/frontend-test-utils/src/utils/index.ts @@ -20,5 +20,6 @@ export { type TestApiProviderPropsApiPair, type TestApiProviderPropsApiPairs, type TestApiPairs, + type TestApiProviderEntry, } from './TestApiProvider'; export type { TestApiProviderProps } from './TestApiProvider'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 74c596b197..d362c9c7d9 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -15,15 +15,14 @@ */ import { ApiProvider } from '@backstage/core-app-api'; -import { alertApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { errorApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; -import { mockApis } from '@backstage/frontend-test-utils'; +import { renderWithEffects } from '@backstage/test-utils'; +import { mockApis, TestApiRegistry } from '@backstage/frontend-test-utils'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SelectedKindsFilter } from './SelectedKindsFilter'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; -import { translationApiRef } from '@backstage/core-plugin-api/alpha'; const catalogApi = catalogApiMock.mock({ getEntityFacets: jest.fn().mockResolvedValue({ @@ -38,9 +37,9 @@ const catalogApi = catalogApiMock.mock({ }), }); const apis = TestApiRegistry.from( + mockApis.alert(), + mockApis.translation(), [catalogApiRef, catalogApi], - [alertApiRef, mockApis.alert()], - [translationApiRef, mockApis.translation()], [errorApiRef, { post: jest.fn() }], ); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 6d5df6fb10..cb22475be6 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -23,15 +23,12 @@ import { render, screen } from '@testing-library/react'; import { ReactNode, useEffect } from 'react'; import { isKind } from './conditions'; import { EntitySwitch } from './EntitySwitch'; -import { featureFlagsApiRef } from '@backstage/core-plugin-api'; -import { TestApiProvider } from '@backstage/test-utils'; -import { mockApis } from '@backstage/frontend-test-utils'; +import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils'; const mockFeatureFlagsApi = mockApis.featureFlags.mock(); + const Wrapper = ({ children }: { children?: ReactNode }) => ( - - {children} - + {children} ); describe('EntitySwitch', () => {