From b9d90a7140cccf4c47168d954212133bbd2326fd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 7 Feb 2026 11:30:35 +0100 Subject: [PATCH] frontend-test-utils: review and type fixes + cleanup Signed-off-by: Patrik Oldsberg --- .changeset/beige-crabs-share.md | 2 +- .changeset/scaffolder-react-test-utils-dep.md | 5 ++ .changeset/scaffolder-test-utils-dep.md | 5 ++ .../utility-apis/05-testing.md | 5 +- packages/frontend-test-utils/report.api.md | 33 ++++++------ .../src/apis/AlertApi/MockAlertApi.test.ts | 22 ++++---- .../frontend-test-utils/src/apis/ApiMock.ts | 34 ------------ .../apis/{utils.ts => MockWithApiFactory.ts} | 16 ------ .../apis/StorageApi/MockStorageApi.test.ts | 2 - .../src/apis/TestApiProvider.tsx | 54 +++++++------------ .../frontend-test-utils/src/apis/index.ts | 11 ++-- .../frontend-test-utils/src/apis/mockApis.ts | 20 ++++++- .../src/app/createExtensionTester.tsx | 5 +- .../src/app/renderInTestApp.tsx | 10 ++-- .../src/app/renderTestApp.tsx | 10 ++-- packages/frontend-test-utils/src/index.ts | 3 -- 16 files changed, 94 insertions(+), 143 deletions(-) create mode 100644 .changeset/scaffolder-react-test-utils-dep.md create mode 100644 .changeset/scaffolder-test-utils-dep.md delete mode 100644 packages/frontend-test-utils/src/apis/ApiMock.ts rename packages/frontend-test-utils/src/apis/{utils.ts => MockWithApiFactory.ts} (87%) diff --git a/.changeset/beige-crabs-share.md b/.changeset/beige-crabs-share.md index 00d8c2c743..6a1a42ffd8 100644 --- a/.changeset/beige-crabs-share.md +++ b/.changeset/beige-crabs-share.md @@ -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`. diff --git a/.changeset/scaffolder-react-test-utils-dep.md b/.changeset/scaffolder-react-test-utils-dep.md new file mode 100644 index 0000000000..88d3ee6309 --- /dev/null +++ b/.changeset/scaffolder-react-test-utils-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests. diff --git a/.changeset/scaffolder-test-utils-dep.md b/.changeset/scaffolder-test-utils-dep.md new file mode 100644 index 0000000000..c9b092bc26 --- /dev/null +++ b/.changeset/scaffolder-test-utils-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests. diff --git a/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md index fc92eb2abb..522b7c2530 100644 --- a/docs/frontend-system/utility-apis/05-testing.md +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -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 diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index bef42bc9e0..c99007a6df 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -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 & { export { registerMswTestHooks }; // @public -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult; // @public -export function renderTestApp( +export function renderTestApp( options?: RenderTestAppOptions, ): RenderResult; @@ -565,24 +563,27 @@ export type RenderTestAppOptions = { mountedRoutes?: { [path: string]: RouteRef; }; - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; // @public -export type TestApiPairs = TestApiProviderPropsApiPairs; +export type TestApiPair = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; // @public -export const TestApiProvider: ( - props: TestApiProviderProps, -) => JSX_2.Element; +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; + +// @public +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element; // @public export type TestApiProviderProps = { - apis: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis: readonly [...TestApiPairs]; children: ReactNode; }; @@ -594,9 +595,7 @@ export type TestAppOptions = { config?: JsonObject; features?: FrontendFeature[]; initialRouteEntries?: string[]; - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; export { withLogCollector }; diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts index 4e44d4281c..ddfbacd3b9 100644 --- a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts @@ -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(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 () => { diff --git a/packages/frontend-test-utils/src/apis/ApiMock.ts b/packages/frontend-test-utils/src/apis/ApiMock.ts deleted file mode 100644 index b5ee6f242b..0000000000 --- a/packages/frontend-test-utils/src/apis/ApiMock.ts +++ /dev/null @@ -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 = { - factory: ApiFactory; - [mockApiFactorySymbol]: ApiFactory; -} & { - [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return - ? TApi[Key] & jest.MockInstance - : TApi[Key]; -}; diff --git a/packages/frontend-test-utils/src/apis/utils.ts b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts similarity index 87% rename from packages/frontend-test-utils/src/apis/utils.ts rename to packages/frontend-test-utils/src/apis/MockWithApiFactory.ts index b6f6af6753..55bb7653e5 100644 --- a/packages/frontend-test-utils/src/apis/utils.ts +++ b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts @@ -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 = { - factory: ApiFactory; - [mockApiFactorySymbol]: ApiFactory; -} & { - [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return - ? TApi[Key] & jest.MockInstance - : TApi[Key]; -}; - /** * Type for an API instance that has been marked as a mock API. * diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts index cdae96d5e4..34908f561a 100644 --- a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts @@ -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, }); }); diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx index 90dd53f61a..10615446be 100644 --- a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx @@ -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 extends infer TImpl - ? readonly [ApiRef, Partial] - : never; - -/** - * Helper type for representing an array of API reference pairs. - * @ignore - */ -export type TestApiProviderPropsApiPairs = { - [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; -}; - -/** - * 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 = TestApiProviderPropsApiPairs; +export type TestApiPair = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; /** - * 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] - | MockWithApiFactory; +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; /** @internal */ export function resolveTestApiEntries( - apis: readonly (TestApiProviderEntry | readonly [ApiRef, any])[], + apis: readonly TestApiPairs[], ): ApiHolder { const apiMap = new Map(); @@ -80,9 +66,7 @@ export function resolveTestApiEntries( * @public */ export type TestApiProviderProps = { - apis: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis: readonly [...TestApiPairs]; children: ReactNode; }; @@ -131,13 +115,13 @@ export type TestApiProviderProps = { * * @public */ -export const TestApiProvider = ( - props: TestApiProviderProps, -) => { +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element { return ( ); -}; +} diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 1a3ce150c9..3bad319786 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -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. diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 01def834e2..0b0bf18cf4 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; /** @internal */ function simpleMock( diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index ddceab52bd..3a373b3b3a 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -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 { /** @internal */ static forSubject< T extends ExtensionDefinitionParameters, - TApiPairs extends any[], + const TApiPairs extends any[], >( subject: ExtensionDefinition, options?: { diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index 79bab70653..10b36b68b4 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -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 = { * }) * ``` */ - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; 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( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult { diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index 78efb05a41..858ad462f4 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -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 = { * }) * ``` */ - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; const appPluginOverride = appPlugin.withOverrides({ @@ -118,7 +116,7 @@ const appPluginOverride = appPlugin.withOverrides({ * * @public */ -export function renderTestApp( +export function renderTestApp( options?: RenderTestAppOptions, ): RenderResult { const extensions = [...(options?.extensions ?? [])]; diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts index 03f08ab929..b4e7a4e007 100644 --- a/packages/frontend-test-utils/src/index.ts +++ b/packages/frontend-test-utils/src/index.ts @@ -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';