From 22864b75a92680a191c864efa670bd7c0f5b0a12 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 31 Jan 2026 15:10:34 +0100 Subject: [PATCH] feat(frontend-test-utils): add API override support to test utilities Added support for API overrides in `createExtensionTester` and `renderInTestApp` to allow tests to override specific APIs without requiring wrapper components. This provides app-level API overrides that are available throughout the entire extension tree. The `apis` option follows the same typing pattern as `TestApiProvider` from `@backstage/test-utils` for consistency and type safety. Example usage: ```typescript const tester = createExtensionTester(MyExtension, { apis: [ [errorApiRef, mockErrorApi], [analyticsApiRef, mockAnalyticsApi], ], }); renderInTestApp(, { apis: [ [errorApiRef, mockErrorApi], [analyticsApiRef, mockAnalyticsApi], ], }); ``` This enables cleaner tests with app-level API overrides, eliminating the need to wrap components with TestApiProvider in many cases. Signed-off-by: Patrik Oldsberg --- .changeset/api-override-test-utils.md | 22 +++ .../building-plugins/02-testing.md | 63 +++++++- packages/frontend-test-utils/report.api.md | 56 +++++-- .../src/app/createExtensionTester.test.tsx | 36 +++++ .../src/app/createExtensionTester.tsx | 35 +++- .../src/app/renderInTestApp.test.tsx | 31 ++++ .../src/app/renderInTestApp.tsx | 44 +++++- packages/frontend-test-utils/src/index.ts | 5 +- .../src/utils/TestApiProvider.tsx | 149 ++++++++++++++++++ .../frontend-test-utils/src/utils/index.ts | 24 +++ 10 files changed, 440 insertions(+), 25 deletions(-) create mode 100644 .changeset/api-override-test-utils.md create mode 100644 packages/frontend-test-utils/src/utils/TestApiProvider.tsx create mode 100644 packages/frontend-test-utils/src/utils/index.ts diff --git a/.changeset/api-override-test-utils.md b/.changeset/api-override-test-utils.md new file mode 100644 index 0000000000..a250dc7d15 --- /dev/null +++ b/.changeset/api-override-test-utils.md @@ -0,0 +1,22 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added support for API overrides in `createExtensionTester` and `renderInTestApp`. You can now pass an `apis` option to override specific APIs when testing extensions: + +```typescript +// Override APIs in createExtensionTester +const tester = createExtensionTester(myExtension, { + apis: [ + [errorApiRef, mockErrorApi], + [analyticsApiRef, mockAnalyticsApi], + ], +}); + +// Override APIs in renderInTestApp +renderInTestApp(, { + apis: [[errorApiRef, mockErrorApi]], +}); +``` + +The package now also exports its own implementations of `TestApiProvider`, `TestApiRegistry`, and related types, rather than re-exporting them from `@backstage/test-utils`. This consolidates common types used internally by the test utilities. diff --git a/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md index ac90185b40..adb0e0a45e 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -76,6 +76,45 @@ describe('Entity details component', () => { This pattern also works for many other context providers. An important example is the `EntityProvider` from the `@backstage/plugin-catalog-react` package, which you can use to provide a mocked entity context to the component. +Alternatively, you can pass API overrides directly to `renderInTestApp` using the `apis` option: + +```tsx +import { screen } from '@testing-library/react'; +import { renderInTestApp } from '@backstage/frontend-test-utils'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { EntityDetails } from './plugin'; + +describe('Entity details component', () => { + it('should render the entity name and owner', async () => { + const catalogApiMock = { + async getEntityFacets() { + return { + facets: { + 'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }], + }, + }, + } + } satisfies Partial; + + const entityRef = stringifyEntityRef({ + kind: 'Component', + namespace: 'default', + name: 'test', + }); + + await renderInTestApp(, { + apis: [[catalogApiRef, catalogApiMock]], + }); + + await expect( + screen.findByText('The entity "test" is owned by "tools"'), + ).resolves.toBeInTheDocument(); + }); +}); +``` + +This approach provides the API overrides at the app level, which is useful when testing extensions that depend on APIs deep in the component tree. + ## Testing extensions To facilitate testing of frontend extensions, the `@backstage/frontend-test-utils` package provides a tester class which starts up an entire frontend harness, complete with a number of default features. You can then provide overrides for extensions whose behavior you need to adjust for the test run. @@ -102,7 +141,29 @@ describe('Index page', () => { }); ``` -This pattern also allows you to wrap the extension with context providers, such as the `TestApiProvider` that was introduced [above](#testing-react-components). +This pattern also allows you to wrap the extension with context providers, such as the `TestApiProvider` that was introduced [above](#testing-react-components). Alternatively, you can provide API overrides directly to `createExtensionTester`: + +```tsx +import { screen } from '@testing-library/react'; +import { createExtensionTester } from '@backstage/frontend-test-utils'; +import { analyticsApiRef } from '@backstage/frontend-plugin-api'; +import { indexPageExtension } from './plugin'; + +describe('Index page', () => { + it('should render and track analytics', async () => { + const analyticsApiMock = { captureEvent: jest.fn() }; + + await renderInTestApp( + createExtensionTester(indexPageExtension, { + apis: [[analyticsApiRef, analyticsApiMock]], + }).reactElement(), + ); + + expect(screen.getByText('Index Page')).toBeInTheDocument(); + expect(analyticsApiMock.captureEvent).toHaveBeenCalled(); + }); +}); +``` Note that the `.reactElement()` method will look for the `coreExtensionData.reactElement` data in the extension outputs. If that doesn't exist and the extension outputs something else that you want to test, you can access the output data using the `.get(dataRef)` method instead. diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index b99cf843d4..b1cee9ba1a 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -5,7 +5,9 @@ ```ts import { AnalyticsApi } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } 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'; import { ErrorWithContext } from '@backstage/test-utils'; @@ -14,6 +16,7 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionDefinitionParameters } 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 { MockConfigApi } from '@backstage/test-utils'; import { MockErrorApi } from '@backstage/test-utils'; @@ -23,22 +26,24 @@ 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 { ReactNode } from 'react'; import { registerMswTestHooks } from '@backstage/test-utils'; import { RenderResult } from '@testing-library/react'; import { RouteRef } from '@backstage/frontend-plugin-api'; -import { TestApiProvider } from '@backstage/test-utils'; -import { TestApiProviderProps } from '@backstage/test-utils'; -import { TestApiRegistry } from '@backstage/test-utils'; import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries'; import { withLogCollector } from '@backstage/test-utils'; export { ApiMock }; // @public (undocumented) -export function createExtensionTester( +export function createExtensionTester< + T extends ExtensionDefinitionParameters, + TApiPairs extends any[] = any[], +>( subject: ExtensionDefinition, options?: { config?: T['configInput']; + apis?: readonly [...TestApiPairs]; }, ): ExtensionTester>; @@ -115,9 +120,9 @@ export { MockStorageBucket }; export { registerMswTestHooks }; // @public -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, - options?: TestAppOptions, + options?: TestAppOptions, ): RenderResult; // @public @@ -133,20 +138,47 @@ export type RenderTestAppOptions = { initialRouteEntries?: string[]; }; -export { TestApiProvider }; - -export { TestApiProviderProps }; - -export { TestApiRegistry }; +// @public +export type TestApiPairs = TestApiProviderPropsApiPairs; // @public -export type TestAppOptions = { +export const TestApiProvider: ( + props: TestApiProviderProps, +) => JSX_2.Element; + +// @public +export type TestApiProviderProps = { + apis: readonly [...TestApiProviderPropsApiPairs]; + children: ReactNode; +}; + +// @public +export type TestApiProviderPropsApiPair = TApi extends infer TImpl + ? readonly [ApiRef, Partial] + : never; + +// @public +export type TestApiProviderPropsApiPairs = { + [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; +}; + +// @public +export class TestApiRegistry implements ApiHolder { + static from( + ...apis: readonly [...TestApiProviderPropsApiPairs] + ): TestApiRegistry; + get(api: ApiRef): T | undefined; +} + +// @public +export type TestAppOptions = { mountedRoutes?: { [path: string]: RouteRef; }; config?: JsonObject; features?: FrontendFeature[]; initialRouteEntries?: string[]; + apis?: readonly [...TestApiPairs]; }; export { withLogCollector }; diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index f412f29b65..e57a7b5058 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -15,12 +15,16 @@ */ import { + analyticsApiRef, coreExtensionData, createExtension, createExtensionDataRef, createExtensionInput, + useAnalytics, } from '@backstage/frontend-plugin-api'; import { createExtensionTester } from './createExtensionTester'; +import { screen } from '@testing-library/react'; +import { renderInTestApp } from './renderInTestApp'; const stringDataRef = createExtensionDataRef().with({ id: 'test.string', @@ -152,4 +156,36 @@ describe('createExtensionTester', () => { expect([test, test2, test3]).toBeDefined(); }); + + it('should support API overrides via options', async () => { + const analyticsApiMock = { captureEvent: jest.fn() }; + + const TestComponent = () => { + const analytics = useAnalytics(); + analytics.captureEvent('test', 'value'); + return
Test
; + }; + + const extension = createExtension({ + attachTo: { id: 'ignored', input: 'ignored' }, + output: [coreExtensionData.reactElement], + factory: () => [coreExtensionData.reactElement()], + }); + + const tester = createExtensionTester(extension, { + apis: [[analyticsApiRef, analyticsApiMock]], + }); + + renderInTestApp(tester.reactElement(), { + apis: [[analyticsApiRef, analyticsApiMock]], + }); + + expect(screen.getByText('Test')).toBeInTheDocument(); + expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'test', + subject: 'value', + }), + ); + }); }); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 50d65fddc7..1076b82d2d 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -37,8 +37,8 @@ import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/insta import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector'; -import { TestApiRegistry } from '@backstage/test-utils'; import { OpaqueExtensionDefinition } from '@internal/frontend'; +import { TestApiRegistry, type TestApiPairs } from '../utils'; /** @public */ export class ExtensionQuery { @@ -78,16 +78,23 @@ export class ExtensionQuery { /** @public */ export class ExtensionTester { /** @internal */ - static forSubject( + static forSubject< + T extends ExtensionDefinitionParameters, + TApiPairs extends any[], + >( subject: ExtensionDefinition, - options?: { config?: T['configInput'] }, + options?: { + config?: T['configInput']; + apis?: readonly [...TestApiPairs]; + }, ): ExtensionTester> { - const tester = new ExtensionTester(); + const tester = new ExtensionTester(options?.apis); tester.add(subject, options as T['configInput'] & {}); return tester; } #tree?: AppTree; + #apis?: readonly any[]; readonly #extensions = new Array<{ id: string; @@ -96,6 +103,10 @@ export class ExtensionTester { config?: JsonValue; }>(); + private constructor(apis?: readonly any[]) { + this.#apis = apis; + } + add( extension: ExtensionDefinition, options?: { config?: T['configInput'] }, @@ -206,7 +217,11 @@ export class ExtensionTester { collector, ); - instantiateAppNodeTree(tree.root, TestApiRegistry.from(), collector); + const apiHolder = this.#apis + ? TestApiRegistry.from(...this.#apis) + : TestApiRegistry.from(); + + instantiateAppNodeTree(tree.root, apiHolder, collector); const errors = collector.collectErrors(); if (errors) { @@ -260,9 +275,15 @@ export class ExtensionTester { } /** @public */ -export function createExtensionTester( +export function createExtensionTester< + T extends ExtensionDefinitionParameters, + TApiPairs extends any[] = any[], +>( subject: ExtensionDefinition, - options?: { config?: T['configInput'] }, + options?: { + config?: T['configInput']; + apis?: readonly [...TestApiPairs]; + }, ): ExtensionTester> { return ExtensionTester.forSubject(subject, options); } diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx index 8e1b1e1ea0..334722f38a 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx @@ -80,4 +80,35 @@ describe('renderInTestApp', () => { expect(screen.getByText('Second Page')).toBeInTheDocument(); }); + + it('should support API overrides via options', async () => { + const IndexPage = () => { + const analyticsApi = useAnalytics(); + const handleClick = useCallback(() => { + analyticsApi.captureEvent('click', 'Test action'); + }, [analyticsApi]); + return ( +
+ +
+ ); + }; + + const analyticsApiMock = new MockAnalyticsApi(); + + renderInTestApp(, { + apis: [[analyticsApiRef, analyticsApiMock]], + }); + + fireEvent.click(screen.getByRole('button', { name: 'Click me' })); + + expect(analyticsApiMock.getEvents()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action: 'click', + subject: 'Test action', + }), + ]), + ); + }); }); diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index f7b1d4b58b..0b1a1fcf61 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -31,9 +31,11 @@ import { createFrontendPlugin, FrontendFeature, createFrontendModule, + ApiBlueprint, } from '@backstage/frontend-plugin-api'; import { RouterBlueprint } from '@backstage/plugin-app-react'; import appPlugin from '@backstage/plugin-app'; +import { type TestApiPairs } from '../utils'; const DEFAULT_MOCK_CONFIG = { app: { baseUrl: 'http://localhost:3000' }, @@ -44,7 +46,7 @@ const DEFAULT_MOCK_CONFIG = { * Options to customize the behavior of the test app. * @public */ -export type TestAppOptions = { +export type TestAppOptions = { /** * An object of paths to mount route ref on, with the key being the path and the value * being the RouteRef that the path will be bound to. This allows the route refs to be @@ -77,6 +79,21 @@ export type TestAppOptions = { * Initial route entries to use for the router. */ initialRouteEntries?: string[]; + + /** + * API overrides to provide to the test app. + * + * @example + * ```ts + * renderInTestApp(, { + * apis: [ + * [errorApiRef, mockErrorApi], + * [analyticsApiRef, mockAnalyticsApi], + * ] + * }) + * ``` + */ + apis?: readonly [...TestApiPairs]; }; const NavItem = (props: { @@ -143,9 +160,9 @@ const appPluginOverride = appPlugin.withOverrides({ * @public * Renders the given element in a test app, for use in unit tests. */ -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, - options?: TestAppOptions, + options?: TestAppOptions, ): RenderResult { const extensions: Array = [ createExtension({ @@ -206,6 +223,27 @@ export function renderInTestApp( features.push(...options.features); } + // If API overrides are provided, add them as a module for the 'app' plugin + // This must come after appPluginOverride so it can override app's default APIs + if (options?.apis) { + features.push( + createFrontendModule({ + pluginId: 'app', + extensions: options.apis.map(([apiRef, implementation], index) => + ApiBlueprint.make({ + name: `test-api-override-${index}`, + params: defineParams => + defineParams({ + api: apiRef, + deps: {}, + factory: () => implementation, + }), + }), + ), + }), + ); + } + const app = createSpecializedApp({ features, config: ConfigReader.fromConfigs([ diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts index cab66895e0..b709e7ac03 100644 --- a/packages/frontend-test-utils/src/index.ts +++ b/packages/frontend-test-utils/src/index.ts @@ -22,9 +22,10 @@ export * from './apis'; export * from './app'; +export * from './utils'; -export { TestApiProvider, TestApiRegistry } from '@backstage/test-utils'; -export type { TestApiProviderProps } from '@backstage/test-utils'; +// Explicit export to satisfy API Extractor +export type { TestApiPairs } from './utils'; export { withLogCollector } from '@backstage/test-utils'; diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx new file mode 100644 index 0000000000..1b09016192 --- /dev/null +++ b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx @@ -0,0 +1,149 @@ +/* + * Copyright 2020 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 { 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'; + +/** + * Helper type for representing an API reference paired with a partial implementation. + * @public + */ +export type TestApiProviderPropsApiPair = TApi extends infer TImpl + ? readonly [ApiRef, Partial] + : never; + +/** + * Helper type for representing an array of API reference pairs. + * @public + */ +export type TestApiProviderPropsApiPairs = { + [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; +}; + +/** + * Shorter alias for TestApiProviderPropsApiPairs for use in function signatures. + * @public + */ +export type TestApiPairs = TestApiProviderPropsApiPairs; + +/** + * Properties for the {@link TestApiProvider} component. + * + * @public + */ +export type TestApiProviderProps = { + apis: readonly [...TestApiProviderPropsApiPairs]; + children: ReactNode; +}; + +/** + * The `TestApiRegistry` is an {@link @backstage/core-plugin-api#ApiHolder} implementation + * that is particularly well suited for development and test environments such as + * unit tests, storybooks, and isolated plugin development setups. + * + * @public + */ +export class TestApiRegistry implements ApiHolder { + /** + * Creates a new {@link TestApiRegistry} with a list of API implementation pairs. + * + * Similar to the {@link TestApiProvider}, there is no need to provide a full + * implementation of each API, it's enough to implement the methods that are tested. + * + * @example + * ```ts + * const apis = TestApiRegistry.from( + * [configApiRef, new ConfigReader({})], + * [identityApiRef, { getUserId: () => 'tester' }], + * ); + * ``` + * + * @public + * @param apis - A list of pairs mapping an ApiRef to its respective implementation. + */ + static from( + ...apis: readonly [...TestApiProviderPropsApiPairs] + ) { + return new TestApiRegistry( + new Map(apis.map(([api, impl]) => [api.id, impl])), + ); + } + + private constructor(private readonly apis: Map) {} + + /** + * Returns an implementation of the API. + * + * @public + */ + get(api: ApiRef): T | undefined { + return this.apis.get(api.id) as T | undefined; + } +} + +/** + * The `TestApiProvider` is a Utility API context provider that is particularly + * well suited for development and test environments such as unit tests, storybooks, + * and isolated plugin development setups. + * + * It lets you provide any number of API implementations, without necessarily + * having to fully implement each of the APIs. + * + * @remarks + * todo: remove this remark tag and ship in the api-reference. There's some odd formatting going on when this is made into a markdown doc, that there's no line break between + * the emitted

for To the following

so what happens is that when parsing in docusaurus, it thinks that the code block is mdx rather than a code + * snippet. Just omitting this from the report for now until we can work out how to fix later. + * A migration from `ApiRegistry` and `ApiProvider` might look like this, from: + * + * ```tsx + * renderInTestApp( + * + * ... + * + * ) + * ``` + * + * To the following: + * + * ```tsx + * renderInTestApp( + * + * ... + * + * ) + * ``` + * + * Note that the cast to `IdentityApi` is no longer needed as long as the mock API + * implements a subset of the `IdentityApi`. + * + * @public + */ +export const TestApiProvider = ( + props: TestApiProviderProps, +) => { + return ( + + ); +}; diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/utils/index.ts new file mode 100644 index 0000000000..2f6bfb5f9c --- /dev/null +++ b/packages/frontend-test-utils/src/utils/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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 { + TestApiProvider, + TestApiRegistry, + type TestApiProviderPropsApiPair, + type TestApiProviderPropsApiPairs, + type TestApiPairs, +} from './TestApiProvider'; +export type { TestApiProviderProps } from './TestApiProvider';