From 22864b75a92680a191c864efa670bd7c0f5b0a12 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 31 Jan 2026 15:10:34 +0100 Subject: [PATCH 1/6] 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'; From 421770753a88f8dc931519cf9e06b4a23d5c6ec7 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 31 Jan 2026 15:10:53 +0100 Subject: [PATCH 2/6] refactor: migrate tests to use new API override utilities Updated tests across the repository to use the new `apis` option with `renderInTestApp` and `createExtensionTester` instead of wrapping components with `TestApiProvider`. This simplifies tests and demonstrates the use of the new API override functionality. Updated test files: - packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx - packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx - plugins/catalog/src/alpha/pages.test.tsx Total: 15 test cases migrated Signed-off-by: Patrik Oldsberg --- .../AppRootElementBlueprint.test.tsx | 14 +- .../src/components/ExtensionBoundary.test.tsx | 62 +-- plugins/catalog/src/alpha/pages.test.tsx | 433 ++++++++---------- 3 files changed, 219 insertions(+), 290 deletions(-) diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx index 31d7323aec..57984761ac 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootElementBlueprint.test.tsx @@ -15,11 +15,7 @@ */ import { screen, waitFor } from '@testing-library/react'; -import { - MockErrorApi, - TestApiProvider, - withLogCollector, -} from '@backstage/test-utils'; +import { MockErrorApi, withLogCollector } from '@backstage/test-utils'; import { errorApiRef } from '../apis'; import { createExtensionTester, @@ -74,11 +70,9 @@ describe('AppRootElementBlueprint', () => { }); const tester = createExtensionTester(extension); - renderInTestApp( - - {tester.reactElement()} - , - ); + renderInTestApp(tester.reactElement(), { + apis: [[errorApiRef, errorApi]], + }); await waitFor(() => { const errors = errorApi.getErrors(); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index c0068e8f7d..1215c7576f 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -16,7 +16,7 @@ import { useEffect, ReactNode } from 'react'; import { act, screen, waitFor } from '@testing-library/react'; -import { TestApiProvider, withLogCollector } from '@backstage/test-utils'; +import { withLogCollector } from '@backstage/test-utils'; import { ExtensionBoundary } from './ExtensionBoundary'; import { coreExtensionData, createExtension } from '../wiring'; import { analyticsApiRef } from '../apis/definitions/AnalyticsApi'; @@ -30,6 +30,7 @@ import { pluginWrapperApiRef, PluginWrapperApi, } from '../apis/definitions/PluginWrapperApi'; +import { useAppNode } from '@backstage/frontend-plugin-api'; const wrapInBoundaryExtension = (element?: JSX.Element) => { const routeRef = createRouteRef(); @@ -105,11 +106,12 @@ describe('ExtensionBoundary', () => { }; renderInTestApp( - - {createExtensionTester( - wrapInBoundaryExtension(), - ).reactElement()} - , + createExtensionTester( + wrapInBoundaryExtension(), + ).reactElement(), + { + apis: [[analyticsApiRef, analyticsApiMock]], + }, ); await waitFor(() => { @@ -132,9 +134,10 @@ describe('ExtensionBoundary', () => { }; const WrapperComponent = ({ children }: { children: ReactNode }) => { + const node = useAppNode(); return (
- Wrapper + Wrapper for {node?.spec.id} {children}
); @@ -150,15 +153,18 @@ describe('ExtensionBoundary', () => { }; renderInTestApp( - - {createExtensionTester( - wrapInBoundaryExtension(), - ).reactElement()} - , + createExtensionTester( + wrapInBoundaryExtension(), + ).reactElement(), + { + apis: [[pluginWrapperApiRef, pluginWrapperApi]], + }, ); - expect(await screen.findByTestId('plugin-wrapper')).toBeInTheDocument(); - expect(screen.getByText('Wrapper')).toBeInTheDocument(); + const wrappers = await screen.findAllByTestId('plugin-wrapper'); + expect(wrappers.length).toBeGreaterThan(1); + expect(screen.getByText('Wrapper for app')).toBeInTheDocument(); + expect(screen.getByText('Wrapper for test')).toBeInTheDocument(); expect(screen.getByText(text)).toBeInTheDocument(); expect(pluginWrapperApi.getPluginWrapper).toHaveBeenCalledWith('app'); }); @@ -184,11 +190,12 @@ describe('ExtensionBoundary', () => { const { error } = await withLogCollector(['error'], async () => { renderInTestApp( - - {createExtensionTester( - wrapInBoundaryExtension(), - ).reactElement()} - , + createExtensionTester( + wrapInBoundaryExtension(), + ).reactElement(), + { + apis: [[pluginWrapperApiRef, pluginWrapperApi]], + }, ); await waitFor(() => expect(screen.getByText(errorMsg)).toBeInTheDocument(), @@ -204,9 +211,7 @@ describe('ExtensionBoundary', () => { ); }); - // TODO(Rugvip): Need a way to be able to override APIs in the app to be able to test this properly - // eslint-disable-next-line jest/no-disabled-tests - it.skip('should emit analytics events if routable', async () => { + it('should emit analytics events if routable', async () => { const Emitter = () => { const analytics = useAnalytics(); useEffect(() => { @@ -221,22 +226,25 @@ describe('ExtensionBoundary', () => { createExtensionTester( wrapInBoundaryExtension(), ).reactElement(), - // { apis: [[analyticsApiRef, analyticsApiMock]] }, + { apis: [[analyticsApiRef, analyticsApiMock]] }, ); }); + // The navigate event is emitted by the app's routing, with app context expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith( expect.objectContaining({ action: 'navigate', subject: '/', + }), + ); + // The dummy event from our test extension has the correct extension context + expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'dummy', context: expect.objectContaining({ - pluginId: 'root', extensionId: 'test', }), }), ); - expect(analyticsApiMock.captureEvent).toHaveBeenCalledWith( - expect.objectContaining({ action: 'dummy' }), - ); }); }); diff --git a/plugins/catalog/src/alpha/pages.test.tsx b/plugins/catalog/src/alpha/pages.test.tsx index 2ce2095358..e6bf4a05cf 100644 --- a/plugins/catalog/src/alpha/pages.test.tsx +++ b/plugins/catalog/src/alpha/pages.test.tsx @@ -19,7 +19,6 @@ import userEvent from '@testing-library/user-event'; import { createExtensionTester, renderInTestApp, - TestApiProvider, } from '@backstage/frontend-test-utils'; import { catalogEntityPage } from './pages'; import { @@ -150,29 +149,23 @@ describe('Entity page', () => { .add(techdocsEntityContent) .add(apidocsEntityContent); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect( @@ -212,29 +205,23 @@ describe('Entity page', () => { .add(techdocsEntityContent) .add(apidocsEntityContent); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect(screen.queryByRole('tab', { name: /Docs/ })).toBeInTheDocument(), @@ -267,29 +254,23 @@ describe('Entity page', () => { }, }); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect( @@ -334,29 +315,23 @@ describe('Entity page', () => { }, }); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect(screen.getByRole('tab', { name: /Docs/ })).toBeInTheDocument(), @@ -390,29 +365,23 @@ describe('Entity page', () => { }, }); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect( @@ -435,29 +404,23 @@ describe('Entity page', () => { .add(apidocsEntityContent) .add(overviewEntityContent); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2)); @@ -485,29 +448,23 @@ describe('Entity page', () => { }, }); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect(screen.getAllByRole('tab')).toHaveLength(2)); @@ -522,29 +479,23 @@ describe('Entity page', () => { Object.assign({ namespace: 'catalog' }, catalogEntityPage), ); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect(screen.getByText(/artist-lookup/)).toBeInTheDocument(), @@ -567,29 +518,23 @@ describe('Entity page', () => { Object.assign({ namespace: 'catalog' }, catalogEntityPage), ).add(customEntityHeader); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); await waitFor(() => expect( @@ -634,29 +579,23 @@ describe('Entity page', () => { Object.assign({ namespace: 'catalog' }, catalogEntityPage), ).add(menuItem); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); const { disabled } = params.useProps(); await userEvent.click(await screen.findByTestId('menu-button')); @@ -697,29 +636,23 @@ describe('Entity page', () => { Object.assign({ namespace: 'catalog' }, catalogEntityPage), ).add(menuItem); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + }); const { disabled } = params.useProps(); @@ -797,29 +730,23 @@ describe('Entity page', () => { .add(menuItem) .add(filteredMenuItem); - await renderInTestApp( - - {tester.reactElement()} - , - { - config: { - app: { - title: 'Custom app', - }, - backend: { baseUrl: 'http://localhost:7000' }, - }, - mountedRoutes: { - '/catalog': convertLegacyRouteRef(rootRouteRef), - '/catalog/:namespace/:kind/:name': - convertLegacyRouteRef(entityRouteRef), + await renderInTestApp(tester.reactElement(), { + config: { + app: { + title: 'Custom app', }, + backend: { baseUrl: 'http://localhost:7000' }, }, - ); + mountedRoutes: { + '/catalog': convertLegacyRouteRef(rootRouteRef), + '/catalog/:namespace/:kind/:name': + convertLegacyRouteRef(entityRouteRef), + }, + apis: [ + [catalogApiRef, mockCatalogApi], + [starredEntitiesApiRef, mockStarredEntitiesApi], + ], + }); await userEvent.click(await screen.findByTestId('menu-button')); From 09032d7bd44cf7f8f5d2db9e39344690126fefbb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 1 Feb 2026 15:43:48 +0100 Subject: [PATCH 3/6] frontend-app-api: add internal app options Signed-off-by: Patrik Oldsberg --- .changeset/thirty-meals-rest.md | 5 +++ .../src/wiring/createSpecializedApp.tsx | 10 ++++++ .../src/app/renderInTestApp.tsx | 32 ++++++------------- 3 files changed, 24 insertions(+), 23 deletions(-) create mode 100644 .changeset/thirty-meals-rest.md diff --git a/.changeset/thirty-meals-rest.md b/.changeset/thirty-meals-rest.md new file mode 100644 index 0000000000..7972167c00 --- /dev/null +++ b/.changeset/thirty-meals-rest.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Internal update to simplify testing utility implementations. diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index 57c91969de..d56a609bb9 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -284,6 +284,14 @@ export type CreateSpecializedAppOptions = { }; }; +// Internal options type, not exported in the public API +export interface CreateSpecializedAppInternalOptions + extends CreateSpecializedAppOptions { + __internal?: { + apiFactoryOverrides?: AnyApiFactory[]; + }; +} + /** * Creates an empty app without any default features. This is a low-level API is * intended for use in tests or specialized setups. Typically you want to use @@ -296,6 +304,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): { tree: AppTree; errors?: AppError[]; } { + const internalOptions = options as CreateSpecializedAppInternalOptions; const config = options?.config ?? new ConfigReader({}, 'empty-config'); const features = deduplicateFeatures(options?.features ?? []).map( createPluginInfoAttacher(config, options?.advanced?.pluginInfoResolver), @@ -337,6 +346,7 @@ export function createSpecializedApp(options?: CreateSpecializedAppOptions): { createApiFactory(configApiRef, config), createApiFactory(routeResolutionApiRef, routeResolutionApi), createApiFactory(identityApiRef, appIdentityProxy), + ...(internalOptions?.__internal?.apiFactoryOverrides ?? []), ], }); diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index 0b1a1fcf61..8aa863512b 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -31,11 +31,13 @@ import { createFrontendPlugin, FrontendFeature, createFrontendModule, - ApiBlueprint, + createApiFactory, } from '@backstage/frontend-plugin-api'; import { RouterBlueprint } from '@backstage/plugin-app-react'; import appPlugin from '@backstage/plugin-app'; import { type TestApiPairs } from '../utils'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; const DEFAULT_MOCK_CONFIG = { app: { baseUrl: 'http://localhost:3000' }, @@ -223,27 +225,6 @@ 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([ @@ -252,7 +233,12 @@ export function renderInTestApp( data: options?.config ?? DEFAULT_MOCK_CONFIG, }, ]), - }); + __internal: options?.apis && { + apiFactoryOverrides: options.apis.map(([apiRef, implementation]) => + createApiFactory(apiRef, implementation), + ), + }, + } as CreateSpecializedAppInternalOptions); return render( app.tree.root.instance!.getData(coreExtensionData.reactElement), From 68d2c57d944e7de1055080969a3429de1331c643 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sun, 1 Feb 2026 21:09:24 +0100 Subject: [PATCH 4/6] docs: update to prefer new apis option and use mockApis Signed-off-by: Patrik Oldsberg --- .changeset/api-override-test-utils.md | 20 ++- .../building-plugins/02-testing.md | 123 +++++++----------- .../src/app/renderInTestApp.tsx | 11 +- 3 files changed, 65 insertions(+), 89 deletions(-) diff --git a/.changeset/api-override-test-utils.md b/.changeset/api-override-test-utils.md index a250dc7d15..f2c73db7f3 100644 --- a/.changeset/api-override-test-utils.md +++ b/.changeset/api-override-test-utils.md @@ -2,21 +2,29 @@ '@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: +Added an `apis` option to `createExtensionTester` and `renderInTestApp` to override APIs when testing extensions. Use the `mockApis` helpers to create mock implementations: ```typescript +import { identityApiRef } from '@backstage/frontend-plugin-api'; +import { mockApis } from '@backstage/frontend-test-utils'; + // Override APIs in createExtensionTester const tester = createExtensionTester(myExtension, { apis: [ - [errorApiRef, mockErrorApi], - [analyticsApiRef, mockAnalyticsApi], + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], ], }); // Override APIs in renderInTestApp renderInTestApp(, { - apis: [[errorApiRef, mockErrorApi]], + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], }); ``` - -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 adb0e0a45e..550434f373 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -31,89 +31,50 @@ describe('Entity details component', () => { }); ``` -To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component you can use the `TestApiProvider` to override individual API implementations. In the snippet below, we wrap the component within a `TestApiProvider` in order to mock the catalog client API: +To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities: ```tsx import { screen } from '@testing-library/react'; -import { - renderInTestApp, - TestApiProvider, -} from '@backstage/frontend-test-utils'; -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { CatalogApi, 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( - - - , - ); - - await expect( - screen.findByText('The entity "test" is owned by "tools"'), - ).resolves.toBeInTheDocument(); - }); -}); -``` - -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 { renderInTestApp, mockApis } from '@backstage/frontend-test-utils'; +import { identityApiRef } from '@backstage/frontend-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { EntityDetails } from './plugin'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { MyEntitiesList } 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]], +describe('MyEntitiesList', () => { + it('should render entities owned by the current user', async () => { + await renderInTestApp(, { + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + [ + catalogApiRef, + catalogApiMock({ + entities: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'my-component' }, + spec: { type: 'service', owner: 'user:default/guest' }, + }, + ], + }), + ], + ], }); await expect( - screen.findByText('The entity "test" is owned by "tools"'), + screen.findByText('my-component'), ).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. +This approach provides the API overrides at the app level, which is useful when testing components that depend on APIs deep in the component tree. + +The `TestApiProvider` component is also available for standalone rendering scenarios where you're not using `renderInTestApp` or other test utilities. Context providers like `EntityProvider` from `@backstage/plugin-catalog-react` can also be used to provide a mocked entity context to the component. ## Testing extensions @@ -141,26 +102,32 @@ 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). Alternatively, you can provide API overrides directly to `createExtensionTester`: +You can also provide API overrides directly to `createExtensionTester` using the `apis` option: ```tsx import { screen } from '@testing-library/react'; -import { createExtensionTester } from '@backstage/frontend-test-utils'; -import { analyticsApiRef } from '@backstage/frontend-plugin-api'; +import { + createExtensionTester, + mockApis, + renderInTestApp, +} from '@backstage/frontend-test-utils'; +import { identityApiRef } from '@backstage/frontend-plugin-api'; import { indexPageExtension } from './plugin'; describe('Index page', () => { - it('should render and track analytics', async () => { - const analyticsApiMock = { captureEvent: jest.fn() }; - + it('should render with a custom identity', async () => { await renderInTestApp( createExtensionTester(indexPageExtension, { - apis: [[analyticsApiRef, analyticsApiMock]], + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], }).reactElement(), ); expect(screen.getByText('Index Page')).toBeInTheDocument(); - expect(analyticsApiMock.captureEvent).toHaveBeenCalled(); }); }); ``` diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index 8aa863512b..93094fb7e3 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -83,15 +83,16 @@ export type TestAppOptions = { initialRouteEntries?: string[]; /** - * API overrides to provide to the test app. + * API overrides to provide to the test app. Use `mockApis` helpers + * from `@backstage/frontend-test-utils` to create mock implementations. * * @example * ```ts + * import { identityApiRef } from '@backstage/frontend-plugin-api'; + * import { mockApis } from '@backstage/frontend-test-utils'; + * * renderInTestApp(, { - * apis: [ - * [errorApiRef, mockErrorApi], - * [analyticsApiRef, mockAnalyticsApi], - * ] + * apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]], * }) * ``` */ From 062e9dcf09f13460bd9fe94ef733fcdcd13a332d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 2 Feb 2026 00:43:31 +0100 Subject: [PATCH 5/6] frontend-test-utils: update docs for TestApiProvider and friends Signed-off-by: Patrik Oldsberg --- .../src/utils/TestApiProvider.tsx | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx index 1b09016192..97529033d0 100644 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx @@ -52,10 +52,15 @@ export type TestApiProviderProps = { }; /** - * The `TestApiRegistry` is an {@link @backstage/core-plugin-api#ApiHolder} implementation + * The `TestApiRegistry` is an {@link @backstage/frontend-plugin-api#ApiHolder} implementation * that is particularly well suited for development and test environments such as * unit tests, storybooks, and isolated plugin development setups. * + * @remarks + * + * For most test scenarios, prefer using the `apis` option in `renderInTestApp` or + * `createExtensionTester` instead of creating a registry directly. + * * @public */ export class TestApiRegistry implements ApiHolder { @@ -67,9 +72,11 @@ export class TestApiRegistry implements ApiHolder { * * @example * ```ts + * import { identityApiRef } from '@backstage/frontend-plugin-api'; + * import { mockApis } from '@backstage/frontend-test-utils'; + * * const apis = TestApiRegistry.from( - * [configApiRef, new ConfigReader({})], - * [identityApiRef, { getUserId: () => 'tester' }], + * [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })], * ); * ``` * @@ -97,44 +104,32 @@ export class TestApiRegistry implements ApiHolder { } /** - * 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. + * The `TestApiProvider` is a Utility API context provider for standalone rendering + * scenarios where you're not using `renderInTestApp` or other test utilities. * * 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: * + * For most test scenarios, prefer using the `apis` option in `renderInTestApp` or + * `createExtensionTester` instead of wrapping components with `TestApiProvider`. + * + * @example * ```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 = ( From 1911ebac7f4a8c8ea63ffc26b59f6939404b9cda Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 3 Feb 2026 00:21:07 +0100 Subject: [PATCH 6/6] frontend-test-utils: also add apis option to renderTestApp Signed-off-by: Patrik Oldsberg --- .changeset/api-override-test-utils.md | 13 ++++++- packages/frontend-test-utils/report.api.md | 7 ++-- .../src/app/renderTestApp.tsx | 34 +++++++++++++++++-- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/.changeset/api-override-test-utils.md b/.changeset/api-override-test-utils.md index f2c73db7f3..6ea60f9e1b 100644 --- a/.changeset/api-override-test-utils.md +++ b/.changeset/api-override-test-utils.md @@ -2,7 +2,7 @@ '@backstage/frontend-test-utils': patch --- -Added an `apis` option to `createExtensionTester` and `renderInTestApp` to override APIs when testing extensions. Use the `mockApis` helpers to create mock implementations: +Added an `apis` option to `createExtensionTester`, `renderInTestApp`, and `renderTestApp` to override APIs when testing extensions. Use the `mockApis` helpers to create mock implementations: ```typescript import { identityApiRef } from '@backstage/frontend-plugin-api'; @@ -27,4 +27,15 @@ renderInTestApp(, { ], ], }); + +// Override APIs in renderTestApp +renderTestApp({ + extensions: [myExtension], + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], +}); ``` diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index b1cee9ba1a..fa57b76e82 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -126,16 +126,17 @@ export function renderInTestApp( ): RenderResult; // @public -export function renderTestApp( - options: RenderTestAppOptions, +export function renderTestApp( + options: RenderTestAppOptions, ): RenderResult; // @public -export type RenderTestAppOptions = { +export type RenderTestAppOptions = { config?: JsonObject; extensions?: ExtensionDefinition[]; features?: FrontendFeature[]; initialRouteEntries?: string[]; + apis?: readonly [...TestApiPairs]; }; // @public diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index 879e3fe1ae..b12d3ffd8c 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -17,6 +17,7 @@ import { createSpecializedApp } from '@backstage/frontend-app-api'; import { coreExtensionData, + createApiFactory, createFrontendModule, createFrontendPlugin, ExtensionDefinition, @@ -28,6 +29,9 @@ import { JsonObject } from '@backstage/types'; import { ConfigReader } from '@backstage/config'; import { MemoryRouter } from 'react-router-dom'; import { RouterBlueprint } from '@backstage/plugin-app-react'; +import { type TestApiPairs } from '../utils'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; const DEFAULT_MOCK_CONFIG = { app: { baseUrl: 'http://localhost:3000' }, @@ -39,7 +43,7 @@ const DEFAULT_MOCK_CONFIG = { * * @public */ -export type RenderTestAppOptions = { +export type RenderTestAppOptions = { /** * Additional configuration passed to the app when rendering elements inside it. */ @@ -58,6 +62,23 @@ export type RenderTestAppOptions = { * Initial route entries to use for the router. */ initialRouteEntries?: string[]; + + /** + * API overrides to provide to the test app. Use `mockApis` helpers + * from `@backstage/frontend-test-utils` to create mock implementations. + * + * @example + * ```ts + * import { identityApiRef } from '@backstage/frontend-plugin-api'; + * import { mockApis } from '@backstage/frontend-test-utils'; + * + * renderTestApp({ + * apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]], + * extensions: [...], + * }) + * ``` + */ + apis?: readonly [...TestApiPairs]; }; const appPluginOverride = appPlugin.withOverrides({ @@ -74,7 +95,9 @@ const appPluginOverride = appPlugin.withOverrides({ * * @public */ -export function renderTestApp(options: RenderTestAppOptions) { +export function renderTestApp( + options: RenderTestAppOptions, +) { const extensions = [...(options.extensions ?? [])]; const features: FrontendFeature[] = [ @@ -111,7 +134,12 @@ export function renderTestApp(options: RenderTestAppOptions) { data: options?.config ?? DEFAULT_MOCK_CONFIG, }, ]), - }); + __internal: options?.apis && { + apiFactoryOverrides: options.apis.map(([apiRef, implementation]) => + createApiFactory(apiRef, implementation), + ), + }, + } as CreateSpecializedAppInternalOptions); return render( app.tree.root.instance!.getData(coreExtensionData.reactElement),