',
+ 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