diff --git a/.changeset/blue-rats-drop.md b/.changeset/blue-rats-drop.md new file mode 100644 index 0000000000..19b732be1a --- /dev/null +++ b/.changeset/blue-rats-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Introduced a new utility for testing service factories, `ServiceFactoryTester`. diff --git a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts index d0b40df33b..a6c586d806 100644 --- a/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/httpRouter/httpRouterServiceFactory.test.ts @@ -14,39 +14,29 @@ * limitations under the License. */ +import { + ServiceFactoryTester, + mockServices, +} from '@backstage/backend-test-utils'; import { httpRouterServiceFactory } from './httpRouterServiceFactory'; describe('httpRouterFactory', () => { it('should register plugin paths', async () => { - const rootHttpRouter = { use: jest.fn() }; - const factory = httpRouterServiceFactory() as any; + const rootHttpRouter = mockServices.rootHttpRouter.mock(); + const tester = ServiceFactoryTester.from(httpRouterServiceFactory, { + dependencies: [rootHttpRouter.factory], + }); - const handler1 = () => {}; - const router1 = await factory.factory( - { - rootHttpRouter, - plugin: { getId: () => 'test1' }, - lifecycle: { addStartupHook() {}, addShutdownHook() {} }, - }, - undefined, - ); - router1.use(handler1); + const router1 = await tester.get('test1'); + router1.use(() => {}); expect(rootHttpRouter.use).toHaveBeenCalledTimes(1); expect(rootHttpRouter.use).toHaveBeenCalledWith( '/api/test1', expect.any(Function), ); - const handler2 = () => {}; - const router2 = await factory.factory( - { - rootHttpRouter, - plugin: { getId: () => 'test2' }, - lifecycle: { addStartupHook() {}, addShutdownHook() {} }, - }, - undefined, - ); - router2.use(handler2); + const router2 = await tester.get('test2'); + router2.use(() => {}); expect(rootHttpRouter.use).toHaveBeenCalledTimes(2); expect(rootHttpRouter.use).toHaveBeenCalledWith( '/api/test2', @@ -55,37 +45,24 @@ describe('httpRouterFactory', () => { }); it('should use custom path generator', async () => { - const rootHttpRouter = { use: jest.fn() }; - const factory = httpRouterServiceFactory({ - getPath: id => `/some/${id}/path`, - }) as any; - - const handler1 = () => {}; - const router1 = await factory.factory( - { - rootHttpRouter, - plugin: { getId: () => 'test1' }, - lifecycle: { addStartupHook() {}, addShutdownHook() {} }, - }, - undefined, + const rootHttpRouter = mockServices.rootHttpRouter.mock(); + const tester = ServiceFactoryTester.from( + httpRouterServiceFactory({ + getPath: id => `/some/${id}/path`, + }), + { dependencies: [rootHttpRouter.factory] }, ); - router1.use(handler1); + + const router1 = await tester.get('test1'); + router1.use(() => {}); expect(rootHttpRouter.use).toHaveBeenCalledTimes(1); expect(rootHttpRouter.use).toHaveBeenCalledWith( '/some/test1/path', expect.any(Function), ); - const handler2 = () => {}; - const router2 = await factory.factory( - { - rootHttpRouter, - plugin: { getId: () => 'test2' }, - lifecycle: { addStartupHook() {}, addShutdownHook() {} }, - }, - undefined, - ); - router2.use(handler2); + const router2 = await tester.get('test2'); + router2.use(() => {}); expect(rootHttpRouter.use).toHaveBeenCalledTimes(2); expect(rootHttpRouter.use).toHaveBeenCalledWith( '/some/test2/path', diff --git a/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.test.ts b/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.test.ts index d771c5dd11..7b889b3a0e 100644 --- a/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.test.ts +++ b/packages/backend-app-api/src/services/implementations/scheduler/schedulerServiceFactory.test.ts @@ -14,54 +14,33 @@ * limitations under the License. */ -import { - coreServices, - createBackendPlugin, -} from '@backstage/backend-plugin-api'; -import { startTestBackend } from '@backstage/backend-test-utils'; +import { coreServices } from '@backstage/backend-plugin-api'; +import { ServiceFactoryTester } from '@backstage/backend-test-utils'; import { schedulerServiceFactory } from './schedulerServiceFactory'; describe('schedulerFactory', () => { it('creates sidecar database features', async () => { - expect.assertions(3); + const tester = ServiceFactoryTester.from(schedulerServiceFactory()); - const subject = schedulerServiceFactory(); - - const plugin = createBackendPlugin({ - pluginId: 'example', - register(reg) { - reg.registerInit({ - deps: { - scheduler: subject.service, - database: coreServices.database, - }, - init: async ({ scheduler, database }) => { - await scheduler.scheduleTask({ - id: 'task1', - timeout: { seconds: 1 }, - frequency: { seconds: 1 }, - fn: async () => {}, - }); - - const client = await database.getClient(); - await expect( - client.from('backstage_backend_tasks__tasks').count(), - ).resolves.toEqual([{ 'count(*)': 1 }]); - await expect( - client.from('backstage_backend_tasks__knex_migrations').count(), - ).resolves.toEqual([{ 'count(*)': expect.any(Number) }]); - await expect( - client - .from('backstage_backend_tasks__knex_migrations_lock') - .count(), - ).resolves.toEqual([{ 'count(*)': expect.any(Number) }]); - }, - }); - }, + const scheduler = await tester.get(); + await scheduler.scheduleTask({ + id: 'task1', + timeout: { seconds: 1 }, + frequency: { seconds: 1 }, + fn: async () => {}, }); - await startTestBackend({ - features: [plugin(), subject], - }); + const database = await tester.getService(coreServices.database); + + const client = await database.getClient(); + await expect( + client.from('backstage_backend_tasks__tasks').count(), + ).resolves.toEqual([{ 'count(*)': 1 }]); + await expect( + client.from('backstage_backend_tasks__knex_migrations').count(), + ).resolves.toEqual([{ 'count(*)': expect.any(Number) }]); + await expect( + client.from('backstage_backend_tasks__knex_migrations_lock').count(), + ).resolves.toEqual([{ 'count(*)': expect.any(Number) }]); }); }); diff --git a/packages/backend-app-api/src/wiring/ServiceRegistry.ts b/packages/backend-app-api/src/wiring/ServiceRegistry.ts index c89916974b..d009a319ba 100644 --- a/packages/backend-app-api/src/wiring/ServiceRegistry.ts +++ b/packages/backend-app-api/src/wiring/ServiceRegistry.ts @@ -58,9 +58,9 @@ const pluginMetadataServiceFactory = createServiceFactory( ); export class ServiceRegistry implements EnumerableServiceHolder { - static create(factories: Array): EnumerableServiceHolder { + static create(factories: Array): ServiceRegistry { const registry = new ServiceRegistry(factories); - registry.checkForCircularDeps(); + registry.#checkForCircularDeps(); return registry; } @@ -80,7 +80,6 @@ export class ServiceRegistry implements EnumerableServiceHolder { InternalServiceFactory, Promise >(); - readonly #dependencyGraph: DependencyGraph; private constructor(factories: Array) { this.#providedFactories = new Map( @@ -88,15 +87,6 @@ export class ServiceRegistry implements EnumerableServiceHolder { ); this.#loadedDefaultFactories = new Map(); this.#implementations = new Map(); - this.#dependencyGraph = DependencyGraph.fromIterable( - Array.from(this.#providedFactories).map( - ([serviceId, serviceFactory]) => ({ - value: serviceId, - provides: [serviceId], - consumes: Object.values(serviceFactory.deps).map(d => d.id), - }), - ), - ); } #resolveFactory( @@ -163,10 +153,17 @@ export class ServiceRegistry implements EnumerableServiceHolder { } } - checkForCircularDeps(): void { - const circularDependencies = Array.from( - this.#dependencyGraph.detectCircularDependencies(), + #checkForCircularDeps(): void { + const graph = DependencyGraph.fromIterable( + Array.from(this.#providedFactories).map( + ([serviceId, serviceFactory]) => ({ + value: serviceId, + provides: [serviceId], + consumes: Object.values(serviceFactory.deps).map(d => d.id), + }), + ), ); + const circularDependencies = Array.from(graph.detectCircularDependencies()); if (circularDependencies.length) { const cycles = circularDependencies diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 95f67da35f..5831b093c5 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -20,10 +20,13 @@ import { LifecycleService } from '@backstage/backend-plugin-api'; import { LoggerService } from '@backstage/backend-plugin-api'; import { PermissionsService } from '@backstage/backend-plugin-api'; import { RootConfigService } from '@backstage/backend-plugin-api'; +import { RootHttpRouterFactoryOptions } from '@backstage/backend-app-api'; +import { RootHttpRouterService } from '@backstage/backend-plugin-api'; import { RootLifecycleService } from '@backstage/backend-plugin-api'; import { RootLoggerService } from '@backstage/backend-plugin-api'; import { SchedulerService } from '@backstage/backend-plugin-api'; import { ServiceFactory } from '@backstage/backend-plugin-api'; +import { ServiceRef } from '@backstage/backend-plugin-api'; import { TokenManagerService } from '@backstage/backend-plugin-api'; import { UrlReaderService } from '@backstage/backend-plugin-api'; @@ -113,6 +116,17 @@ export namespace mockServices { ) => ServiceFactory; } // (undocumented) + export namespace rootHttpRouter { + const // (undocumented) + factory: ( + options?: RootHttpRouterFactoryOptions | undefined, + ) => ServiceFactory; + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ServiceMock; + } + // (undocumented) export namespace rootLifecycle { const // (undocumented) factory: () => ServiceFactory; @@ -169,6 +183,28 @@ export namespace mockServices { } } +// @public +export class ServiceFactoryTester { + static from( + subject: + | ServiceFactory + | (() => ServiceFactory), + options?: ServiceFactoryTesterOptions, + ): ServiceFactoryTester; + get( + ...args: 'root' extends TScope ? [] : [pluginId?: string] + ): Promise; + getService( + service: ServiceRef, + ...args: 'root' extends TGetScope ? [] : [pluginId?: string] + ): Promise; +} + +// @public +export interface ServiceFactoryTesterOptions { + dependencies?: Array ServiceFactory)>; +} + // @public (undocumented) export type ServiceMock = { factory: ServiceFactory; diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 3edc3e0894..22fba38fda 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -31,6 +31,7 @@ import { lifecycleServiceFactory, loggerServiceFactory, permissionsServiceFactory, + rootHttpRouterServiceFactory, rootLifecycleServiceFactory, schedulerServiceFactory, urlReaderServiceFactory, @@ -184,6 +185,12 @@ export namespace mockServices { use: jest.fn(), })); } + export namespace rootHttpRouter { + export const factory = rootHttpRouterServiceFactory; + export const mock = simpleMock(coreServices.rootHttpRouter, () => ({ + use: jest.fn(), + })); + } export namespace lifecycle { export const factory = lifecycleServiceFactory; export const mock = simpleMock(coreServices.lifecycle, () => ({ diff --git a/packages/backend-test-utils/src/next/wiring/ServiceFactoryTester.test.ts b/packages/backend-test-utils/src/next/wiring/ServiceFactoryTester.test.ts new file mode 100644 index 0000000000..14dd427ecf --- /dev/null +++ b/packages/backend-test-utils/src/next/wiring/ServiceFactoryTester.test.ts @@ -0,0 +1,145 @@ +/* + * 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 { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { ServiceFactoryTester } from './ServiceFactoryTester'; + +const rootServiceRef = createServiceRef({ id: 'a', scope: 'root' }); +const pluginServiceRef = createServiceRef({ id: 'b', scope: 'plugin' }); +const sharedPluginServiceRef = createServiceRef({ + id: 'c', + scope: 'plugin', +}); + +const rootFactory = createServiceFactory({ + service: rootServiceRef, + deps: {}, + factory: async () => 'root', +}); + +const pluginFactory = createServiceFactory({ + service: pluginServiceRef, + deps: { plugin: coreServices.pluginMetadata }, + factory: async ({ plugin }) => `${plugin.getId()}-plugin`, +}); + +const sharedPluginFactory = createServiceFactory({ + service: sharedPluginServiceRef, + deps: { plugin: coreServices.pluginMetadata }, + createRootContext() { + return { counter: 0 }; + }, + factory: async ({ plugin }, state) => { + state.counter += 1; + return `${plugin.getId()}-${state.counter}-plugin`; + }, +}); + +describe('ServiceFactoryTester', () => { + it('should test a root service factory', async () => { + const tester = ServiceFactoryTester.from(rootFactory); + + await expect(tester.get()).resolves.toBe('root'); + }); + + it('should test a plugin service factory', async () => { + const tester = ServiceFactoryTester.from(pluginFactory); + + await expect(tester.get('x')).resolves.toBe('x-plugin'); + await expect(tester.get('y')).resolves.toBe('y-plugin'); + await expect(tester.get('z')).resolves.toBe('z-plugin'); + }); + + it('should test a plugin service factory with root context', async () => { + const tester = ServiceFactoryTester.from(sharedPluginFactory); + + await expect(tester.get('x')).resolves.toBe('x-1-plugin'); + await expect(tester.get('y')).resolves.toBe('y-2-plugin'); + await expect(tester.get('y')).resolves.toBe('y-2-plugin'); + await expect(tester.get('y')).resolves.toBe('y-2-plugin'); + await expect(tester.get('z')).resolves.toBe('z-3-plugin'); + + const tester2 = ServiceFactoryTester.from(sharedPluginFactory); + + await expect(tester2.get('z')).resolves.toBe('z-1-plugin'); + await expect(tester2.get('y')).resolves.toBe('y-2-plugin'); + await expect(tester2.get('x')).resolves.toBe('x-3-plugin'); + await expect(tester2.get('x')).resolves.toBe('x-3-plugin'); + await expect(tester2.get('y')).resolves.toBe('y-2-plugin'); + await expect(tester2.get('z')).resolves.toBe('z-1-plugin'); + }); + + it('should use dependencies', async () => { + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'concat' }), + deps: { root: rootServiceRef, plugin: pluginServiceRef }, + factory: async ({ root, plugin }) => `${root}, ${plugin}`, + }), + { dependencies: [rootFactory, pluginFactory()] }, + ); + + await expect(tester.get('x')).resolves.toBe('root, x-plugin'); + }); + + it('should use dependencies with root context', async () => { + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'concat' }), + deps: { shared: sharedPluginServiceRef, plugin: pluginServiceRef }, + factory: async ({ shared, plugin }) => `${shared}, ${plugin}`, + }), + { dependencies: [sharedPluginFactory(), pluginFactory] }, + ); + + await expect(tester.get('x')).resolves.toBe('x-1-plugin, x-plugin'); + await expect(tester.get('y')).resolves.toBe('y-2-plugin, y-plugin'); + await expect(tester.get('y')).resolves.toBe('y-2-plugin, y-plugin'); + await expect(tester.get('y')).resolves.toBe('y-2-plugin, y-plugin'); + await expect(tester.get('z')).resolves.toBe('z-3-plugin, z-plugin'); + }); + + it('should prioritize the subject implementation', async () => { + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: rootServiceRef, + deps: {}, + factory: async () => 'other-root', + }), + { dependencies: [rootFactory] }, + ); + + await expect(tester.get()).resolves.toBe('other-root'); + }); + + it('should throw on missing dependencies', async () => { + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: pluginServiceRef, + deps: { root: rootServiceRef }, + factory: async () => 'plugin', + }), + ); + + await expect(tester.get('x')).rejects.toThrow( + "Failed to instantiate service 'b' for 'x' because the following dependent services are missing: 'a'", + ); + }); +}); diff --git a/packages/backend-test-utils/src/next/wiring/ServiceFactoryTester.ts b/packages/backend-test-utils/src/next/wiring/ServiceFactoryTester.ts new file mode 100644 index 0000000000..0555ab10a4 --- /dev/null +++ b/packages/backend-test-utils/src/next/wiring/ServiceFactoryTester.ts @@ -0,0 +1,117 @@ +/* + * 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 { ServiceFactory, ServiceRef } from '@backstage/backend-plugin-api'; +import { defaultServiceFactories } from './TestBackend'; +// Direct internal import to avoid duplication. +// This is a relative import in order to make sure that the implementation is duplicated +// rather than leading to an import from @backstage/backend-app-api. +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ServiceRegistry } from '../../../../backend-app-api/src/wiring/ServiceRegistry'; + +/** + * Options for {@link ServiceFactoryTester}. + * @public + */ +export interface ServiceFactoryTesterOptions { + /** + * Additional service factories to make available as dependencies. + * + * @remarks + * + * If a service factory is provided for a service that already has a default + * implementation, the provided factory will override the default. + */ + dependencies?: Array ServiceFactory)>; +} + +/** + * A utility to help test service factories in isolation. + * + * @public + */ +export class ServiceFactoryTester { + readonly #subject: ServiceRef; + readonly #registry: ServiceRegistry; + + /** + * Creates a new {@link ServiceFactoryTester} used to test the provided subject. + * + * @param subject - The service factory to test. + * @param options - Additional options + * @returns A new tester instance for the provided subject. + */ + static from( + subject: + | ServiceFactory + | (() => ServiceFactory), + options?: ServiceFactoryTesterOptions, + ) { + const subjectFactory = typeof subject === 'function' ? subject() : subject; + const registry = ServiceRegistry.create([ + ...defaultServiceFactories, + ...(options?.dependencies?.map(f => + typeof f === 'function' ? f() : f, + ) ?? []), + subjectFactory, + ]); + return new ServiceFactoryTester(subjectFactory.service, registry); + } + + private constructor( + subject: ServiceRef, + registry: ServiceRegistry, + ) { + this.#subject = subject; + this.#registry = registry; + } + + /** + * Returns the service instance for the subject. + * + * @remarks + * + * If the subject is a plugin scoped service factory a plugin ID + * can be provided to instantiate the service for a specific plugin. + * + * By default the plugin ID 'test' is used. + */ + async get( + ...args: 'root' extends TScope ? [] : [pluginId?: string] + ): Promise { + const [pluginId] = args; + return this.#registry.get(this.#subject, pluginId ?? 'test')!; + } + + /** + * Return the service instance for any of the provided dependencies or built-in services. + * + * @remarks + * + * A plugin ID can optionally be provided for plugin scoped services, otherwise the plugin ID 'test' is used. + */ + async getService( + service: ServiceRef, + ...args: 'root' extends TGetScope ? [] : [pluginId?: string] + ): Promise { + const [pluginId] = args; + const instance = await this.#registry.get(service, pluginId ?? 'test'); + if (instance === undefined) { + throw new Error(`Service '${service.id}' not found`); + } + return instance; + } +} diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 97da3543ae..0b92ec3353 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -61,7 +61,7 @@ export interface TestBackend extends Backend { readonly server: ExtendedHttpServer; } -const defaultServiceFactories = [ +export const defaultServiceFactories = [ mockServices.cache.factory(), mockServices.rootConfig.factory(), mockServices.database.factory(), diff --git a/packages/backend-test-utils/src/next/wiring/index.ts b/packages/backend-test-utils/src/next/wiring/index.ts index 7c39474d4c..bd6a61317f 100644 --- a/packages/backend-test-utils/src/next/wiring/index.ts +++ b/packages/backend-test-utils/src/next/wiring/index.ts @@ -14,5 +14,9 @@ * limitations under the License. */ +export { + ServiceFactoryTester, + type ServiceFactoryTesterOptions, +} from './ServiceFactoryTester'; export { startTestBackend } from './TestBackend'; export type { TestBackend, TestBackendOptions } from './TestBackend';