frontend-test-utils: new utility for passing mock APIs directly
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiFactory } from '@backstage/frontend-plugin-api';
|
||||
import { mockApiFactorySymbol } from './utils';
|
||||
|
||||
/**
|
||||
* Represents a mocked version of an API, where you automatically have access to
|
||||
* the mocked versions of all of its methods along with a factory that returns
|
||||
* that same mock.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiMock<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];
|
||||
};
|
||||
@@ -24,11 +24,16 @@ export {
|
||||
MockPermissionApi,
|
||||
MockStorageApi,
|
||||
type MockStorageBucket,
|
||||
type ApiMock,
|
||||
} from '@backstage/test-utils';
|
||||
|
||||
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
|
||||
export { mockApis } from './mockApis';
|
||||
export {
|
||||
type MockApiFactorySymbol,
|
||||
type ApiMock,
|
||||
type MockWithApiFactory,
|
||||
mockApiFactorySymbol,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -38,4 +43,7 @@ export type { MockAlertApi } from './AlertApi';
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockFeatureFlagsApi, MockFeatureFlagsApiOptions } from './FeatureFlagsApi';
|
||||
export type {
|
||||
MockFeatureFlagsApi,
|
||||
MockFeatureFlagsApiOptions,
|
||||
} from './FeatureFlagsApi';
|
||||
|
||||
@@ -19,15 +19,18 @@ import {
|
||||
createApiFactory,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
mockApis as testUtilsMockApis,
|
||||
type ApiMock,
|
||||
} from '@backstage/test-utils';
|
||||
import { mockApis as testUtilsMockApis } from '@backstage/test-utils';
|
||||
import { MockAlertApi } from './AlertApi';
|
||||
import {
|
||||
MockFeatureFlagsApi,
|
||||
MockFeatureFlagsApiOptions,
|
||||
} from './FeatureFlagsApi';
|
||||
import {
|
||||
ApiMock,
|
||||
mockWithApiFactory,
|
||||
mockApiFactorySymbol,
|
||||
type MockWithApiFactory,
|
||||
} from './utils';
|
||||
|
||||
/** @internal */
|
||||
function simpleMock<TApi>(
|
||||
@@ -45,13 +48,15 @@ function simpleMock<TApi>(
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.assign(mock, {
|
||||
factory: createApiFactory({
|
||||
api: ref,
|
||||
deps: {},
|
||||
factory: () => mock,
|
||||
}),
|
||||
}) as ApiMock<TApi>;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -119,8 +124,12 @@ export namespace mockApis {
|
||||
* expect(alertApi.getAlerts()).toHaveLength(1);
|
||||
* ```
|
||||
*/
|
||||
export function alert(): MockAlertApi {
|
||||
return new MockAlertApi();
|
||||
export function alert(): MockWithApiFactory<MockAlertApi> {
|
||||
const instance = new MockAlertApi();
|
||||
return mockWithApiFactory(
|
||||
alertApiRef,
|
||||
instance,
|
||||
) as MockWithApiFactory<MockAlertApi>;
|
||||
}
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
|
||||
@@ -165,8 +174,12 @@ export namespace mockApis {
|
||||
*/
|
||||
export function featureFlags(
|
||||
options?: MockFeatureFlagsApiOptions,
|
||||
): MockFeatureFlagsApi {
|
||||
return new MockFeatureFlagsApi(options);
|
||||
): MockWithApiFactory<MockFeatureFlagsApi> {
|
||||
const instance = new MockFeatureFlagsApi(options);
|
||||
return mockWithApiFactory(
|
||||
featureFlagsApiRef,
|
||||
instance,
|
||||
) as MockWithApiFactory<MockFeatureFlagsApi>;
|
||||
}
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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 { ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ApiFactory } from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Symbol used to mark mock API instances with their corresponding API factory.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api');
|
||||
|
||||
/**
|
||||
* Symbol used to mark mock API instances with their corresponding API factory.
|
||||
* This allows mock APIs to be passed directly to test utilities without
|
||||
* needing to explicitly provide the [apiRef, implementation] tuple.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
|
||||
|
||||
/**
|
||||
* Represents a mocked version of an API, where you automatically have access to
|
||||
* the mocked versions of all of its methods along with a factory that returns
|
||||
* that same mock.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiMock<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];
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for an API instance that has been marked as a mock API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MockWithApiFactory<TApi> = TApi & {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to attach mock API metadata to an instance.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function mockWithApiFactory<TApi>(
|
||||
apiRef: ApiRef<TApi>,
|
||||
implementation: TApi,
|
||||
): MockWithApiFactory<TApi> {
|
||||
const marked = implementation as MockWithApiFactory<TApi>;
|
||||
(marked as any)[mockApiFactorySymbol] = {
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => implementation,
|
||||
};
|
||||
return marked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the API factory from a mock API instance.
|
||||
* Returns undefined if the value is not a mock API instance.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getMockApiFactory(
|
||||
value: unknown,
|
||||
): ApiFactory<any, any, {}> | undefined {
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
mockApiFactorySymbol in value &&
|
||||
typeof (value as any)[mockApiFactorySymbol] === 'object'
|
||||
) {
|
||||
return (value as any)[mockApiFactorySymbol];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ApiProvider } from '../../../core-app-api/src/apis/system';
|
||||
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils';
|
||||
|
||||
/**
|
||||
* Helper type for representing an API reference paired with a partial implementation.
|
||||
@@ -41,13 +42,26 @@ export type TestApiProviderPropsApiPairs<TApiPairs> = {
|
||||
*/
|
||||
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
|
||||
|
||||
/**
|
||||
* Type for entries that can be passed to TestApiProvider/TestApiRegistry.
|
||||
* Can be either a traditional [apiRef, implementation] tuple or a mock API instance
|
||||
* marked with the MockApiSymbol.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TestApiProviderEntry =
|
||||
| readonly [ApiRef<any>, any]
|
||||
| MockWithApiFactory<any>;
|
||||
|
||||
/**
|
||||
* Properties for the {@link TestApiProvider} component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TestApiProviderProps<TApiPairs extends any[]> = {
|
||||
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
|
||||
apis: readonly [
|
||||
...(TestApiProviderPropsApiPairs<TApiPairs> | MockWithApiFactory<any>[]),
|
||||
];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
@@ -75,20 +89,43 @@ export class TestApiRegistry implements ApiHolder {
|
||||
* import { identityApiRef } from '@backstage/frontend-plugin-api';
|
||||
* import { mockApis } from '@backstage/frontend-test-utils';
|
||||
*
|
||||
* const apis = TestApiRegistry.from(
|
||||
* // Traditional tuple syntax
|
||||
* const apis1 = TestApiRegistry.from(
|
||||
* [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })],
|
||||
* );
|
||||
*
|
||||
* // Direct mock API instance (no tuple needed)
|
||||
* const apis2 = TestApiRegistry.from(
|
||||
* mockApis.identity({ userEntityRef: 'user:default/guest' }),
|
||||
* mockApis.alert(),
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
* @param apis - A list of pairs mapping an ApiRef to its respective implementation.
|
||||
* @param apis - A list of pairs mapping an ApiRef to its respective implementation,
|
||||
* or mock API instances marked with the mockApiSymbol.
|
||||
*/
|
||||
static from<TApiPairs extends any[]>(
|
||||
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
|
||||
) {
|
||||
return new TestApiRegistry(
|
||||
new Map(apis.map(([api, impl]) => [api.id, impl])),
|
||||
);
|
||||
static from(...apis: readonly TestApiProviderEntry[]) {
|
||||
const apiMap = new Map<string, unknown>();
|
||||
|
||||
for (const entry of apis) {
|
||||
const mockFactory = getMockApiFactory(entry);
|
||||
if (mockFactory) {
|
||||
// Handle mock API instances marked with the symbol
|
||||
const impl = mockFactory.factory({});
|
||||
apiMap.set(mockFactory.api.id, impl);
|
||||
} else if (Array.isArray(entry)) {
|
||||
// Handle traditional [apiRef, impl] tuples
|
||||
const [apiRef, impl] = entry;
|
||||
apiMap.set(apiRef.id, impl);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid API entry provided to TestApiRegistry.from(). Expected either [apiRef, impl] tuple or a mock API instance.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new TestApiRegistry(apiMap);
|
||||
}
|
||||
|
||||
private constructor(private readonly apis: Map<string, unknown>) {}
|
||||
@@ -121,6 +158,7 @@ export class TestApiRegistry implements ApiHolder {
|
||||
* import { identityApiRef } from '\@backstage/frontend-plugin-api';
|
||||
* import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils';
|
||||
*
|
||||
* // Traditional tuple syntax
|
||||
* render(
|
||||
* <TestApiProvider
|
||||
* apis={[[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]]}
|
||||
@@ -128,6 +166,18 @@ export class TestApiRegistry implements ApiHolder {
|
||||
* <MyComponent />
|
||||
* </TestApiProvider>
|
||||
* );
|
||||
*
|
||||
* // Direct mock API instances (no tuples needed)
|
||||
* render(
|
||||
* <TestApiProvider
|
||||
* apis={[
|
||||
* mockApis.identity({ userEntityRef: 'user:default/guest' }),
|
||||
* mockApis.alert(),
|
||||
* ]}
|
||||
* >
|
||||
* <MyComponent />
|
||||
* </TestApiProvider>
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
|
||||
@@ -20,5 +20,6 @@ export {
|
||||
type TestApiProviderPropsApiPair,
|
||||
type TestApiProviderPropsApiPairs,
|
||||
type TestApiPairs,
|
||||
type TestApiProviderEntry,
|
||||
} from './TestApiProvider';
|
||||
export type { TestApiProviderProps } from './TestApiProvider';
|
||||
|
||||
Reference in New Issue
Block a user