frontend-test-utils: review and type fixes + cleanup

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-07 11:30:35 +01:00
parent f4bb4d1983
commit b9d90a7140
16 changed files with 94 additions and 143 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
'@backstage/frontend-test-utils': minor
---
**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directory instead, storing resused APIs in an a variable instead, e.g. `const apis = [...] as const`.
**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directly instead, storing reused APIs in a variable, e.g. `const apis = [...] as const`.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': patch
---
Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests.
@@ -32,14 +32,15 @@ Call `.mock()` to get an instance where every method is a `jest.fn()`. You can o
```ts
import { mockApis } from '@backstage/frontend-test-utils';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
const catalogApi = mockApis.permission.mock({
const permissionApi = mockApis.permission.mock({
authorize: async () => ({ result: AuthorizeResult.ALLOW }),
});
// ... exercise the component ...
expect(catalogApi.authorize).toHaveBeenCalledTimes(1);
expect(permissionApi.authorize).toHaveBeenCalledTimes(1);
```
## Providing mock APIs in tests
+16 -17
View File
@@ -8,7 +8,6 @@ import { AlertMessage } from '@backstage/frontend-plugin-api';
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
import { ApiFactory } from '@backstage/frontend-plugin-api';
import { ApiHolder } from '@backstage/frontend-plugin-api';
import { ApiRef } from '@backstage/frontend-plugin-api';
import { AppNode } from '@backstage/frontend-plugin-api';
import { AppNodeInstance } from '@backstage/frontend-plugin-api';
@@ -39,7 +38,6 @@ import { IdentityApi } from '@backstage/frontend-plugin-api';
import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { JsonValue } from '@backstage/types';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { Observable } from '@backstage/types';
import { PermissionApi } from '@backstage/plugin-permission-react';
import { ReactNode } from 'react';
@@ -546,13 +544,13 @@ export type MockWithApiFactory<TApi> = TApi & {
export { registerMswTestHooks };
// @public
export function renderInTestApp<TApiPairs extends any[] = any[]>(
export function renderInTestApp<const TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions<TApiPairs>,
): RenderResult;
// @public
export function renderTestApp<TApiPairs extends any[] = any[]>(
export function renderTestApp<const TApiPairs extends any[] = any[]>(
options?: RenderTestAppOptions<TApiPairs>,
): RenderResult;
@@ -565,24 +563,27 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
mountedRoutes?: {
[path: string]: RouteRef;
};
apis?: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
apis?: readonly [...TestApiPairs<TApiPairs>];
};
// @public
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
export type TestApiPair<TApi> =
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
| MockWithApiFactory<TApi>;
// @public
export const TestApiProvider: <T extends any[]>(
props: TestApiProviderProps<T>,
) => JSX_2.Element;
export type TestApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiPair<TApiPairs[TIndex]>;
};
// @public
export function TestApiProvider<const TApiPairs extends any[]>(
props: TestApiProviderProps<TApiPairs>,
): JSX.Element;
// @public
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
apis: readonly [...TestApiPairs<TApiPairs>];
children: ReactNode;
};
@@ -594,9 +595,7 @@ export type TestAppOptions<TApiPairs extends any[] = any[]> = {
config?: JsonObject;
features?: FrontendFeature[];
initialRouteEntries?: string[];
apis?: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
apis?: readonly [...TestApiPairs<TApiPairs>];
};
export { withLogCollector };
@@ -46,22 +46,26 @@ describe('MockAlertApi', () => {
expect(api.getAlerts()).toHaveLength(0);
});
it('should notify observers', done => {
it('should notify observers', async () => {
const api = new MockAlertApi();
const messages: string[] = [];
api.alert$().subscribe({
next: alert => {
messages.push(alert.message);
if (messages.length === 2) {
expect(messages).toEqual(['First', 'Second']);
done();
}
},
const collected = new Promise<void>(resolve => {
api.alert$().subscribe({
next: alert => {
messages.push(alert.message);
if (messages.length === 2) {
resolve();
}
},
});
});
api.post({ message: 'First' });
api.post({ message: 'Second' });
await collected;
expect(messages).toEqual(['First', 'Second']);
});
it('should wait for matching alert', async () => {
@@ -1,34 +0,0 @@
/*
* Copyright 2024 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 { ApiFactory } from '@backstage/frontend-plugin-api';
import { mockApiFactorySymbol } from './utils';
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
@@ -33,22 +33,6 @@ export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api');
*/
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
/**
* Type for an API instance that has been marked as a mock API.
*
@@ -30,7 +30,6 @@ describe('WebStorage Storage API', () => {
key: 'myfakekey',
presence: 'absent',
value: undefined,
newValue: undefined,
});
});
@@ -135,7 +134,6 @@ describe('WebStorage Storage API', () => {
key: 'correctKey',
presence: 'absent',
value: undefined,
newValue: undefined,
});
});
@@ -18,44 +18,30 @@ 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';
import { getMockApiFactory, type MockWithApiFactory } from './utils';
import {
getMockApiFactory,
type MockWithApiFactory,
} from './MockWithApiFactory';
/**
* Helper type for representing an API reference paired with a partial implementation.
* @ignore
*/
export type TestApiProviderPropsApiPair<TApi> = TApi extends infer TImpl
? readonly [ApiRef<TApi>, Partial<TImpl>]
: never;
/**
* Helper type for representing an array of API reference pairs.
* @ignore
*/
export type TestApiProviderPropsApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
};
/**
* Shorter alias for TestApiProviderPropsApiPairs for use in function signatures.
* Represents a single API implementation, either as a tuple of the reference and the implementation, or a mock with an embedded factory.
* @public
*/
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
export type TestApiPair<TApi> =
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
| MockWithApiFactory<TApi>;
/**
* Type for entries that can be passed to TestApiProvider.
* Can be either a traditional [apiRef, implementation] tuple or a mock API instance
* marked with the mockApiFactorySymbol.
*
* @internal
* Represents an array of mock API implementation.
* @public
*/
export type TestApiProviderEntry =
| readonly [ApiRef<any>, any]
| MockWithApiFactory<any>;
export type TestApiPairs<TApiPairs> = {
[TIndex in keyof TApiPairs]: TestApiPair<TApiPairs[TIndex]>;
};
/** @internal */
export function resolveTestApiEntries(
apis: readonly (TestApiProviderEntry | readonly [ApiRef<any>, any])[],
apis: readonly TestApiPairs<any>[],
): ApiHolder {
const apiMap = new Map<string, unknown>();
@@ -80,9 +66,7 @@ export function resolveTestApiEntries(
* @public
*/
export type TestApiProviderProps<TApiPairs extends any[]> = {
apis: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
apis: readonly [...TestApiPairs<TApiPairs>];
children: ReactNode;
};
@@ -131,13 +115,13 @@ export type TestApiProviderProps<TApiPairs extends any[]> = {
*
* @public
*/
export const TestApiProvider = <T extends any[]>(
props: TestApiProviderProps<T>,
) => {
export function TestApiProvider<const TApiPairs extends any[]>(
props: TestApiProviderProps<TApiPairs>,
): JSX.Element {
return (
<ApiProvider
apis={resolveTestApiEntries(props.apis)}
children={props.children}
/>
);
};
}
@@ -14,21 +14,18 @@
* limitations under the License.
*/
export { mockApis } from './mockApis';
export { type ApiMock, mockApis } from './mockApis';
export {
type MockApiFactorySymbol,
type ApiMock,
type MockWithApiFactory,
attachMockApiFactory,
} from './utils';
} from './MockWithApiFactory';
export {
TestApiProvider,
type TestApiProviderPropsApiPair,
type TestApiProviderPropsApiPairs,
type TestApiProviderProps,
type TestApiPair,
type TestApiPairs,
type TestApiProviderEntry,
} from './TestApiProvider';
export type { TestApiProviderProps } from './TestApiProvider';
/**
* Mock API classes are exported as types only to prevent direct instantiation.
@@ -34,6 +34,7 @@ import {
type IdentityApi,
type StorageApi,
type TranslationApi,
ApiFactory,
} from '@backstage/frontend-plugin-api';
import {
permissionApiRef,
@@ -57,11 +58,26 @@ import { MockStorageApi } from './StorageApi';
import { MockPermissionApi } from './PermissionApi';
import { MockTranslationApi } from './TranslationApi';
import {
ApiMock,
mockWithApiFactory,
mockApiFactorySymbol,
type MockWithApiFactory,
} from './utils';
} from './MockWithApiFactory';
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
/** @internal */
function simpleMock<TApi>(
@@ -38,8 +38,7 @@ import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/read
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector';
import { OpaqueExtensionDefinition } from '@internal/frontend';
import { type TestApiPairs } from '../apis';
import { resolveTestApiEntries } from '../apis/TestApiProvider';
import { resolveTestApiEntries, TestApiPairs } from '../apis/TestApiProvider';
/**
* Represents a snapshot of an extension in the app tree.
@@ -97,7 +96,7 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
/** @internal */
static forSubject<
T extends ExtensionDefinitionParameters,
TApiPairs extends any[],
const TApiPairs extends any[],
>(
subject: ExtensionDefinition<T>,
options?: {
@@ -36,10 +36,10 @@ import {
} from '@backstage/frontend-plugin-api';
import { RouterBlueprint } from '@backstage/plugin-app-react';
import appPlugin from '@backstage/plugin-app';
import { type TestApiProviderPropsApiPairs } from '../apis';
import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils';
import { getMockApiFactory } from '../apis/MockWithApiFactory';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp';
import { TestApiPairs } from '../apis/TestApiProvider';
const DEFAULT_MOCK_CONFIG = {
app: { baseUrl: 'http://localhost:3000' },
@@ -97,9 +97,7 @@ export type TestAppOptions<TApiPairs extends any[] = any[]> = {
* })
* ```
*/
apis?: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
apis?: readonly [...TestApiPairs<TApiPairs>];
};
const NavItem = (props: {
@@ -166,7 +164,7 @@ const appPluginOverride = appPlugin.withOverrides({
* @public
* Renders the given element in a test app, for use in unit tests.
*/
export function renderInTestApp<TApiPairs extends any[] = any[]>(
export function renderInTestApp<const TApiPairs extends any[] = any[]>(
element: JSX.Element,
options?: TestAppOptions<TApiPairs>,
): RenderResult {
@@ -33,10 +33,10 @@ 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 TestApiProviderPropsApiPairs } from '../apis';
import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils';
import { getMockApiFactory } from '../apis/MockWithApiFactory';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp';
import { TestApiPairs } from '../apis/TestApiProvider';
const DEFAULT_MOCK_CONFIG = {
app: { baseUrl: 'http://localhost:3000' },
@@ -99,9 +99,7 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
* })
* ```
*/
apis?: readonly [
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
];
apis?: readonly [...TestApiPairs<TApiPairs>];
};
const appPluginOverride = appPlugin.withOverrides({
@@ -118,7 +116,7 @@ const appPluginOverride = appPlugin.withOverrides({
*
* @public
*/
export function renderTestApp<TApiPairs extends any[] = any[]>(
export function renderTestApp<const TApiPairs extends any[] = any[]>(
options?: RenderTestAppOptions<TApiPairs>,
): RenderResult {
const extensions = [...(options?.extensions ?? [])];
@@ -23,9 +23,6 @@
export * from './apis';
export * from './app';
// Explicit export to satisfy API Extractor
export type { TestApiPairs } from './apis';
export { withLogCollector } from '@backstage/test-utils';
export { registerMswTestHooks } from '@backstage/test-utils';