Add createServiceMock to backend-test-utils

Exports the internal simpleMock utility as createServiceMock, allowing
plugin authors to define mock creators for their own service refs
following the same pattern as the built-in mockServices mocks.

Also updates catalog-node to use createServiceMock instead of its own
copy, and simplifies the gateway-backend test to use the mock's
.factory property directly.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-09 22:29:07 +01:00
parent 9848734ce6
commit 42abfb1b85
14 changed files with 138 additions and 145 deletions
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': minor
---
Added `createServiceMock`, a public utility for creating `ServiceMock` instances for custom service refs. This allows plugin authors to define mock creators for their own services following the same pattern as the built-in `mockServices` mocks.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-node': patch
---
Updated `catalogServiceMock.mock` to use `createServiceMock` from `@backstage/backend-test-utils`, replacing the internal copy of `simpleMock`. Added `@backstage/backend-test-utils` as an optional peer dependency.
@@ -70,7 +70,7 @@ export class MockActionsRegistry
>(options: ActionsRegistryActionOptions<TInputSchema, TOutputSchema>): void;
}
// @alpha (undocumented)
// @public (undocumented)
export type ServiceMock<TService> = {
factory: ServiceFactory<TService>;
} & {
@@ -55,6 +55,12 @@ export interface CreateMockDirectoryOptions {
mockOsTmpDir?: boolean;
}
// @public
export function createServiceMock<TService>(
ref: ServiceRef<TService, any>,
mockFactory: () => jest.Mocked<TService>,
): (partialImpl?: Partial<TService>) => ServiceMock<TService>;
// @public (undocumented)
export namespace mockCredentials {
export function limitedUser(
@@ -16,11 +16,8 @@
import { mockServices } from '../../services';
import { LoggerService } from '@backstage/backend-plugin-api';
import { MockActionsRegistry } from './MockActionsRegistry';
import { simpleMock } from './simpleMock';
import {
ActionsRegistryService,
actionsRegistryServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { createServiceMock } from './simpleMock';
import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha';
import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/alpha';
/**
@@ -40,10 +37,7 @@ export function actionsRegistryServiceMock(options?: {
export namespace actionsRegistryServiceMock {
export const factory = () => actionsRegistryServiceFactory;
export const mock = simpleMock<ActionsRegistryService>(
actionsRegistryServiceRef,
() => ({
register: jest.fn(),
}),
);
export const mock = createServiceMock(actionsRegistryServiceRef, () => ({
register: jest.fn(),
}));
}
@@ -14,11 +14,8 @@
* limitations under the License.
*/
import { simpleMock } from './simpleMock';
import {
ActionsService,
actionsServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { createServiceMock } from './simpleMock';
import { actionsServiceRef } from '@backstage/backend-plugin-api/alpha';
import { actionsServiceFactory } from '@backstage/backend-defaults/alpha';
/**
@@ -27,7 +24,7 @@ import { actionsServiceFactory } from '@backstage/backend-defaults/alpha';
export namespace actionsServiceMock {
export const factory = () => actionsServiceFactory;
export const mock = simpleMock<ActionsService>(actionsServiceRef, () => ({
export const mock = createServiceMock(actionsServiceRef, () => ({
invoke: jest.fn(),
list: jest.fn(),
}));
@@ -13,45 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceFactory,
ServiceFactory,
ServiceRef,
} from '@backstage/backend-plugin-api';
export { createServiceMock } from '../../services/simpleMock';
/** @alpha */
export type ServiceMock<TService> = {
factory: ServiceFactory<TService>;
} & {
[Key in keyof TService]: TService[Key] extends (
...args: infer Args
) => infer Return
? TService[Key] & jest.MockInstance<Return, Args>
: TService[Key];
};
/** @internal */
export function simpleMock<TService>(
ref: ServiceRef<TService, any>,
mockFactory: () => jest.Mocked<TService>,
): (partialImpl?: Partial<TService>) => ServiceMock<TService> {
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: createServiceFactory({
service: ref,
deps: {},
factory: () => mock,
}),
}) as ServiceMock<TService>;
};
}
export type { ServiceMock } from '../../services/simpleMock';
@@ -15,4 +15,4 @@
*/
export { mockServices } from './mockServices';
export { mockCredentials } from './mockCredentials';
export { type ServiceMock } from './simpleMock';
export { createServiceMock, type ServiceMock } from './simpleMock';
@@ -57,7 +57,7 @@ import { MockUserInfoService } from './MockUserInfoService';
import { mockCredentials } from './mockCredentials';
import { MockEventsService } from './MockEventsService';
import { MockPermissionsService } from './MockPermissionsService';
import { simpleMock } from './simpleMock';
import { createServiceMock } from './simpleMock';
import { MockSchedulerService } from './MockSchedulerService';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { ObservableConfigProxy } from '../../../config-loader/src/sources/ObservableConfigProxy';
@@ -159,7 +159,7 @@ export namespace mockServices {
coreServices.rootConfig,
rootConfig,
);
export const mock = simpleMock(coreServices.rootConfig, () => ({
export const mock = createServiceMock(coreServices.rootConfig, () => ({
get: jest.fn(),
getBoolean: jest.fn(),
getConfig: jest.fn(),
@@ -191,7 +191,7 @@ export namespace mockServices {
coreServices.rootLogger,
rootLogger,
);
export const mock = simpleMock(coreServices.rootLogger, () => ({
export const mock = createServiceMock(coreServices.rootLogger, () => ({
child: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
@@ -203,7 +203,7 @@ export namespace mockServices {
export namespace auditor {
export const factory = () => auditorServiceFactory;
export const mock = simpleMock(coreServices.auditor, () => ({
export const mock = createServiceMock(coreServices.auditor, () => ({
createEvent: jest.fn(async _ => {
return {
success: jest.fn(),
@@ -242,7 +242,7 @@ export namespace mockServices {
});
},
});
export const mock = simpleMock(coreServices.auth, () => ({
export const mock = createServiceMock(coreServices.auth, () => ({
authenticate: jest.fn(),
getNoneCredentials: jest.fn(),
getOwnServiceCredentials: jest.fn(),
@@ -271,7 +271,7 @@ export namespace mockServices {
deps: {},
factory: () => discovery(),
});
export const mock = simpleMock(coreServices.discovery, () => ({
export const mock = createServiceMock(coreServices.discovery, () => ({
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
}));
@@ -320,7 +320,7 @@ export namespace mockServices {
options?.defaultCredentials ?? mockCredentials.user(),
),
});
export const mock = simpleMock(coreServices.httpAuth, () => ({
export const mock = createServiceMock(coreServices.httpAuth, () => ({
credentials: jest.fn(),
issueUserCookie: jest.fn(),
}));
@@ -353,7 +353,7 @@ export namespace mockServices {
return new MockUserInfoService();
},
});
export const mock = simpleMock(coreServices.userInfo, () => ({
export const mock = createServiceMock(coreServices.userInfo, () => ({
getUserInfo: jest.fn(),
}));
}
@@ -363,7 +363,7 @@ export namespace mockServices {
// re-implement functioning mock versions here.
export namespace cache {
export const factory = () => cacheServiceFactory;
export const mock = simpleMock(coreServices.cache, () => ({
export const mock = createServiceMock(coreServices.cache, () => ({
delete: jest.fn(),
get: jest.fn(),
set: jest.fn(),
@@ -410,14 +410,14 @@ export namespace mockServices {
* {@link @backstage/backend-plugin-api#coreServices.database}, optionally
* with some given method implementations.
*/
export const mock = simpleMock(coreServices.database, () => ({
export const mock = createServiceMock(coreServices.database, () => ({
getClient: jest.fn(),
}));
}
export namespace rootHealth {
export const factory = () => rootHealthServiceFactory;
export const mock = simpleMock(coreServices.rootHealth, () => ({
export const mock = createServiceMock(coreServices.rootHealth, () => ({
getLiveness: jest.fn(),
getReadiness: jest.fn(),
}));
@@ -425,7 +425,7 @@ export namespace mockServices {
export namespace httpRouter {
export const factory = () => httpRouterServiceFactory;
export const mock = simpleMock(coreServices.httpRouter, () => ({
export const mock = createServiceMock(coreServices.httpRouter, () => ({
use: jest.fn(),
addAuthPolicy: jest.fn(),
}));
@@ -433,14 +433,14 @@ export namespace mockServices {
export namespace rootHttpRouter {
export const factory = () => rootHttpRouterServiceFactory();
export const mock = simpleMock(coreServices.rootHttpRouter, () => ({
export const mock = createServiceMock(coreServices.rootHttpRouter, () => ({
use: jest.fn(),
}));
}
export namespace lifecycle {
export const factory = () => lifecycleServiceFactory;
export const mock = simpleMock(coreServices.lifecycle, () => ({
export const mock = createServiceMock(coreServices.lifecycle, () => ({
addShutdownHook: jest.fn(),
addStartupHook: jest.fn(),
}));
@@ -448,7 +448,7 @@ export namespace mockServices {
export namespace logger {
export const factory = () => loggerServiceFactory;
export const mock = simpleMock(coreServices.logger, () =>
export const mock = createServiceMock(coreServices.logger, () =>
createLoggerMock(),
);
}
@@ -484,7 +484,7 @@ export namespace mockServices {
* {@link @backstage/backend-plugin-api#coreServices.permissions},
* optionally with some given method implementations.
*/
export const mock = simpleMock(coreServices.permissions, () => ({
export const mock = createServiceMock(coreServices.permissions, () => ({
authorize: jest.fn(),
authorizeConditional: jest.fn(),
}));
@@ -492,17 +492,20 @@ export namespace mockServices {
export namespace permissionsRegistry {
export const factory = () => permissionsRegistryServiceFactory;
export const mock = simpleMock(coreServices.permissionsRegistry, () => ({
addPermissionRules: jest.fn(),
addPermissions: jest.fn(),
addResourceType: jest.fn(),
getPermissionRuleset: jest.fn(),
}));
export const mock = createServiceMock(
coreServices.permissionsRegistry,
() => ({
addPermissionRules: jest.fn(),
addPermissions: jest.fn(),
addResourceType: jest.fn(),
getPermissionRuleset: jest.fn(),
}),
);
}
export namespace rootLifecycle {
export const factory = () => rootLifecycleServiceFactory;
export const mock = simpleMock(coreServices.rootLifecycle, () => ({
export const mock = createServiceMock(coreServices.rootLifecycle, () => ({
addShutdownHook: jest.fn(),
addBeforeShutdownHook: jest.fn(),
addStartupHook: jest.fn(),
@@ -518,7 +521,7 @@ export namespace mockServices {
includeManualTasksOnStartup?: boolean;
includeInitialDelayedTasksOnStartup?: boolean;
}) => new MockSchedulerService().factory(options);
export const mock = simpleMock(coreServices.scheduler, () => ({
export const mock = createServiceMock(coreServices.scheduler, () => ({
createScheduledTaskRunner: jest.fn(),
getScheduledTasks: jest.fn(),
scheduleTask: jest.fn(),
@@ -528,7 +531,7 @@ export namespace mockServices {
export namespace urlReader {
export const factory = () => urlReaderServiceFactory;
export const mock = simpleMock(coreServices.urlReader, () => ({
export const mock = createServiceMock(coreServices.urlReader, () => ({
readTree: jest.fn(),
readUrl: jest.fn(),
search: jest.fn(),
@@ -553,7 +556,7 @@ export namespace mockServices {
* {@link @backstage/backend-events-node#eventsServiceRef}, optionally
* with some given method implementations.
*/
export const mock = simpleMock(eventsServiceRef, () => ({
export const mock = createServiceMock(eventsServiceRef, () => ({
publish: jest.fn(),
subscribe: jest.fn(),
}));
@@ -565,9 +568,12 @@ export namespace mockServices {
};
}
export namespace rootInstanceMetadata {
export const mock = simpleMock(coreServices.rootInstanceMetadata, () => ({
getInstalledPlugins: jest.fn(),
}));
export const mock = createServiceMock(
coreServices.rootInstanceMetadata,
() => ({
getInstalledPlugins: jest.fn(),
}),
);
export const factory = simpleFactoryWithOptions(
coreServices.rootInstanceMetadata,
rootInstanceMetadata,
@@ -30,8 +30,43 @@ export type ServiceMock<TService> = {
: TService[Key];
};
/** @internal */
export function simpleMock<TService>(
/**
* Creates a standardized Backstage service mock factory function for producing
* mock service 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 service reference.
*
* The returned mock has a `.factory` property that can be passed directly to
* `startTestBackend` or other test utilities.
*
* @example
* ```ts
* import { createServiceMock } from '@backstage/backend-test-utils';
*
* const myServiceMock = createServiceMock(myServiceRef, () => ({
* doStuff: jest.fn(),
* doOtherStuff: jest.fn(),
* }));
*
* // Create a mock with default behavior:
* const mock = myServiceMock();
*
* // Or with a partial implementation:
* const mock = myServiceMock({ doStuff: async () => 'test' });
* expect(mock.doStuff).toHaveBeenCalledTimes(1);
*
* // It also has a `.factory` property for use with startTestBackend:
* await startTestBackend({ features: [mock.factory] });
* ```
*
* @public
*/
export function createServiceMock<TService>(
ref: ServiceRef<TService, any>,
mockFactory: () => jest.Mocked<TService>,
): (partialImpl?: Partial<TService>) => ServiceMock<TService> {
+8
View File
@@ -71,6 +71,14 @@
"lodash": "^4.17.21",
"yaml": "^2.0.0"
},
"peerDependencies": {
"@backstage/backend-test-utils": "workspace:^"
},
"peerDependenciesMeta": {
"@backstage/backend-test-utils": {
"optional": true
}
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
@@ -17,41 +17,14 @@
import {
createServiceFactory,
ServiceFactory,
ServiceRef,
} from '@backstage/backend-plugin-api';
import { InMemoryCatalogClient } from '@backstage/catalog-client/testUtils';
import { Entity } from '@backstage/catalog-model';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
// eslint-disable-next-line @backstage/no-undeclared-imports
import { ServiceMock } from '@backstage/backend-test-utils';
import { createServiceMock } from '@backstage/backend-test-utils';
import { CatalogServiceMock } from './types';
/** @internal */
function simpleMock<TService>(
ref: ServiceRef<TService, any>,
mockFactory: () => jest.Mocked<TService>,
): (partialImpl?: Partial<TService>) => ServiceMock<TService> {
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: createServiceFactory({
service: ref,
deps: {},
factory: () => mock,
}),
}) as ServiceMock<TService>;
};
}
/**
* Creates a fake catalog client that handles entities in memory storage. Note
* that this client may be severely limited in functionality, and advanced
@@ -86,23 +59,26 @@ export namespace catalogServiceMock {
* Creates a catalog client whose methods are mock functions, possibly with
* some of them overloaded by the caller.
*/
export const mock = simpleMock<CatalogServiceMock>(catalogServiceRef, () => ({
getEntities: jest.fn(),
getEntitiesByRefs: jest.fn(),
queryEntities: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityByRef: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityFacets: jest.fn(),
getLocations: jest.fn(),
getLocationById: jest.fn(),
getLocationByRef: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getLocationByEntity: jest.fn(),
validateEntity: jest.fn(),
analyzeLocation: jest.fn(),
streamEntities: jest.fn(),
}));
export const mock = createServiceMock<CatalogServiceMock>(
catalogServiceRef,
() => ({
getEntities: jest.fn(),
getEntitiesByRefs: jest.fn(),
queryEntities: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityByRef: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityFacets: jest.fn(),
getLocations: jest.fn(),
getLocationById: jest.fn(),
getLocationByRef: jest.fn(),
addLocation: jest.fn(),
removeLocationById: jest.fn(),
getLocationByEntity: jest.fn(),
validateEntity: jest.fn(),
analyzeLocation: jest.fn(),
streamEntities: jest.fn(),
}),
);
}
+1 -8
View File
@@ -19,7 +19,6 @@ import { mockServices } from '@backstage/backend-test-utils';
import {
coreServices,
createBackendPlugin,
createServiceFactory,
} from '@backstage/backend-plugin-api';
import { Router } from 'express';
import { EventSource } from 'eventsource';
@@ -68,13 +67,7 @@ describe('gateway', () => {
);
backend.add(mockServices.auth.factory());
backend.add(mockServices.httpAuth.factory());
backend.add(
createServiceFactory({
service: coreServices.discovery,
deps: {},
factory: () => discovery,
}),
);
backend.add(discovery.factory);
backend.add(dummyPlugin);
backend.add(import('./'));
+5
View File
@@ -5399,6 +5399,11 @@ __metadata:
lodash: "npm:^4.17.21"
msw: "npm:^1.0.0"
yaml: "npm:^2.0.0"
peerDependencies:
"@backstage/backend-test-utils": "workspace:^"
peerDependenciesMeta:
"@backstage/backend-test-utils":
optional: true
languageName: unknown
linkType: soft