Merge pull request #32617 from backstage/rugvip/api-override-test-utils

frontend-test-utils: add API override support
This commit is contained in:
Patrik Oldsberg
2026-02-03 13:55:58 +01:00
committed by GitHub
16 changed files with 710 additions and 354 deletions
+41
View File
@@ -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(<MyComponent />, {
apis: [
[
identityApiRef,
mockApis.identity({ userEntityRef: 'user:default/guest' }),
],
],
});
// Override APIs in renderTestApp
renderTestApp({
extensions: [myExtension],
apis: [
[
identityApiRef,
mockApis.identity({ userEntityRef: 'user:default/guest' }),
],
],
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Internal update to simplify testing utility implementations.
@@ -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<typeof catalogApiRef.T>;
const entityRef = stringifyEntityRef({
kind: 'Component',
namespace: 'default',
name: 'test',
describe('MyEntitiesList', () => {
it('should render entities owned by the current user', async () => {
await renderInTestApp(<MyEntitiesList />, {
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(
<TestApiProvider apis={[[catalogApiRef, catalogApiMock]]}>
<EntityDetails entityRef={entityRef} />
</TestApiProvider>,
);
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.
@@ -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 ?? []),
],
});
@@ -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(
<TestApiProvider apis={[[errorApiRef, errorApi]]}>
{tester.reactElement()}
</TestApiProvider>,
);
renderInTestApp(tester.reactElement(), {
apis: [[errorApiRef, errorApi]],
});
await waitFor(() => {
const errors = errorApi.getErrors();
@@ -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(
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
{createExtensionTester(
wrapInBoundaryExtension(<AnalyticsComponent />),
).reactElement()}
</TestApiProvider>,
createExtensionTester(
wrapInBoundaryExtension(<AnalyticsComponent />),
).reactElement(),
{
apis: [[analyticsApiRef, analyticsApiMock]],
},
);
await waitFor(() => {
@@ -132,9 +134,10 @@ describe('ExtensionBoundary', () => {
};
const WrapperComponent = ({ children }: { children: ReactNode }) => {
const node = useAppNode();
return (
<div data-testid="plugin-wrapper">
<span>Wrapper</span>
<span>Wrapper for {node?.spec.id}</span>
{children}
</div>
);
@@ -150,15 +153,18 @@ describe('ExtensionBoundary', () => {
};
renderInTestApp(
<TestApiProvider apis={[[pluginWrapperApiRef, pluginWrapperApi]]}>
{createExtensionTester(
wrapInBoundaryExtension(<TextComponent />),
).reactElement()}
</TestApiProvider>,
createExtensionTester(
wrapInBoundaryExtension(<TextComponent />),
).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(
<TestApiProvider apis={[[pluginWrapperApiRef, pluginWrapperApi]]}>
{createExtensionTester(
wrapInBoundaryExtension(<TextComponent />),
).reactElement()}
</TestApiProvider>,
createExtensionTester(
wrapInBoundaryExtension(<TextComponent />),
).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(<Emitter />),
).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' }),
);
});
});
+48 -15
View File
@@ -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<T extends ExtensionDefinitionParameters>(
export function createExtensionTester<
T extends ExtensionDefinitionParameters,
TApiPairs extends any[] = any[],
>(
subject: ExtensionDefinition<T>,
options?: {
config?: T['configInput'];
apis?: readonly [...TestApiPairs<TApiPairs>];
},
): ExtensionTester<NonNullable<T['output']>>;
@@ -115,38 +120,66 @@ export { MockStorageBucket };
export { registerMswTestHooks };
// @public
export function renderInTestApp(
export function renderInTestApp<TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions,
options?: TestAppOptions<TApiPairs>,
): RenderResult;
// @public
export function renderTestApp(
options: RenderTestAppOptions,
export function renderTestApp<TApiPairs extends any[] = any[]>(
options: RenderTestAppOptions<TApiPairs>,
): RenderResult<testingLibraryDomTypesQueries, HTMLElement, HTMLElement>;
// @public
export type RenderTestAppOptions = {
export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
config?: JsonObject;
extensions?: ExtensionDefinition<any>[];
features?: FrontendFeature[];
initialRouteEntries?: string[];
apis?: readonly [...TestApiPairs<TApiPairs>];
};
export { TestApiProvider };
export { TestApiProviderProps };
export { TestApiRegistry };
// @public
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
// @public
export type TestAppOptions = {
export const TestApiProvider: <T extends any[]>(
props: TestApiProviderProps<T>,
) => JSX_2.Element;
// @public
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
children: ReactNode;
};
// @public
export type TestApiProviderPropsApiPair<TApi> = TApi extends infer TImpl
? readonly [ApiRef<TApi>, Partial<TImpl>]
: never;
// @public
export type TestApiProviderPropsApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
};
// @public
export class TestApiRegistry implements ApiHolder {
static from<TApiPairs extends any[]>(
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
): TestApiRegistry;
get<T>(api: ApiRef<T>): T | undefined;
}
// @public
export type TestAppOptions<TApiPairs extends any[] = any[]> = {
mountedRoutes?: {
[path: string]: RouteRef;
};
config?: JsonObject;
features?: FrontendFeature[];
initialRouteEntries?: string[];
apis?: readonly [...TestApiPairs<TApiPairs>];
};
export { withLogCollector };
@@ -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<string>().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 <div>Test</div>;
};
const extension = createExtension({
attachTo: { id: 'ignored', input: 'ignored' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<TestComponent />)],
});
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',
}),
);
});
});
@@ -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<UOutput extends ExtensionDataRef> {
@@ -78,16 +78,23 @@ export class ExtensionQuery<UOutput extends ExtensionDataRef> {
/** @public */
export class ExtensionTester<UOutput extends ExtensionDataRef> {
/** @internal */
static forSubject<T extends ExtensionDefinitionParameters>(
static forSubject<
T extends ExtensionDefinitionParameters,
TApiPairs extends any[],
>(
subject: ExtensionDefinition<T>,
options?: { config?: T['configInput'] },
options?: {
config?: T['configInput'];
apis?: readonly [...TestApiPairs<TApiPairs>];
},
): ExtensionTester<NonNullable<T['output']>> {
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<UOutput extends ExtensionDataRef> {
config?: JsonValue;
}>();
private constructor(apis?: readonly any[]) {
this.#apis = apis;
}
add<T extends ExtensionDefinitionParameters>(
extension: ExtensionDefinition<T>,
options?: { config?: T['configInput'] },
@@ -206,7 +217,11 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
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<UOutput extends ExtensionDataRef> {
}
/** @public */
export function createExtensionTester<T extends ExtensionDefinitionParameters>(
export function createExtensionTester<
T extends ExtensionDefinitionParameters,
TApiPairs extends any[] = any[],
>(
subject: ExtensionDefinition<T>,
options?: { config?: T['configInput'] },
options?: {
config?: T['configInput'];
apis?: readonly [...TestApiPairs<TApiPairs>];
},
): ExtensionTester<NonNullable<T['output']>> {
return ExtensionTester.forSubject(subject, options);
}
@@ -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 (
<div>
<button onClick={handleClick}>Click me</button>
</div>
);
};
const analyticsApiMock = new MockAnalyticsApi();
renderInTestApp(<IndexPage />, {
apis: [[analyticsApiRef, analyticsApiMock]],
});
fireEvent.click(screen.getByRole('button', { name: 'Click me' }));
expect(analyticsApiMock.getEvents()).toEqual(
expect.arrayContaining([
expect.objectContaining({
action: 'click',
subject: 'Test action',
}),
]),
);
});
});
@@ -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<TApiPairs extends any[] = any[]> = {
/**
* 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(<MyComponent />, {
* apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]],
* })
* ```
*/
apis?: readonly [...TestApiPairs<TApiPairs>];
};
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<TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions,
options?: TestAppOptions<TApiPairs>,
): RenderResult {
const extensions: Array<ExtensionDefinition> = [
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),
@@ -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<TApiPairs extends any[] = any[]> = {
/**
* 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<TApiPairs>];
};
const appPluginOverride = appPlugin.withOverrides({
@@ -74,7 +95,9 @@ const appPluginOverride = appPlugin.withOverrides({
*
* @public
*/
export function renderTestApp(options: RenderTestAppOptions) {
export function renderTestApp<TApiPairs extends any[] = any[]>(
options: RenderTestAppOptions<TApiPairs>,
) {
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),
+3 -2
View File
@@ -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';
@@ -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> = TApi extends infer TImpl
? readonly [ApiRef<TApi>, Partial<TImpl>]
: never;
/**
* Helper type for representing an array of API reference pairs.
* @public
*/
export type TestApiProviderPropsApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
};
/**
* Shorter alias for TestApiProviderPropsApiPairs for use in function signatures.
* @public
*/
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
/**
* Properties for the {@link TestApiProvider} component.
*
* @public
*/
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
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<TApiPairs extends any[]>(
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
) {
return new TestApiRegistry(
new Map(apis.map(([api, impl]) => [api.id, impl])),
);
}
private constructor(private readonly apis: Map<string, unknown>) {}
/**
* Returns an implementation of the API.
*
* @public
*/
get<T>(api: ApiRef<T>): 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(
* <TestApiProvider
* apis={[[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]]}
* >
* <MyComponent />
* </TestApiProvider>
* );
* ```
*
* @public
*/
export const TestApiProvider = <T extends any[]>(
props: TestApiProviderProps<T>,
) => {
return (
<ApiProvider
apis={TestApiRegistry.from(...props.apis)}
children={props.children}
/>
);
};
@@ -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';
+180 -253
View File
@@ -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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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(
<TestApiProvider
apis={[
[catalogApiRef, mockCatalogApi],
[starredEntitiesApiRef, mockStarredEntitiesApi],
]}
>
{tester.reactElement()}
</TestApiProvider>,
{
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'));