;
-
- 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'));