feat(frontend-test-utils): add API override support to test utilities

Added support for API overrides in `createExtensionTester` and
`renderInTestApp` to allow tests to override specific APIs without
requiring wrapper components. This provides app-level API overrides
that are available throughout the entire extension tree.

The `apis` option follows the same typing pattern as `TestApiProvider`
from `@backstage/test-utils` for consistency and type safety.

Example usage:

```typescript
const tester = createExtensionTester(MyExtension, {
  apis: [
    [errorApiRef, mockErrorApi],
    [analyticsApiRef, mockAnalyticsApi],
  ],
});

renderInTestApp(<MyComponent />, {
  apis: [
    [errorApiRef, mockErrorApi],
    [analyticsApiRef, mockAnalyticsApi],
  ],
});
```

This enables cleaner tests with app-level API overrides, eliminating
the need to wrap components with TestApiProvider in many cases.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-01-31 15:10:34 +01:00
parent f2fc1def80
commit 22864b75a9
10 changed files with 440 additions and 25 deletions
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/frontend-test-utils': patch
---
Added support for API overrides in `createExtensionTester` and `renderInTestApp`. You can now pass an `apis` option to override specific APIs when testing extensions:
```typescript
// Override APIs in createExtensionTester
const tester = createExtensionTester(myExtension, {
apis: [
[errorApiRef, mockErrorApi],
[analyticsApiRef, mockAnalyticsApi],
],
});
// Override APIs in renderInTestApp
renderInTestApp(<MyComponent />, {
apis: [[errorApiRef, mockErrorApi]],
});
```
The package now also exports its own implementations of `TestApiProvider`, `TestApiRegistry`, and related types, rather than re-exporting them from `@backstage/test-utils`. This consolidates common types used internally by the test utilities.
@@ -76,6 +76,45 @@ describe('Entity details component', () => {
This pattern also works for many other context providers. An important example is the `EntityProvider` from the `@backstage/plugin-catalog-react` package, which you can use to provide a mocked entity context to the component.
Alternatively, you can pass API overrides directly to `renderInTestApp` using the `apis` option:
```tsx
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/frontend-test-utils';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { EntityDetails } from './plugin';
describe('Entity details component', () => {
it('should render the entity name and owner', async () => {
const catalogApiMock = {
async getEntityFacets() {
return {
facets: {
'relations.ownedBy': [{ count: 1, value: 'group:default/tools' }],
},
},
}
} satisfies Partial<typeof catalogApiRef.T>;
const entityRef = stringifyEntityRef({
kind: 'Component',
namespace: 'default',
name: 'test',
});
await renderInTestApp(<EntityDetails entityRef={entityRef} />, {
apis: [[catalogApiRef, catalogApiMock]],
});
await expect(
screen.findByText('The entity "test" is owned by "tools"'),
).resolves.toBeInTheDocument();
});
});
```
This approach provides the API overrides at the app level, which is useful when testing extensions that depend on APIs deep in the component tree.
## Testing extensions
To facilitate testing of frontend extensions, the `@backstage/frontend-test-utils` package provides a tester class which starts up an entire frontend harness, complete with a number of default features. You can then provide overrides for extensions whose behavior you need to adjust for the test run.
@@ -102,7 +141,29 @@ describe('Index page', () => {
});
```
This pattern also allows you to wrap the extension with context providers, such as the `TestApiProvider` that was introduced [above](#testing-react-components).
This pattern also allows you to wrap the extension with context providers, such as the `TestApiProvider` that was introduced [above](#testing-react-components). Alternatively, you can provide API overrides directly to `createExtensionTester`:
```tsx
import { screen } from '@testing-library/react';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { analyticsApiRef } from '@backstage/frontend-plugin-api';
import { indexPageExtension } from './plugin';
describe('Index page', () => {
it('should render and track analytics', async () => {
const analyticsApiMock = { captureEvent: jest.fn() };
await renderInTestApp(
createExtensionTester(indexPageExtension, {
apis: [[analyticsApiRef, analyticsApiMock]],
}).reactElement(),
);
expect(screen.getByText('Index Page')).toBeInTheDocument();
expect(analyticsApiMock.captureEvent).toHaveBeenCalled();
});
});
```
Note that the `.reactElement()` method will look for the `coreExtensionData.reactElement` data in the extension outputs. If that doesn't exist and the extension outputs something else that you want to test, you can access the output data using the `.get(dataRef)` method instead.
+44 -12
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,9 +120,9 @@ 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
@@ -133,20 +138,47 @@ export type RenderTestAppOptions = {
initialRouteEntries?: string[];
};
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,11 @@ import {
createFrontendPlugin,
FrontendFeature,
createFrontendModule,
ApiBlueprint,
} from '@backstage/frontend-plugin-api';
import { RouterBlueprint } from '@backstage/plugin-app-react';
import appPlugin from '@backstage/plugin-app';
import { type TestApiPairs } from '../utils';
const DEFAULT_MOCK_CONFIG = {
app: { baseUrl: 'http://localhost:3000' },
@@ -44,7 +46,7 @@ const DEFAULT_MOCK_CONFIG = {
* Options to customize the behavior of the test app.
* @public
*/
export type TestAppOptions = {
export type TestAppOptions<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 +79,21 @@ export type TestAppOptions = {
* Initial route entries to use for the router.
*/
initialRouteEntries?: string[];
/**
* API overrides to provide to the test app.
*
* @example
* ```ts
* renderInTestApp(<MyComponent />, {
* apis: [
* [errorApiRef, mockErrorApi],
* [analyticsApiRef, mockAnalyticsApi],
* ]
* })
* ```
*/
apis?: readonly [...TestApiPairs<TApiPairs>];
};
const NavItem = (props: {
@@ -143,9 +160,9 @@ const appPluginOverride = appPlugin.withOverrides({
* @public
* Renders the given element in a test app, for use in unit tests.
*/
export function renderInTestApp(
export function renderInTestApp<TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions,
options?: TestAppOptions<TApiPairs>,
): RenderResult {
const extensions: Array<ExtensionDefinition> = [
createExtension({
@@ -206,6 +223,27 @@ export function renderInTestApp(
features.push(...options.features);
}
// If API overrides are provided, add them as a module for the 'app' plugin
// This must come after appPluginOverride so it can override app's default APIs
if (options?.apis) {
features.push(
createFrontendModule({
pluginId: 'app',
extensions: options.apis.map(([apiRef, implementation], index) =>
ApiBlueprint.make({
name: `test-api-override-${index}`,
params: defineParams =>
defineParams({
api: apiRef,
deps: {},
factory: () => implementation,
}),
}),
),
}),
);
}
const app = createSpecializedApp({
features,
config: ConfigReader.fromConfigs([
+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,149 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ReactNode } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ApiProvider } from '../../../core-app-api/src/apis/system';
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
/**
* Helper type for representing an API reference paired with a partial implementation.
* @public
*/
export type TestApiProviderPropsApiPair<TApi> = 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/core-plugin-api#ApiHolder} implementation
* that is particularly well suited for development and test environments such as
* unit tests, storybooks, and isolated plugin development setups.
*
* @public
*/
export class TestApiRegistry implements ApiHolder {
/**
* Creates a new {@link TestApiRegistry} with a list of API implementation pairs.
*
* Similar to the {@link TestApiProvider}, there is no need to provide a full
* implementation of each API, it's enough to implement the methods that are tested.
*
* @example
* ```ts
* const apis = TestApiRegistry.from(
* [configApiRef, new ConfigReader({})],
* [identityApiRef, { getUserId: () => 'tester' }],
* );
* ```
*
* @public
* @param apis - A list of pairs mapping an ApiRef to its respective implementation.
*/
static from<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 that is particularly
* well suited for development and test environments such as unit tests, storybooks,
* and isolated plugin development setups.
*
* It lets you provide any number of API implementations, without necessarily
* having to fully implement each of the APIs.
*
* @remarks
* todo: remove this remark tag and ship in the api-reference. There's some odd formatting going on when this is made into a markdown doc, that there's no line break between
* the emitted <p> for To the following </p> so what happens is that when parsing in docusaurus, it thinks that the code block is mdx rather than a code
* snippet. Just omitting this from the report for now until we can work out how to fix later.
* A migration from `ApiRegistry` and `ApiProvider` might look like this, from:
*
* ```tsx
* renderInTestApp(
* <ApiProvider
* apis={ApiRegistry.from([
* [identityApiRef, mockIdentityApi as unknown as IdentityApi]
* ])}
* >
* ...
* </ApiProvider>
* )
* ```
*
* To the following:
*
* ```tsx
* renderInTestApp(
* <TestApiProvider apis={[[identityApiRef, mockIdentityApi]]}>
* ...
* </TestApiProvider>
* )
* ```
*
* Note that the cast to `IdentityApi` is no longer needed as long as the mock API
* implements a subset of the `IdentityApi`.
*
* @public
*/
export const TestApiProvider = <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';