diff --git a/.changeset/api-override-test-utils.md b/.changeset/api-override-test-utils.md new file mode 100644 index 0000000000..6ea60f9e1b --- /dev/null +++ b/.changeset/api-override-test-utils.md @@ -0,0 +1,41 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +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'; +import { mockApis } from '@backstage/frontend-test-utils'; + +// Override APIs in createExtensionTester +const tester = createExtensionTester(myExtension, { + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], +}); + +// Override APIs in renderInTestApp +renderInTestApp(, { + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], +}); + +// Override APIs in renderTestApp +renderTestApp({ + extensions: [myExtension], + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], +}); +``` 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/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md index ac90185b40..550434f373 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -31,50 +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'; +import { renderInTestApp, mockApis } from '@backstage/frontend-test-utils'; +import { identityApiRef } from '@backstage/frontend-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +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', +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 renderInTestApp( - - - , - ); - await expect( - screen.findByText('The entity "test" is owned by "tools"'), + screen.findByText('my-component'), ).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. +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 @@ -102,7 +102,35 @@ 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). +You can also provide API overrides directly to `createExtensionTester` using the `apis` option: + +```tsx +import { screen } from '@testing-library/react'; +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 with a custom identity', async () => { + await renderInTestApp( + createExtensionTester(indexPageExtension, { + apis: [ + [ + identityApiRef, + mockApis.identity({ userEntityRef: 'user:default/guest' }), + ], + ], + }).reactElement(), + ); + + expect(screen.getByText('Index Page')).toBeInTheDocument(); + }); +}); +``` 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-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-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/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index b99cf843d4..fa57b76e82 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,38 +120,66 @@ export { MockStorageBucket }; export { registerMswTestHooks }; // @public -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, - options?: TestAppOptions, + options?: TestAppOptions, ): 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]; }; -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..93094fb7e3 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -31,9 +31,13 @@ import { createFrontendPlugin, FrontendFeature, createFrontendModule, + 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' }, @@ -44,7 +48,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 +81,22 @@ export type TestAppOptions = { * 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'; + * + * renderInTestApp(, { + * apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]], + * }) + * ``` + */ + apis?: readonly [...TestApiPairs]; }; const NavItem = (props: { @@ -143,9 +163,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({ @@ -214,7 +234,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), 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), 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..97529033d0 --- /dev/null +++ b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx @@ -0,0 +1,144 @@ +/* + * 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/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 { + /** + * 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 + * import { identityApiRef } from '@backstage/frontend-plugin-api'; + * import { mockApis } from '@backstage/frontend-test-utils'; + * + * const apis = TestApiRegistry.from( + * [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })], + * ); + * ``` + * + * @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 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 + * + * For most test scenarios, prefer using the `apis` option in `renderInTestApp` or + * `createExtensionTester` instead of wrapping components with `TestApiProvider`. + * + * @example + * ```tsx + * import { render } from '\@testing-library/react'; + * import { identityApiRef } from '\@backstage/frontend-plugin-api'; + * import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils'; + * + * render( + * + * + * + * ); + * ``` + * + * @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'; 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'));