frontend-test-utils: adjust fetch mock + add createApiMock
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user