',
+ interpolation: { escapeValue: true },
+ }),
+ ).toBe('Foo <div>');
+ });
+
+ it('should support nesting', () => {
+ const snapshot = snapshotWithMessages({
+ foo: 'Foo $t(bar) $t(baz)',
+ bar: 'Nested',
+ baz: 'Baz {{ qux }}',
+ });
+
+ expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
+ });
+
+ it('should support jsx interpolation', () => {
+ const snapshot = snapshotWithMessages({
+ empty: 'derp',
+ jsx: '={{ x }}',
+ jsxNested: '={{ x.y.z }}',
+ jsxDeep: '<$t(jsx)>',
+ });
+
+ expect(snapshot.t('jsx', { x:
hello
})).toMatchInlineSnapshot(`
+
+ =
+
+ hello
+
+
+ `);
+ expect(snapshot.t('jsx', { replace: { x:
hello
} }))
+ .toMatchInlineSnapshot(`
+
+ =
+
+ hello
+
+
+ `);
+ expect(
+ snapshot.t('jsxNested', { replace: { x: { y: { z:
hello
} } } }),
+ ).toMatchInlineSnapshot(`
+
+ =
+
+ hello
+
+
+ `);
+ expect(snapshot.t('jsxDeep', { x:
hello
})).toMatchInlineSnapshot(`
+
+ <=
+
+ hello
+
+ >
+
+ `);
+ });
+
+ it('should support formatting', () => {
+ const snapshot = snapshotWithMessages({
+ plain: '= {{ x }}',
+ number: '= {{ x, number }}',
+ numberFixed: '= {{ x, number(minimumFractionDigits: 2) }}',
+ relativeTime: '= {{ x, relativeTime }}',
+ relativeSeconds: '= {{ x, relativeTime(second) }}',
+ relativeSecondsShort:
+ '= {{ x, relativeTime(range: second; style: short) }}',
+ list: '= {{ x, list }}',
+ });
+
+ expect(snapshot.t('plain', { x: '5' })).toBe('= 5');
+ expect(snapshot.t('number', { x: 5 })).toBe('= 5');
+ expect(
+ snapshot.t('number', {
+ x: 5,
+ formatParams: { x: { minimumFractionDigits: 1 } },
+ }),
+ ).toBe('= 5.0');
+ expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
+ expect(
+ snapshot.t('numberFixed', {
+ x: 5,
+ formatParams: { x: { minimumFractionDigits: 3 } },
+ }),
+ ).toBe('= 5.000');
+ expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
+ expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
+ expect(
+ snapshot.t('relativeTime', {
+ x: 15,
+ formatParams: { x: { range: 'weeks' } },
+ }),
+ ).toBe('= in 15 weeks');
+ expect(
+ snapshot.t('relativeTime', {
+ x: 15,
+ formatParams: { x: { range: 'weeks', style: 'short' } },
+ }),
+ ).toBe('= in 15 wk.');
+ expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
+ expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
+ expect(snapshot.t('relativeSeconds', { x: -3 })).toBe('= 3 seconds ago');
+ expect(snapshot.t('relativeSeconds', { x: 0 })).toBe('= in 0 seconds');
+ expect(snapshot.t('relativeSecondsShort', { x: 1 })).toBe('= in 1 sec.');
+ expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2 sec.');
+ expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3 sec. ago');
+ expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0 sec.');
+ expect(snapshot.t('list', { x: ['a'] })).toBe('= a');
+ expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b');
+ expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c');
+ });
+
+ it('should support plurals', () => {
+ const snapshot = snapshotWithMessages({
+ derp_one: 'derp',
+ derp_other: 'derps',
+ derpWithCount_one: '{{ count }} derp',
+ derpWithCount_other: '{{ count }} derps',
+ });
+
+ expect(snapshot.t('derp', { count: 1 })).toBe('derp');
+ expect(snapshot.t('derp', { count: 2 })).toBe('derps');
+ expect(snapshot.t('derp', { count: 0 })).toBe('derps');
+ expect(snapshot.t('derpWithCount', { count: 1 })).toBe('1 derp');
+ expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
+ expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
+ });
+});
diff --git a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts
new file mode 100644
index 0000000000..d5fa29ea73
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+
+import {
+ TranslationApi,
+ TranslationRef,
+ TranslationSnapshot,
+} from '@backstage/core-plugin-api/alpha';
+import { createInstance as createI18n, type i18n as I18n } from 'i18next';
+import ObservableImpl from 'zen-observable';
+
+import { Observable } from '@backstage/types';
+// Internal import to avoid code duplication, this will lead to duplication in build output
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { toInternalTranslationRef } from '../../../../frontend-plugin-api/src/translation/TranslationRef';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { JsxInterpolator } from '../../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
+
+const DEFAULT_LANGUAGE = 'en';
+
+/**
+ * Mock implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
+ *
+ * @public
+ */
+export class MockTranslationApi implements TranslationApi {
+ static create() {
+ const i18n = createI18n({
+ fallbackLng: DEFAULT_LANGUAGE,
+ supportedLngs: [DEFAULT_LANGUAGE],
+ interpolation: {
+ escapeValue: false,
+ // Used for the JsxInterpolator format hook
+ alwaysFormat: true,
+ },
+ ns: [],
+ defaultNS: false,
+ fallbackNS: false,
+
+ // Disable resource loading on init, meaning i18n will be ready to use immediately
+ initImmediate: false,
+ });
+
+ i18n.init();
+ if (!i18n.isInitialized) {
+ throw new Error('i18next was unexpectedly not initialized');
+ }
+
+ const interpolator = JsxInterpolator.fromI18n(i18n);
+
+ return new MockTranslationApi(i18n, interpolator);
+ }
+
+ readonly #i18n: I18n;
+ readonly #interpolator: JsxInterpolator;
+ readonly #registeredRefs = new Set
();
+
+ private constructor(i18n: I18n, interpolator: JsxInterpolator) {
+ this.#i18n = i18n;
+ this.#interpolator = interpolator;
+ }
+
+ getTranslation(
+ translationRef: TranslationRef,
+ ): TranslationSnapshot {
+ const internalRef = toInternalTranslationRef(translationRef);
+
+ if (!this.#registeredRefs.has(internalRef.id)) {
+ this.#registeredRefs.add(internalRef.id);
+ this.#i18n.addResourceBundle(
+ DEFAULT_LANGUAGE,
+ internalRef.id,
+ internalRef.getDefaultMessages(),
+ false, // do not merge
+ true, // overwrite existing
+ );
+ }
+
+ const t = this.#interpolator.wrapT(
+ this.#i18n.getFixedT(null, internalRef.id),
+ );
+
+ return {
+ ready: true,
+ t,
+ };
+ }
+
+ translation$(): Observable<
+ TranslationSnapshot
+ > {
+ // No need to implement, getTranslation will always return a ready snapshot
+ return new ObservableImpl>(_subscriber => {
+ return () => {};
+ });
+ }
+}
diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts
similarity index 72%
rename from packages/frontend-test-utils/src/utils/index.ts
rename to packages/frontend-test-utils/src/apis/TranslationApi/index.ts
index 2f6bfb5f9c..2c10347545 100644
--- a/packages/frontend-test-utils/src/utils/index.ts
+++ b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts
@@ -14,11 +14,4 @@
* limitations under the License.
*/
-export {
- TestApiProvider,
- TestApiRegistry,
- type TestApiProviderPropsApiPair,
- type TestApiProviderPropsApiPairs,
- type TestApiPairs,
-} from './TestApiProvider';
-export type { TestApiProviderProps } from './TestApiProvider';
+export { MockTranslationApi } from './MockTranslationApi';
diff --git a/packages/frontend-test-utils/src/apis/createApiMock.test.ts b/packages/frontend-test-utils/src/apis/createApiMock.test.ts
new file mode 100644
index 0000000000..8b03b6df75
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/createApiMock.test.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2025 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 { createApiRef } from '@backstage/frontend-plugin-api';
+import { createApiMock } from './createApiMock';
+import { getMockApiFactory } from './MockWithApiFactory';
+
+describe('createApiMock', () => {
+ type TestApi = {
+ greet(name: string): string;
+ count: number;
+ };
+
+ const testApiRef = createApiRef({ id: 'test.create-mock' });
+
+ it('returns a factory function that produces jest mocks', () => {
+ const mock = createApiMock(testApiRef, () => ({
+ greet: jest.fn(),
+ count: 0 as any,
+ }));
+
+ const api = mock();
+ api.greet('world');
+ expect(api.greet).toHaveBeenCalledTimes(1);
+ expect(api.greet).toHaveBeenCalledWith('world');
+ });
+
+ it('applies partial implementations via mockImplementation', () => {
+ const mock = createApiMock(testApiRef, () => ({
+ greet: jest.fn(),
+ count: 0 as any,
+ }));
+
+ const api = mock({ greet: (name: string) => `Hello ${name}!` });
+ expect(api.greet('world')).toBe('Hello world!');
+ expect(api.greet).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves non-function partial values', () => {
+ const mock = createApiMock(testApiRef, () => ({
+ greet: jest.fn(),
+ count: 0 as any,
+ }));
+
+ const api = mock({ count: 42 });
+ expect(api.count).toBe(42);
+ });
+
+ it('attaches a mock API factory via the symbol', () => {
+ const mock = createApiMock(testApiRef, () => ({
+ greet: jest.fn(),
+ count: 0 as any,
+ }));
+
+ const api = mock();
+ const factory = getMockApiFactory(api);
+ expect(factory).toBeDefined();
+ expect(factory!.api).toBe(testApiRef);
+ });
+
+ it('creates fresh mocks on each call', () => {
+ const mock = createApiMock(testApiRef, () => ({
+ greet: jest.fn(),
+ count: 0 as any,
+ }));
+
+ const api1 = mock();
+ const api2 = mock();
+ api1.greet('a');
+ expect(api1.greet).toHaveBeenCalledTimes(1);
+ expect(api2.greet).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/packages/frontend-test-utils/src/apis/createApiMock.ts b/packages/frontend-test-utils/src/apis/createApiMock.ts
new file mode 100644
index 0000000000..759a8a1616
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/createApiMock.ts
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 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, type ApiRef } from '@backstage/frontend-plugin-api';
+import { mockApiFactorySymbol } 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 = {
+ [mockApiFactorySymbol]: ApiFactory;
+} & {
+ [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
+ ? TApi[Key] & jest.MockInstance
+ : TApi[Key];
+};
+
+/**
+ * Creates a standardized Backstage Utility API mockfactory function for
+ * producing mock API instances.
+ *
+ * @remarks
+ *
+ * Each method in the mock factory is a `jest.fn()`, and you can optionally pass
+ * partial implementations when calling the returned function. No type
+ * parameters should be provided to this function, they will be inferred from
+ * the provided API reference.
+ *
+ * @public
+ * @example
+ * ```ts
+ * import { createApiMock } from '@backstage/frontend-test-utils';
+ * import { myApiRef } from '../apis';
+ *
+ * // Set up the mock factory
+ * const mock = createApiMock(myApiRef, () => ({
+ * greet: jest.fn(),
+ * }));
+ *
+ * // Create a mock with default behavior
+ * const api = mock();
+ *
+ * // Or with a partial implementation
+ * const api = mock({ greet: async () => 'Hello!' });
+ * expect(api.greet).toHaveBeenCalledTimes(1);
+ * ```
+ */
+export function createApiMock(
+ apiRef: ApiRef,
+ mockFactory: () => jest.Mocked,
+): (partialImpl?: Partial) => ApiMock {
+ return partialImpl => {
+ const mock = mockFactory();
+ if (partialImpl) {
+ for (const [key, impl] of Object.entries(partialImpl)) {
+ if (typeof impl === 'function') {
+ (mock as any)[key].mockImplementation(impl);
+ } else {
+ (mock as any)[key] = impl;
+ }
+ }
+ }
+ (mock as any)[mockApiFactorySymbol] = {
+ api: apiRef,
+ deps: {},
+ factory: () => mock,
+ };
+ return mock as unknown as ApiMock;
+ };
+}
diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts
index e5a0786cd0..6e09833298 100644
--- a/packages/frontend-test-utils/src/apis/index.ts
+++ b/packages/frontend-test-utils/src/apis/index.ts
@@ -14,18 +14,73 @@
* limitations under the License.
*/
+export { mockApis } from './mockApis';
+export { createApiMock, type ApiMock } from './createApiMock';
export {
- MockConfigApi,
- type ErrorWithContext,
- MockErrorApi,
- type MockErrorApiOptions,
- MockFetchApi,
- type MockFetchApiOptions,
- MockPermissionApi,
- MockStorageApi,
- type MockStorageBucket,
- mockApis,
- type ApiMock,
-} from '@backstage/test-utils';
+ type MockApiFactorySymbol,
+ type MockWithApiFactory,
+ attachMockApiFactory,
+} from './MockWithApiFactory';
+export {
+ TestApiProvider,
+ type TestApiProviderProps,
+ type TestApiPair,
+ type TestApiPairs,
+} from './TestApiProvider';
-export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
+/**
+ * Mock API classes are exported as types only to prevent direct instantiation.
+ * Always use the `mockApis` namespace to create mock instances (e.g., `mockApis.alert()`).
+ */
+
+/**
+ * @public
+ */
+export type { MockAlertApi } from './AlertApi';
+
+/**
+ * @public
+ */
+export type { MockAnalyticsApi } from './AnalyticsApi';
+
+/**
+ * @public
+ */
+export type { MockConfigApi } from './ConfigApi';
+
+/**
+ * @public
+ */
+export type {
+ MockErrorApi,
+ MockErrorApiOptions,
+ ErrorWithContext,
+} from './ErrorApi';
+
+/**
+ * @public
+ */
+export type { MockFetchApi, MockFetchApiOptions } from './FetchApi';
+
+/**
+ * @public
+ */
+export type {
+ MockFeatureFlagsApi,
+ MockFeatureFlagsApiOptions,
+} from './FeatureFlagsApi';
+
+/**
+ * @public
+ */
+export type { MockPermissionApi } from './PermissionApi';
+
+/**
+ * @public
+ */
+export type { MockStorageApi } from './StorageApi';
+
+/**
+ * @public
+ */
+export type { MockTranslationApi } from './TranslationApi';
diff --git a/packages/frontend-test-utils/src/apis/mockApis.test.ts b/packages/frontend-test-utils/src/apis/mockApis.test.ts
new file mode 100644
index 0000000000..9c6310ca9c
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/mockApis.test.ts
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2025 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 { FeatureFlagState } from '@backstage/frontend-plugin-api';
+import { mockApis } from './mockApis';
+
+describe('mockApis', () => {
+ describe('alert', () => {
+ it('can create an instance', () => {
+ const alert = mockApis.alert();
+ alert.post({ message: 'test alert' });
+ expect(alert.getAlerts()).toHaveLength(1);
+ expect(alert.getAlerts()[0]).toMatchObject({ message: 'test alert' });
+ });
+
+ it('can clear alerts', () => {
+ const alert = mockApis.alert();
+ alert.post({ message: 'test' });
+ expect(alert.getAlerts()).toHaveLength(1);
+ alert.clearAlerts();
+ expect(alert.getAlerts()).toHaveLength(0);
+ });
+
+ it('can create a mock and make assertions on it', () => {
+ const alert = mockApis.alert.mock({
+ post: jest.fn(msg => {
+ expect(msg).toMatchObject({ message: 'test' });
+ }),
+ });
+ alert.post({ message: 'test' });
+ expect(alert.post).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('featureFlags', () => {
+ it('can create an instance', () => {
+ const featureFlags = mockApis.featureFlags({
+ initialStates: { 'test-flag': FeatureFlagState.Active },
+ });
+ expect(featureFlags.isActive('test-flag')).toBe(true);
+ expect(featureFlags.isActive('other-flag')).toBe(false);
+ });
+
+ it('can save and merge state', () => {
+ const featureFlags = mockApis.featureFlags({
+ initialStates: { 'flag-1': FeatureFlagState.Active },
+ });
+
+ featureFlags.save({
+ states: { 'flag-2': FeatureFlagState.Active },
+ merge: true,
+ });
+
+ expect(featureFlags.isActive('flag-1')).toBe(true);
+ expect(featureFlags.isActive('flag-2')).toBe(true);
+ });
+
+ it('can set and clear state using helper methods', () => {
+ const featureFlags = mockApis.featureFlags();
+ featureFlags.setState({ 'test-flag': FeatureFlagState.Active });
+ expect(featureFlags.getState()).toEqual({
+ 'test-flag': FeatureFlagState.Active,
+ });
+ featureFlags.clearState();
+ expect(featureFlags.getState()).toEqual({});
+ });
+
+ it('can create a mock and make assertions on it', () => {
+ const featureFlags = mockApis.featureFlags.mock({
+ isActive: jest.fn(() => true),
+ });
+ expect(featureFlags.isActive('test')).toBe(true);
+ expect(featureFlags.isActive).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('re-exported APIs from test-utils', () => {
+ it('should have analytics', () => {
+ expect(mockApis.analytics).toBeDefined();
+ });
+
+ it('should have config', () => {
+ expect(mockApis.config).toBeDefined();
+ });
+
+ it('should have discovery', () => {
+ expect(mockApis.discovery).toBeDefined();
+ });
+
+ it('should have identity', () => {
+ expect(mockApis.identity).toBeDefined();
+ });
+
+ it('should have permission', () => {
+ expect(mockApis.permission).toBeDefined();
+ });
+
+ it('should have storage', () => {
+ expect(mockApis.storage).toBeDefined();
+ });
+
+ it('should have translation', () => {
+ expect(mockApis.translation).toBeDefined();
+ });
+ });
+});
diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts
new file mode 100644
index 0000000000..9e3dce1e3a
--- /dev/null
+++ b/packages/frontend-test-utils/src/apis/mockApis.ts
@@ -0,0 +1,465 @@
+/*
+ * Copyright 2025 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 {
+ alertApiRef,
+ analyticsApiRef,
+ configApiRef,
+ discoveryApiRef,
+ errorApiRef,
+ fetchApiRef,
+ featureFlagsApiRef,
+ identityApiRef,
+ storageApiRef,
+ translationApiRef,
+ type AnalyticsApi,
+ type ConfigApi,
+ type DiscoveryApi,
+ type ErrorApi,
+ type FetchApi,
+ type IdentityApi,
+ type StorageApi,
+ type TranslationApi,
+} from '@backstage/frontend-plugin-api';
+import {
+ permissionApiRef,
+ type PermissionApi,
+} from '@backstage/plugin-permission-react';
+import { JsonObject } from '@backstage/types';
+import {
+ AuthorizeResult,
+ EvaluatePermissionRequest,
+} from '@backstage/plugin-permission-common';
+import { MockAlertApi } from './AlertApi';
+import {
+ MockFeatureFlagsApi,
+ MockFeatureFlagsApiOptions,
+} from './FeatureFlagsApi';
+import { MockAnalyticsApi } from './AnalyticsApi';
+import { MockConfigApi } from './ConfigApi';
+import { MockErrorApi, MockErrorApiOptions } from './ErrorApi';
+import { MockFetchApi, MockFetchApiOptions } from './FetchApi';
+import { MockStorageApi } from './StorageApi';
+import { MockPermissionApi } from './PermissionApi';
+import { MockTranslationApi } from './TranslationApi';
+import {
+ mockWithApiFactory,
+ type MockWithApiFactory,
+} from './MockWithApiFactory';
+import { createApiMock } from './createApiMock';
+
+/**
+ * Mock implementations of the core utility APIs, to be used in tests.
+ *
+ * @public
+ * @remarks
+ *
+ * There are some variations among the APIs depending on what needs tests
+ * might have, but overall there are two main usage patterns:
+ *
+ * 1: Creating an actual fake API instance, often with a simplified version
+ * of functionality, by calling the mock API itself as a function.
+ *
+ * ```ts
+ * // The function often accepts parameters that control its behavior
+ * const foo = mockApis.foo();
+ * ```
+ *
+ * 2: Creating a mock API, where all methods are replaced with jest mocks, by
+ * calling the API's `mock` function.
+ *
+ * ```ts
+ * // You can optionally supply a subset of its methods to implement
+ * const foo = mockApis.foo.mock({
+ * someMethod: () => 'mocked result',
+ * });
+ * // After exercising your test, you can make assertions on the mock:
+ * expect(foo.someMethod).toHaveBeenCalledTimes(2);
+ * expect(foo.otherMethod).toHaveBeenCalledWith(testData);
+ * ```
+ */
+export namespace mockApis {
+ /**
+ * Fake implementation of {@link @backstage/frontend-plugin-api#AlertApi}.
+ *
+ * @public
+ * @example
+ *
+ * ```tsx
+ * const alertApi = mockApis.alert();
+ * alertApi.post({ message: 'Test alert' });
+ * expect(alertApi.getAlerts()).toHaveLength(1);
+ * ```
+ */
+ export function alert(): MockWithApiFactory {
+ const instance = new MockAlertApi();
+ return mockWithApiFactory(
+ alertApiRef,
+ instance,
+ ) as MockWithApiFactory;
+ }
+ /**
+ * Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
+ *
+ * @see {@link @backstage/frontend-plugin-api#mockApis.alert}
+ * @public
+ */
+ export namespace alert {
+ /**
+ * Creates a mock implementation of
+ * {@link @backstage/frontend-plugin-api#AlertApi}. All methods are
+ * replaced with jest mock functions, and you can optionally pass in a
+ * subset of methods with an explicit implementation.
+ *
+ * @public
+ */
+ export const mock = createApiMock(alertApiRef, () => ({
+ post: jest.fn(),
+ alert$: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
+ *
+ * @public
+ * @example
+ *
+ * ```tsx
+ * const featureFlagsApi = mockApis.featureFlags({
+ * initialStates: { 'my-feature': FeatureFlagState.Active },
+ * });
+ * expect(featureFlagsApi.isActive('my-feature')).toBe(true);
+ * ```
+ */
+ export function featureFlags(
+ options?: MockFeatureFlagsApiOptions,
+ ): MockWithApiFactory {
+ const instance = new MockFeatureFlagsApi(options);
+ return mockWithApiFactory(
+ featureFlagsApiRef,
+ instance,
+ ) as MockWithApiFactory;
+ }
+ /**
+ * Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
+ *
+ * @see {@link @backstage/frontend-plugin-api#mockApis.featureFlags}
+ * @public
+ */
+ export namespace featureFlags {
+ /**
+ * Creates a mock implementation of
+ * {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. All methods are
+ * replaced with jest mock functions, and you can optionally pass in a
+ * subset of methods with an explicit implementation.
+ *
+ * @public
+ */
+ export const mock = createApiMock(featureFlagsApiRef, () => ({
+ registerFlag: jest.fn(),
+ getRegisteredFlags: jest.fn(),
+ isActive: jest.fn(),
+ save: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#AnalyticsApi}.
+ *
+ * @public
+ */
+ export function analytics(): MockAnalyticsApi &
+ MockWithApiFactory {
+ const instance = new MockAnalyticsApi();
+ return mockWithApiFactory(analyticsApiRef, instance) as MockAnalyticsApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#AnalyticsApi}.
+ *
+ * @public
+ */
+ export namespace analytics {
+ export const mock = createApiMock(analyticsApiRef, () => ({
+ captureEvent: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
+ * By default returns the default translation.
+ *
+ * @public
+ */
+ export function translation(): MockTranslationApi &
+ MockWithApiFactory {
+ const instance = MockTranslationApi.create();
+ return mockWithApiFactory(
+ translationApiRef,
+ instance,
+ ) as MockTranslationApi & MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api/alpha#TranslationApi}.
+ *
+ * @see {@link @backstage/frontend-plugin-api#mockApis.translation}
+ * @public
+ */
+ export namespace translation {
+ /**
+ * Creates a mock of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
+ *
+ * @public
+ */
+ export const mock = createApiMock(translationApiRef, () => ({
+ getTranslation: jest.fn(),
+ translation$: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#ConfigApi}.
+ *
+ * @public
+ */
+ export function config(options?: {
+ data?: JsonObject;
+ }): MockConfigApi & MockWithApiFactory {
+ const instance = new MockConfigApi({ data: options?.data ?? {} });
+ return mockWithApiFactory(configApiRef, instance) as MockConfigApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#ConfigApi}.
+ *
+ * @public
+ */
+ export namespace config {
+ export const mock = createApiMock(configApiRef, () => ({
+ has: jest.fn(),
+ keys: jest.fn(),
+ get: jest.fn(),
+ getOptional: jest.fn(),
+ getConfig: jest.fn(),
+ getOptionalConfig: jest.fn(),
+ getConfigArray: jest.fn(),
+ getOptionalConfigArray: jest.fn(),
+ getNumber: jest.fn(),
+ getOptionalNumber: jest.fn(),
+ getBoolean: jest.fn(),
+ getOptionalBoolean: jest.fn(),
+ getString: jest.fn(),
+ getOptionalString: jest.fn(),
+ getStringArray: jest.fn(),
+ getOptionalStringArray: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#DiscoveryApi}.
+ *
+ * @public
+ */
+ export function discovery(options?: {
+ baseUrl?: string;
+ }): DiscoveryApi & MockWithApiFactory {
+ const baseUrl = options?.baseUrl ?? 'http://example.com';
+ const instance: DiscoveryApi = {
+ async getBaseUrl(pluginId: string) {
+ return `${baseUrl}/api/${pluginId}`;
+ },
+ };
+ return mockWithApiFactory(discoveryApiRef, instance) as DiscoveryApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#DiscoveryApi}.
+ *
+ * @public
+ */
+ export namespace discovery {
+ export const mock = createApiMock(discoveryApiRef, () => ({
+ getBaseUrl: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#IdentityApi}.
+ *
+ * @public
+ */
+ export function identity(options?: {
+ userEntityRef?: string;
+ ownershipEntityRefs?: string[];
+ token?: string;
+ email?: string;
+ displayName?: string;
+ picture?: string;
+ }): MockWithApiFactory {
+ const {
+ userEntityRef = 'user:default/test',
+ ownershipEntityRefs = ['user:default/test'],
+ token,
+ email,
+ displayName,
+ picture,
+ } = options ?? {};
+ const instance: IdentityApi = {
+ async getBackstageIdentity() {
+ return { type: 'user', ownershipEntityRefs, userEntityRef };
+ },
+ async getCredentials() {
+ return { token };
+ },
+ async getProfileInfo() {
+ return { email, displayName, picture };
+ },
+ async signOut() {},
+ };
+ return mockWithApiFactory(identityApiRef, instance) as IdentityApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#IdentityApi}.
+ *
+ * @public
+ */
+ export namespace identity {
+ export const mock = createApiMock(identityApiRef, () => ({
+ getBackstageIdentity: jest.fn(),
+ getCredentials: jest.fn(),
+ getProfileInfo: jest.fn(),
+ signOut: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/plugin-permission-react#PermissionApi}.
+ *
+ * @public
+ */
+ export function permission(options?: {
+ authorize?:
+ | AuthorizeResult.ALLOW
+ | AuthorizeResult.DENY
+ | ((
+ request: EvaluatePermissionRequest,
+ ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY);
+ }): MockPermissionApi & MockWithApiFactory {
+ const authorizeInput = options?.authorize;
+ const handler =
+ typeof authorizeInput === 'function'
+ ? authorizeInput
+ : () => authorizeInput ?? AuthorizeResult.ALLOW;
+ const instance = new MockPermissionApi(handler);
+ return mockWithApiFactory(permissionApiRef, instance) as MockPermissionApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/plugin-permission-react#PermissionApi}.
+ *
+ * @public
+ */
+ export namespace permission {
+ export const mock = createApiMock(permissionApiRef, () => ({
+ authorize: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#StorageApi}.
+ *
+ * @public
+ */
+ export function storage(options?: {
+ data?: JsonObject;
+ }): MockStorageApi & MockWithApiFactory {
+ const instance = MockStorageApi.create(options?.data);
+ return mockWithApiFactory(storageApiRef, instance) as MockStorageApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#StorageApi}.
+ *
+ * @public
+ */
+ export namespace storage {
+ export const mock = createApiMock(storageApiRef, () => ({
+ forBucket: jest.fn(),
+ snapshot: jest.fn(),
+ set: jest.fn(),
+ remove: jest.fn(),
+ observe$: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#ErrorApi}.
+ *
+ * @public
+ */
+ export function error(
+ options?: MockErrorApiOptions,
+ ): MockErrorApi & MockWithApiFactory {
+ const instance = new MockErrorApi(options);
+ return mockWithApiFactory(errorApiRef, instance) as MockErrorApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#ErrorApi}.
+ *
+ * @public
+ */
+ export namespace error {
+ export const mock = createApiMock(errorApiRef, () => ({
+ post: jest.fn(),
+ error$: jest.fn(),
+ }));
+ }
+
+ /**
+ * Fake implementation of {@link @backstage/core-plugin-api#FetchApi}.
+ *
+ * @public
+ */
+ export function fetch(
+ options?: MockFetchApiOptions,
+ ): MockFetchApi & MockWithApiFactory {
+ const instance = new MockFetchApi(options);
+ return mockWithApiFactory(fetchApiRef, instance) as MockFetchApi &
+ MockWithApiFactory;
+ }
+
+ /**
+ * Mock helpers for {@link @backstage/core-plugin-api#FetchApi}.
+ *
+ * @public
+ */
+ export namespace fetch {
+ export const mock = createApiMock(fetchApiRef, () => ({
+ fetch: jest.fn(),
+ }));
+ }
+}
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
index 8293872e29..9ce1863186 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
@@ -173,11 +173,11 @@ describe('createExtensionTester', () => {
});
const tester = createExtensionTester(extension, {
- apis: [[analyticsApiRef, analyticsApiMock]],
+ apis: [[analyticsApiRef, analyticsApiMock] as const],
});
renderInTestApp(tester.reactElement(), {
- apis: [[analyticsApiRef, analyticsApiMock]],
+ apis: [[analyticsApiRef, analyticsApiMock] as const],
});
expect(screen.getByText('Test')).toBeInTheDocument();
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx
index 91a7bf9022..3a373b3b3a 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx
@@ -38,7 +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 { TestApiRegistry, type TestApiPairs } from '../utils';
+import { resolveTestApiEntries, TestApiPairs } from '../apis/TestApiProvider';
/**
* Represents a snapshot of an extension in the app tree.
@@ -96,7 +96,7 @@ export class ExtensionTester {
/** @internal */
static forSubject<
T extends ExtensionDefinitionParameters,
- TApiPairs extends any[],
+ const TApiPairs extends any[],
>(
subject: ExtensionDefinition,
options?: {
@@ -281,9 +281,7 @@ export class ExtensionTester {
collector,
);
- const apiHolder = this.#apis
- ? TestApiRegistry.from(...this.#apis)
- : TestApiRegistry.from();
+ const apiHolder = resolveTestApiEntries(this.#apis ?? []);
instantiateAppNodeTree(tree.root, apiHolder, collector);
diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
index 334722f38a..c9d90715f6 100644
--- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
+++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
@@ -16,11 +16,8 @@
import { useCallback } from 'react';
import { screen, fireEvent } from '@testing-library/react';
-import {
- MockAnalyticsApi,
- TestApiProvider,
-} from '@backstage/frontend-test-utils';
-import { analyticsApiRef, useAnalytics } from '@backstage/frontend-plugin-api';
+import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils';
+import { useAnalytics } from '@backstage/frontend-plugin-api';
import { Routes, Route } from 'react-router-dom';
import { renderInTestApp } from './renderInTestApp';
@@ -47,10 +44,10 @@ describe('renderInTestApp', () => {
);
};
- const analyticsApiMock = new MockAnalyticsApi();
+ const analyticsApiMock = mockApis.analytics();
renderInTestApp(
-
+
,
);
@@ -94,10 +91,10 @@ describe('renderInTestApp', () => {
);
};
- const analyticsApiMock = new MockAnalyticsApi();
+ const analyticsApiMock = mockApis.analytics();
renderInTestApp(, {
- apis: [[analyticsApiRef, analyticsApiMock]],
+ apis: [analyticsApiMock],
});
fireEvent.click(screen.getByRole('button', { name: 'Click me' }));
diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx
index bcc6e5948e..10b36b68b4 100644
--- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx
+++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx
@@ -32,12 +32,14 @@ import {
FrontendFeature,
createFrontendModule,
createApiFactory,
+ type ApiRef,
} from '@backstage/frontend-plugin-api';
import { RouterBlueprint } from '@backstage/plugin-app-react';
import appPlugin from '@backstage/plugin-app';
-import { type TestApiPairs } from '../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' },
@@ -88,11 +90,10 @@ export type TestAppOptions = {
*
* @example
* ```ts
- * import { identityApiRef } from '@backstage/frontend-plugin-api';
* import { mockApis } from '@backstage/frontend-test-utils';
*
* renderInTestApp(, {
- * apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]],
+ * apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })],
* })
* ```
*/
@@ -163,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 {
@@ -241,9 +242,14 @@ export function renderInTestApp(
},
]),
__internal: options?.apis && {
- apiFactoryOverrides: options.apis.map(([apiRef, implementation]) =>
- createApiFactory(apiRef, implementation),
- ),
+ apiFactoryOverrides: options.apis.map(entry => {
+ const mockFactory = getMockApiFactory(entry);
+ if (mockFactory) {
+ return mockFactory;
+ }
+ const [apiRef, implementation] = entry as readonly [ApiRef, any];
+ return createApiFactory(apiRef, implementation);
+ }),
},
} as CreateSpecializedAppInternalOptions);
diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx
index b83ae7832a..858ad462f4 100644
--- a/packages/frontend-test-utils/src/app/renderTestApp.tsx
+++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx
@@ -25,16 +25,18 @@ import {
ExtensionDefinition,
FrontendFeature,
RouteRef,
+ type ApiRef,
} from '@backstage/frontend-plugin-api';
-import { render } from '@testing-library/react';
+import { render, type RenderResult } from '@testing-library/react';
import appPlugin from '@backstage/plugin-app';
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';
+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' },
@@ -89,11 +91,10 @@ export type RenderTestAppOptions = {
*
* @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' })]],
+ * apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })],
* extensions: [...],
* })
* ```
@@ -115,12 +116,12 @@ const appPluginOverride = appPlugin.withOverrides({
*
* @public
*/
-export function renderTestApp(
- options: RenderTestAppOptions,
-) {
- const extensions = [...(options.extensions ?? [])];
+export function renderTestApp(
+ options?: RenderTestAppOptions,
+): RenderResult {
+ const extensions = [...(options?.extensions ?? [])];
- if (options.mountedRoutes) {
+ if (options?.mountedRoutes) {
for (const [path, routeRef] of Object.entries(options.mountedRoutes)) {
extensions.push(
createExtension({
@@ -150,7 +151,7 @@ export function renderTestApp(
params: {
component: ({ children }) => (
(
appPluginOverride,
];
- if (options.features) {
+ if (options?.features) {
features.push(...options.features);
}
@@ -183,9 +184,14 @@ export function renderTestApp(
},
]),
__internal: options?.apis && {
- apiFactoryOverrides: options.apis.map(([apiRef, implementation]) =>
- createApiFactory(apiRef, implementation),
- ),
+ apiFactoryOverrides: options.apis.map(entry => {
+ const mockFactory = getMockApiFactory(entry);
+ if (mockFactory) {
+ return mockFactory;
+ }
+ const [apiRef, implementation] = entry as readonly [ApiRef, any];
+ return createApiFactory(apiRef, implementation);
+ }),
},
} as CreateSpecializedAppInternalOptions);
diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts
index b709e7ac03..b4e7a4e007 100644
--- a/packages/frontend-test-utils/src/index.ts
+++ b/packages/frontend-test-utils/src/index.ts
@@ -22,10 +22,6 @@
export * from './apis';
export * from './app';
-export * from './utils';
-
-// Explicit export to satisfy API Extractor
-export type { TestApiPairs } from './utils';
export { withLogCollector } from '@backstage/test-utils';
diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx
deleted file mode 100644
index 97529033d0..0000000000
--- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-/*
- * 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 extends infer TImpl
- ? readonly [ApiRef, Partial]
- : never;
-
-/**
- * Helper type for representing an array of API reference pairs.
- * @public
- */
-export type TestApiProviderPropsApiPairs = {
- [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair;
-};
-
-/**
- * Shorter alias for TestApiProviderPropsApiPairs for use in function signatures.
- * @public
- */
-export type TestApiPairs = TestApiProviderPropsApiPairs;
-
-/**
- * Properties for the {@link TestApiProvider} component.
- *
- * @public
- */
-export type TestApiProviderProps = {
- apis: readonly [...TestApiProviderPropsApiPairs];
- 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(
- ...apis: readonly [...TestApiProviderPropsApiPairs]
- ) {
- return new TestApiRegistry(
- new Map(apis.map(([api, impl]) => [api.id, impl])),
- );
- }
-
- private constructor(private readonly apis: Map) {}
-
- /**
- * Returns an implementation of the API.
- *
- * @public
- */
- get(api: ApiRef): 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(
- *
- *
- *
- * );
- * ```
- *
- * @public
- */
-export const TestApiProvider = (
- props: TestApiProviderProps,
-) => {
- return (
-
- );
-};
diff --git a/packages/repo-tools/src/commands/type-deps/type-deps.ts b/packages/repo-tools/src/commands/type-deps/type-deps.ts
index d6049f347c..80d30a41a8 100644
--- a/packages/repo-tools/src/commands/type-deps/type-deps.ts
+++ b/packages/repo-tools/src/commands/type-deps/type-deps.ts
@@ -89,7 +89,21 @@ function findAllDeps(declSrc: string) {
.filter(n => !n.startsWith('.'))
// We allow references to these without an explicit dependency.
.filter(n => !['node', 'react'].includes(n));
- return Array.from(new Set([...importedDeps, ...referencedDeps]));
+
+ // Detect ambient global type namespaces (e.g. jest.Mocked, jest.MockInstance)
+ // that are used in type positions but don't appear as explicit imports.
+ // Strip comments first to avoid false positives from JSDoc examples.
+ const strippedSrc = declSrc
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .replace(/\/\/.*$/gm, '');
+ const ambientDeps: string[] = [];
+ if (/\bjest\.\w/.test(strippedSrc)) {
+ ambientDeps.push('jest');
+ }
+
+ return Array.from(
+ new Set([...importedDeps, ...referencedDeps, ...ambientDeps]),
+ );
}
/**
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index f6127ee5c7..591cb149e8 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -74,12 +74,16 @@
},
"peerDependencies": {
"@testing-library/react": "^16.0.0",
+ "@types/jest": "*",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.30.2"
},
"peerDependenciesMeta": {
+ "@types/jest": {
+ "optional": true
+ },
"@types/react": {
"optional": true
}
diff --git a/plugins/catalog-graph/src/alpha.test.tsx b/plugins/catalog-graph/src/alpha.test.tsx
index 277f6bcc28..e0241ec7b0 100644
--- a/plugins/catalog-graph/src/alpha.test.tsx
+++ b/plugins/catalog-graph/src/alpha.test.tsx
@@ -21,7 +21,6 @@ import {
createTestEntityPage,
catalogApiMock,
} from '@backstage/plugin-catalog-react/testUtils';
-import { catalogApiRef } from '@backstage/plugin-catalog-react';
import catalogGraphPlugin from './alpha';
import { catalogGraphRouteRef } from './routes';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api';
@@ -58,7 +57,7 @@ describe('catalog-graph alpha plugin', () => {
'/catalog-graph': catalogGraphRouteRef,
},
apis: [
- [catalogApiRef, catalogApiMock({ entities: [entity] })],
+ catalogApiMock({ entities: [entity] }),
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
],
});
@@ -95,7 +94,7 @@ describe('catalog-graph alpha plugin', () => {
'/catalog-graph': catalogGraphRouteRef,
},
apis: [
- [catalogApiRef, catalogApiMock({ entities: [entity] })],
+ catalogApiMock({ entities: [entity] }),
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
],
});
@@ -131,7 +130,7 @@ describe('catalog-graph alpha plugin', () => {
'/catalog-graph': catalogGraphRouteRef,
},
apis: [
- [catalogApiRef, catalogApiMock({ entities: [entity] })],
+ catalogApiMock({ entities: [entity] }),
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
],
});
diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx
index 4349d7005a..b8822de1c1 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx
@@ -14,19 +14,12 @@
* limitations under the License.
*/
-import { ApiProvider } from '@backstage/core-app-api';
-import { AlertApi, alertApiRef, errorApiRef } from '@backstage/core-plugin-api';
-import { catalogApiRef } from '@backstage/plugin-catalog-react';
-import {
- mockApis,
- renderWithEffects,
- TestApiRegistry,
-} from '@backstage/test-utils';
+import { renderWithEffects } from '@backstage/test-utils';
+import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils';
import { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SelectedKindsFilter } from './SelectedKindsFilter';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
-import { translationApiRef } from '@backstage/core-plugin-api/alpha';
const catalogApi = catalogApiMock.mock({
getEntityFacets: jest.fn().mockResolvedValue({
@@ -40,28 +33,28 @@ const catalogApi = catalogApiMock.mock({
},
}),
});
-const apis = TestApiRegistry.from(
- [catalogApiRef, catalogApi],
- [alertApiRef, {} as AlertApi],
- [translationApiRef, mockApis.translation()],
- [errorApiRef, { post: jest.fn() }],
-);
+const apis = [
+ mockApis.alert(),
+ mockApis.translation(),
+ mockApis.error(),
+ catalogApi,
+] as const;
describe('', () => {
it('should not explode while loading', async () => {
const { baseElement } = await renderWithEffects(
-
+
{}} />
- ,
+ ,
);
expect(baseElement).toBeInTheDocument();
});
it('should render current value', async () => {
await renderWithEffects(
-
+
{}} />
- ,
+ ,
);
expect(screen.getByText('API')).toBeInTheDocument();
@@ -71,9 +64,9 @@ describe('', () => {
it('should select value', async () => {
const onChange = jest.fn();
await renderWithEffects(
-
+
- ,
+ ,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -89,12 +82,12 @@ describe('', () => {
it('should return undefined if all values are selected', async () => {
const onChange = jest.fn();
await renderWithEffects(
-
+
- ,
+ ,
);
await userEvent.click(screen.getByLabelText('Open'));
@@ -112,9 +105,9 @@ describe('', () => {
it('should return all values when cleared', async () => {
const onChange = jest.fn();
await renderWithEffects(
-
+
- ,
+ ,
);
await userEvent.click(screen.getByRole('combobox'));
diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json
index 5d63046df2..908a0c470e 100644
--- a/plugins/catalog-react/package.json
+++ b/plugins/catalog-react/package.json
@@ -68,7 +68,6 @@
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
- "@backstage/frontend-test-utils": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
@@ -91,6 +90,7 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
"@backstage/test-utils": "workspace:^",
@@ -107,12 +107,16 @@
"zod": "^3.25.76"
},
"peerDependencies": {
+ "@backstage/frontend-test-utils": "workspace:^",
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.30.2"
},
"peerDependenciesMeta": {
+ "@backstage/frontend-test-utils": {
+ "optional": true
+ },
"@types/react": {
"optional": true
}
diff --git a/plugins/catalog-react/report-testUtils.api.md b/plugins/catalog-react/report-testUtils.api.md
index 0f7b687965..c5c5300761 100644
--- a/plugins/catalog-react/report-testUtils.api.md
+++ b/plugins/catalog-react/report-testUtils.api.md
@@ -11,10 +11,13 @@ import { Entity } from '@backstage/catalog-model';
import { EntityListContextProps } from '@backstage/plugin-catalog-react';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react/jsx-runtime';
+import { MockWithApiFactory } from '@backstage/frontend-test-utils';
import { PropsWithChildren } from 'react';
// @public
-export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi;
+export function catalogApiMock(options?: {
+ entities?: Entity[];
+}): MockWithApiFactory;
// @public
export namespace catalogApiMock {
diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx
index b2de7522b6..9fa8e6e097 100644
--- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx
+++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx
@@ -19,6 +19,7 @@ import { Entity } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import { alertApiRef } from '@backstage/core-plugin-api';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { fireEvent, waitFor, screen, within } from '@testing-library/react';
import { capitalize } from 'lodash';
import { catalogApiRef } from '../../api';
@@ -65,12 +66,7 @@ describe('', () => {
} as GetEntityFacetsResponse),
},
],
- [
- alertApiRef,
- {
- post: jest.fn(),
- },
- ],
+ [alertApiRef, mockApis.alert()],
);
it('renders available entity kinds', async () => {
diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx
index 281b94251e..58e731f197 100644
--- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx
+++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx
@@ -23,6 +23,7 @@ import { EntityKindFilter, EntityTypeFilter } from '../../filters';
import { alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
const entities: Entity[] = [
@@ -72,12 +73,7 @@ const apis = TestApiRegistry.from(
} as GetEntityFacetsResponse),
},
],
- [
- alertApiRef,
- {
- post: jest.fn(),
- },
- ],
+ [alertApiRef, mockApis.alert()],
);
describe('', () => {
diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx
index 595cd70fc4..eea5f95e89 100644
--- a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx
+++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx
@@ -25,22 +25,12 @@ import { catalogApiRef } from '../../api';
import { entityRouteRef } from '../../routes';
import { screen, waitFor } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import * as state from './useUnregisterEntityDialogState';
-import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
+import { alertApiRef } from '@backstage/core-plugin-api';
describe('UnregisterEntityDialog', () => {
- const alertApi: AlertApi = {
- post() {
- return undefined;
- },
- alert$() {
- throw new Error('not implemented');
- },
- };
-
- beforeEach(() => {
- jest.spyOn(alertApi, 'post').mockImplementation(() => {});
- });
+ const alertApi = mockApis.alert();
const entity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -340,11 +330,13 @@ describe('UnregisterEntityDialog', () => {
await waitFor(() => {
expect(deleteEntity).toHaveBeenCalled();
expect(onConfirm).toHaveBeenCalled();
- expect(alertApi.post).toHaveBeenCalledWith({
- message: 'Removed entity n',
- severity: 'success',
- display: 'transient',
- });
+ expect(alertApi.getAlerts()).toContainEqual(
+ expect.objectContaining({
+ message: 'Removed entity n',
+ severity: 'success',
+ display: 'transient',
+ }),
+ );
});
});
});
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
index 711fb09f8c..a5fda1bf5b 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
@@ -25,7 +25,8 @@ import {
} from '@backstage/core-plugin-api';
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
-import { mockApis, TestApiProvider } from '@backstage/test-utils';
+import { TestApiProvider } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { useMountEffect } from '@react-hookz/web';
import { act, renderHook, waitFor } from '@testing-library/react';
import qs from 'qs';
@@ -111,7 +112,7 @@ const createWrapper =
[identityApiRef, mockIdentityApi],
[storageApiRef, mockApis.storage()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
- [alertApiRef, { post: jest.fn() }],
+ [alertApiRef, mockApis.alert()],
[translationApiRef, mockApis.translation()],
[errorApiRef, { error$: jest.fn(), post: jest.fn() }],
]}
diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts
index a37d2669ad..007345de22 100644
--- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts
+++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts
@@ -14,42 +14,16 @@
* limitations under the License.
*/
-import {
- ApiFactory,
- ApiRef,
- createApiFactory,
-} from '@backstage/frontend-plugin-api';
+import { ApiFactory, createApiFactory } from '@backstage/frontend-plugin-api';
import { InMemoryCatalogClient } from '@backstage/catalog-client/testUtils';
import { Entity } from '@backstage/catalog-model';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { CatalogApi } from '@backstage/catalog-client';
-import { ApiMock } from '@backstage/frontend-test-utils';
-
-/** @internal */
-function simpleMock(
- ref: ApiRef,
- mockFactory: () => jest.Mocked,
-): (partialImpl?: Partial) => ApiMock {
- return partialImpl => {
- const mock = mockFactory();
- if (partialImpl) {
- for (const [key, impl] of Object.entries(partialImpl)) {
- if (typeof impl === 'function') {
- (mock as any)[key].mockImplementation(impl);
- } else {
- (mock as any)[key] = impl;
- }
- }
- }
- return Object.assign(mock, {
- factory: createApiFactory({
- api: ref,
- deps: {},
- factory: () => mock,
- }),
- }) as ApiMock;
- };
-}
+import {
+ createApiMock,
+ attachMockApiFactory,
+ type MockWithApiFactory,
+} from '@backstage/frontend-test-utils';
/**
* Creates a fake catalog client that handles entities in memory storage. Note
@@ -58,8 +32,11 @@ function simpleMock(
*
* @public
*/
-export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi {
- return new InMemoryCatalogClient(options);
+export function catalogApiMock(options?: {
+ entities?: Entity[];
+}): MockWithApiFactory {
+ const instance = new InMemoryCatalogClient(options);
+ return attachMockApiFactory(catalogApiRef, instance);
}
/**
@@ -85,7 +62,7 @@ export namespace catalogApiMock {
* Creates a catalog client whose methods are mock functions, possibly with
* some of them overloaded by the caller.
*/
- export const mock = simpleMock(catalogApiRef, () => ({
+ export const mock = createApiMock(catalogApiRef, () => ({
getEntities: jest.fn(),
getEntitiesByRefs: jest.fn(),
queryEntities: jest.fn(),
diff --git a/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx b/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx
index 2b15ae5fc4..4fa8465797 100644
--- a/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx
+++ b/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx
@@ -23,7 +23,6 @@ import {
EntityCardBlueprint,
EntityContentBlueprint,
} from '../alpha/blueprints';
-import { catalogApiRef } from '../api';
const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
@@ -386,7 +385,7 @@ describe('createTestEntityPage', () => {
createTestEntityPage({ entity: mockEntity }),
catalogApiCard,
],
- apis: [[catalogApiRef, catalogApiMock({ entities: customEntities })]],
+ apis: [catalogApiMock({ entities: customEntities })],
});
// Should use the user-provided catalog API with custom entities, not mockEntity
diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx
index 9ae448a9fc..c43db8746c 100644
--- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx
+++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx
@@ -32,11 +32,11 @@ import {
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
- mockApis,
renderInTestApp,
TestApiProvider,
TestApiRegistry,
} from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { EntityLayout } from './EntityLayout';
import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes';
@@ -52,7 +52,7 @@ describe('EntityLayout', () => {
const apis = TestApiRegistry.from(
[catalogApiRef, catalogApiMock()],
- [alertApiRef, {} as AlertApi],
+ [alertApiRef, mockApis.alert()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[permissionApiRef, mockApis.permission()],
);
diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx
index 80d97a2e1e..40085d195d 100644
--- a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx
+++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx
@@ -22,13 +22,11 @@ import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { screen, waitFor } from '@testing-library/react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
-import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
+import { alertApiRef } from '@backstage/core-plugin-api';
+import { mockApis } from '@backstage/frontend-test-utils';
describe('DeleteEntityDialog', () => {
- const alertApi: jest.Mocked = {
- post: jest.fn(),
- alert$: jest.fn(),
- };
+ const alertApi = mockApis.alert();
const catalogClient = catalogApiMock.mock();
@@ -123,7 +121,9 @@ describe('DeleteEntityDialog', () => {
await waitFor(() => {
expect(catalogClient.removeEntityByUid).toHaveBeenCalledWith('123');
- expect(alertApi.post).toHaveBeenCalledWith({ message: 'no no no' });
+ expect(alertApi.getAlerts()).toContainEqual(
+ expect.objectContaining({ message: 'no no no' }),
+ );
});
});
});
diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
index 66affd86f1..cb22475be6 100644
--- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
+++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx
@@ -23,15 +23,12 @@ import { render, screen } from '@testing-library/react';
import { ReactNode, useEffect } from 'react';
import { isKind } from './conditions';
import { EntitySwitch } from './EntitySwitch';
-import { featureFlagsApiRef } from '@backstage/core-plugin-api';
-import { LocalStorageFeatureFlags } from '@backstage/core-app-api';
-import { TestApiProvider } from '@backstage/test-utils';
+import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils';
+
+const mockFeatureFlagsApi = mockApis.featureFlags.mock();
-const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
const Wrapper = ({ children }: { children?: ReactNode }) => (
-
- {children}
-
+ {children}
);
describe('EntitySwitch', () => {
diff --git a/plugins/org/src/alpha.test.tsx b/plugins/org/src/alpha.test.tsx
index d643ec79ee..1a42f36e54 100644
--- a/plugins/org/src/alpha.test.tsx
+++ b/plugins/org/src/alpha.test.tsx
@@ -21,7 +21,6 @@ import {
createTestEntityPage,
catalogApiMock,
} from '@backstage/plugin-catalog-react/testUtils';
-import { catalogApiRef } from '@backstage/plugin-catalog-react';
import orgPlugin from './alpha';
const EntityGroupProfileCard = orgPlugin.getExtension(
@@ -230,12 +229,7 @@ describe('org plugin entity cards', () => {
createTestEntityPage({ entity: groupEntity }),
EntityMembersListCard,
],
- apis: [
- [
- catalogApiRef,
- catalogApiMock({ entities: [groupEntity, memberUser] }),
- ],
- ],
+ apis: [catalogApiMock({ entities: [groupEntity, memberUser] })],
});
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
@@ -261,7 +255,7 @@ describe('org plugin entity cards', () => {
createTestEntityPage({ entity: groupEntity }),
EntityMembersListCard,
],
- apis: [[catalogApiRef, catalogApiMock({ entities: [groupEntity] })]],
+ apis: [catalogApiMock({ entities: [groupEntity] })],
});
expect(
diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json
index 840f210c78..077824950b 100644
--- a/plugins/scaffolder-react/package.json
+++ b/plugins/scaffolder-react/package.json
@@ -98,6 +98,7 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
diff --git a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx
index cfbe8a40b4..9489321854 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx
+++ b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx
@@ -17,6 +17,7 @@
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
import { TemplateCategoryPicker } from './TemplateCategoryPicker';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { alertApiRef } from '@backstage/core-plugin-api';
import { fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -26,10 +27,10 @@ jest.mock('@backstage/plugin-catalog-react', () => ({
}));
describe('TemplateCategoryPicker', () => {
- const mockAlertApi = { post: jest.fn() };
+ const mockAlertApi = mockApis.alert();
beforeEach(() => {
- mockAlertApi.post.mockClear();
+ mockAlertApi.clearAlerts();
});
it('should post the error to errorApi if an errors is returned', async () => {
@@ -37,13 +38,16 @@ describe('TemplateCategoryPicker', () => {
error: new Error('something broked'),
});
+ mockAlertApi.clearAlerts();
+
await renderInTestApp(
,
);
- expect(mockAlertApi.post).toHaveBeenCalledWith({
+ expect(mockAlertApi.getAlerts().length).toBeGreaterThanOrEqual(1);
+ expect(mockAlertApi.getAlerts()[0]).toMatchObject({
message: expect.stringContaining('something broked'),
severity: 'error',
});
diff --git a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx
index 78c2064c4b..293ef54bc2 100644
--- a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx
+++ b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx
@@ -18,11 +18,10 @@ import { renderHook } from '@testing-library/react';
import { useFilteredSchemaProperties } from './useFilteredSchemaProperties';
import { TemplateParameterSchema } from '../../types';
import { TestApiProvider } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
-const mockFeatureFlagApi = {
- isActive: jest.fn(),
-};
+const mockFeatureFlagApi = mockApis.featureFlags.mock();
describe('useFilteredSchemaProperties', () => {
it('should return the same manifest if no feature flag is set', () => {
diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx
index dff5500b51..b51e3ff147 100644
--- a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx
+++ b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx
@@ -15,9 +15,9 @@
*/
import { useTemplateSchema } from './useTemplateSchema';
import { renderHook } from '@testing-library/react';
-import { TestApiProvider } from '@backstage/test-utils';
+import { TestApiProvider } from '@backstage/frontend-test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { PropsWithChildren } from 'react';
-import { featureFlagsApiRef } from '@backstage/core-plugin-api';
import { TemplateParameterSchema } from '../../types';
describe('useTemplateSchema', () => {
@@ -49,11 +49,13 @@ describe('useTemplateSchema', () => {
],
};
+ const mockFeatureFlagsApi = mockApis.featureFlags.mock({
+ isActive: jest.fn(() => false),
+ });
+
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
- false }]]}
- >
+
{children}
),
@@ -119,7 +121,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
false }]]}
+ apis={[
+ mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
+ ]}
>
{children}
@@ -163,7 +167,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
true }]]}
+ apis={[
+ mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }),
+ ]}
>
{children}
@@ -214,7 +220,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
false }]]}
+ apis={[
+ mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
+ ]}
>
{children}
@@ -252,7 +260,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
false }]]}
+ apis={[
+ mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
+ ]}
>
{children}
@@ -356,7 +366,9 @@ describe('useTemplateSchema', () => {
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }: PropsWithChildren<{}>) => (
false }]]}
+ apis={[
+ mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
+ ]}
>
{children}
diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json
index 9b505fc2b6..c08f507f42 100644
--- a/plugins/scaffolder/package.json
+++ b/plugins/scaffolder/package.json
@@ -108,6 +108,7 @@
"@backstage/cli": "workspace:^",
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
+ "@backstage/frontend-test-utils": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-techdocs": "workspace:^",
diff --git a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx
index 5968f1da4f..cc45547827 100644
--- a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx
+++ b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx
@@ -23,9 +23,10 @@ import {
EntityKindFilter,
} from '@backstage/plugin-catalog-react';
import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils';
-import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
+import { alertApiRef } from '@backstage/core-plugin-api';
import { ApiProvider } from '@backstage/core-app-api';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
+import { mockApis } from '@backstage/frontend-test-utils';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
const entities: Entity[] = [
@@ -75,12 +76,7 @@ const apis = TestApiRegistry.from(
} as GetEntityFacetsResponse),
},
],
- [
- alertApiRef,
- {
- post: jest.fn(),
- } as unknown as AlertApi,
- ],
+ [alertApiRef, mockApis.alert()],
);
describe('', () => {
diff --git a/yarn.lock b/yarn.lock
index d9ff91af82..0544682600 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3207,6 +3207,11 @@ __metadata:
yn: "npm:^4.0.0"
zod: "npm:^3.25.76"
zod-to-json-schema: "npm:^3.25.1"
+ peerDependencies:
+ "@types/jest": "*"
+ peerDependenciesMeta:
+ "@types/jest":
+ optional: true
languageName: unknown
linkType: soft
@@ -3954,26 +3959,38 @@ __metadata:
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
+ "@backstage/core-app-api": "workspace:^"
+ "@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/plugin-app-react": "workspace:^"
+ "@backstage/plugin-permission-common": "workspace:^"
+ "@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/version-bridge": "workspace:^"
"@testing-library/jest-dom": "npm:^6.0.0"
+ "@types/jest": "npm:*"
"@types/react": "npm:^18.0.0"
+ "@types/zen-observable": "npm:^0.8.0"
+ i18next: "npm:^22.4.15"
+ msw: "npm:^2.0.0"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.30.2"
+ zen-observable: "npm:^0.10.0"
zod: "npm:^3.25.76"
peerDependencies:
"@testing-library/react": ^16.0.0
+ "@types/jest": "*"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
+ "@types/jest":
+ optional: true
"@types/react":
optional: true
languageName: unknown
@@ -5430,11 +5447,14 @@ __metadata:
zen-observable: "npm:^0.10.0"
zod: "npm:^3.25.76"
peerDependencies:
+ "@backstage/frontend-test-utils": "workspace:^"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
+ "@backstage/frontend-test-utils":
+ optional: true
"@types/react":
optional: true
languageName: unknown
@@ -7042,6 +7062,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
+ "@backstage/frontend-test-utils": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
@@ -7111,6 +7132,7 @@ __metadata:
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
+ "@backstage/frontend-test-utils": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
@@ -7967,11 +7989,14 @@ __metadata:
zen-observable: "npm:^0.10.0"
peerDependencies:
"@testing-library/react": ^16.0.0
+ "@types/jest": "*"
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.30.2
peerDependenciesMeta:
+ "@types/jest":
+ optional: true
"@types/react":
optional: true
languageName: unknown