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:
@@ -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([
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user