frontend-test-utils: adjust fetch mock + add createApiMock

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-09 17:21:24 +01:00
parent 3f19d5ec32
commit 7ee7f122a8
10 changed files with 255 additions and 224 deletions
@@ -24,8 +24,6 @@ import {
FetchApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import crossFetch, { Response } from 'cross-fetch';
/**
* The options given when constructing a {@link MockFetchApi}.
*
@@ -47,7 +45,7 @@ export interface MockFetchApiOptions {
* `jest.fn()`, if you want to use a custom implementation or to just track
* and assert on calls.
*/
baseImplementation?: undefined | 'none' | typeof crossFetch;
baseImplementation?: undefined | 'none' | typeof fetch;
/**
* Add translation from `plugin://` URLs to concrete http(s) URLs, basically
@@ -103,7 +101,7 @@ export class MockFetchApi implements FetchApi {
}
/** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */
get fetch(): typeof crossFetch {
get fetch(): typeof fetch {
return this.implementation.fetch;
}
}
@@ -124,7 +122,7 @@ function build(options?: MockFetchApiOptions): FetchApi {
function baseImplementation(
options: MockFetchApiOptions | undefined,
): typeof crossFetch {
): typeof fetch {
const implementation = options?.baseImplementation;
if (!implementation) {
// Return a wrapper that evaluates global.fetch at call time, not construction time.
@@ -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<TestApi>({ 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);
});
});
@@ -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<TApi> = {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
/**
* 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<TApi>(
apiRef: ApiRef<TApi>,
mockFactory: () => jest.Mocked<TApi>,
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
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<TApi>;
};
}
@@ -14,7 +14,8 @@
* limitations under the License.
*/
export { type ApiMock, mockApis } from './mockApis';
export { mockApis } from './mockApis';
export { createApiMock, type ApiMock } from './createApiMock';
export {
type MockApiFactorySymbol,
type MockWithApiFactory,
@@ -18,7 +18,6 @@ import {
alertApiRef,
analyticsApiRef,
configApiRef,
createApiFactory,
discoveryApiRef,
errorApiRef,
fetchApiRef,
@@ -34,7 +33,6 @@ import {
type IdentityApi,
type StorageApi,
type TranslationApi,
ApiFactory,
} from '@backstage/frontend-plugin-api';
import {
permissionApiRef,
@@ -59,53 +57,9 @@ import { MockPermissionApi } from './PermissionApi';
import { MockTranslationApi } from './TranslationApi';
import {
mockWithApiFactory,
mockApiFactorySymbol,
type MockWithApiFactory,
} from './MockWithApiFactory';
/**
* Represents a mocked version of an API, where you automatically have access to
* the mocked versions of all of its methods along with a factory that returns
* that same mock.
*
* @public
*/
export type ApiMock<TApi> = {
factory: ApiFactory<TApi, TApi, {}>;
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
} & {
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
? TApi[Key] & jest.MockInstance<Return, Args>
: TApi[Key];
};
/** @internal */
function simpleMock<TApi>(
ref: any,
mockFactory: () => jest.Mocked<TApi>,
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
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;
}
}
}
const factory = createApiFactory({
api: ref,
deps: {},
factory: () => mock,
});
const apiMock = Object.assign(mock, { factory }) as ApiMock<TApi>;
// Set the mock API symbol to the same factory
(apiMock as any)[mockApiFactorySymbol] = factory;
return apiMock;
};
}
import { createApiMock } from './createApiMock';
/**
* Mock implementations of the core utility APIs, to be used in tests.
@@ -172,7 +126,7 @@ export namespace mockApis {
*
* @public
*/
export const mock = simpleMock(alertApiRef, () => ({
export const mock = createApiMock(alertApiRef, () => ({
post: jest.fn(),
alert$: jest.fn(),
}));
@@ -215,7 +169,7 @@ export namespace mockApis {
*
* @public
*/
export const mock = simpleMock(featureFlagsApiRef, () => ({
export const mock = createApiMock(featureFlagsApiRef, () => ({
registerFlag: jest.fn(),
getRegisteredFlags: jest.fn(),
isActive: jest.fn(),
@@ -241,7 +195,7 @@ export namespace mockApis {
* @public
*/
export namespace analytics {
export const mock = simpleMock(analyticsApiRef, () => ({
export const mock = createApiMock(analyticsApiRef, () => ({
captureEvent: jest.fn(),
}));
}
@@ -273,7 +227,7 @@ export namespace mockApis {
*
* @public
*/
export const mock = simpleMock(translationApiRef, () => ({
export const mock = createApiMock(translationApiRef, () => ({
getTranslation: jest.fn(),
translation$: jest.fn(),
}));
@@ -298,7 +252,7 @@ export namespace mockApis {
* @public
*/
export namespace config {
export const mock = simpleMock(configApiRef, () => ({
export const mock = createApiMock(configApiRef, () => ({
has: jest.fn(),
keys: jest.fn(),
get: jest.fn(),
@@ -342,7 +296,7 @@ export namespace mockApis {
* @public
*/
export namespace discovery {
export const mock = simpleMock(discoveryApiRef, () => ({
export const mock = createApiMock(discoveryApiRef, () => ({
getBaseUrl: jest.fn(),
}));
}
@@ -390,7 +344,7 @@ export namespace mockApis {
* @public
*/
export namespace identity {
export const mock = simpleMock(identityApiRef, () => ({
export const mock = createApiMock(identityApiRef, () => ({
getBackstageIdentity: jest.fn(),
getCredentials: jest.fn(),
getProfileInfo: jest.fn(),
@@ -427,7 +381,7 @@ export namespace mockApis {
* @public
*/
export namespace permission {
export const mock = simpleMock(permissionApiRef, () => ({
export const mock = createApiMock(permissionApiRef, () => ({
authorize: jest.fn(),
}));
}
@@ -451,7 +405,7 @@ export namespace mockApis {
* @public
*/
export namespace storage {
export const mock = simpleMock(storageApiRef, () => ({
export const mock = createApiMock(storageApiRef, () => ({
forBucket: jest.fn(),
snapshot: jest.fn(),
set: jest.fn(),
@@ -479,7 +433,7 @@ export namespace mockApis {
* @public
*/
export namespace error {
export const mock = simpleMock(errorApiRef, () => ({
export const mock = createApiMock(errorApiRef, () => ({
post: jest.fn(),
error$: jest.fn(),
}));
@@ -504,7 +458,7 @@ export namespace mockApis {
* @public
*/
export namespace fetch {
export const mock = simpleMock(fetchApiRef, () => ({
export const mock = createApiMock(fetchApiRef, () => ({
fetch: jest.fn(),
}));
}