backend-test-utils: add mock implementation for scheduler service

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-08-15 16:58:14 +02:00
parent 643db70f09
commit dffaf708e9
6 changed files with 421 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': minor
---
Switched out `mockServices.scheduler` to use a mocked implementation instead of the default scheduler implementation. This implementation runs any scheduled tasks immediately on startup, as long as they don't have an initial delay or a manual trigger. After the initial run, the tasks are never run again unless manually triggered.
@@ -129,3 +129,31 @@ Responds with
- `200 OK` if successful
- `404 Not Found` if there was no such registered task for this plugin
- `409 Conflict` if the task was already in a running state
## Testing
The `@backstage/backend-test-utils` package provides `mockServices.scheduler`, which provides a mocked implementation of the scheduler service that can be used in tests. This mocked implementation is used by default in `startTestBackend`, and it will immediately run any registered tasks on startup as long as they're not configured to run manually or with an initial delay.
A dedicated instance can be used for more control during testing, with the mock implementation providing additional utilities to trigger and wait for tasks to complete:
```ts
it('should trigger a task', async () => {
const scheduler = mockServices.scheduler();
const { server } = await startTestBackend({
features: [scheduler.factory()],
});
// Start waiting for some task to complete
const waitForTask = scheduler.waitForTask('some-task-id');
// Call an endpoit that triggers a task
const res = await request(server).post(
'/api/my-plugin/route-that-triggers-a-task',
);
expect(res.status).toBe(200);
// Wait for the task to complete
await waitForTask;
});
```
+7 -1
View File
@@ -366,9 +366,15 @@ export namespace mockServices {
) => ServiceMock<RootLoggerService>;
}
// (undocumented)
export function scheduler(): SchedulerService;
// (undocumented)
export namespace scheduler {
const // (undocumented)
factory: () => ServiceFactory<SchedulerService, 'plugin', 'singleton'>;
factory: (options?: {
skipTaskRunOnStartup?: boolean;
includeManualTasksOnStartup?: boolean;
includeInitialDelayedTasksOnStartup?: boolean;
}) => ServiceFactory<SchedulerService, 'plugin', 'singleton'>;
const // (undocumented)
mock: (
partialImpl?: Partial<SchedulerService> | undefined,
@@ -0,0 +1,208 @@
/*
* 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 {
coreServices,
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { startTestBackend } from '../wiring';
import { MockSchedulerService } from './MockSchedulerService';
import { mockServices } from './mockServices';
import { setTimeout } from 'timers/promises';
const baseOpts = {
frequency: { seconds: 10 },
timeout: { seconds: 10 },
};
describe('MockSchedulerService', () => {
it('should run a task', async () => {
const scheduler = new MockSchedulerService();
expect(scheduler).toBeDefined();
const taskFn = jest.fn();
scheduler.scheduleTask({
...baseOpts,
id: 'test',
fn: taskFn,
});
expect(taskFn).not.toHaveBeenCalled();
await scheduler.triggerTask('test');
expect(taskFn).toHaveBeenCalled();
});
it('should run tasks on startup', async () => {
const testFnPlain = jest.fn();
const testFnInitialDelay = jest.fn();
const testFnManual = jest.fn();
const testFnLocal = jest.fn();
// Relying on the fact that the mock scheduler service is used by default
await startTestBackend({
features: [
createBackendPlugin({
pluginId: 'tester',
register(reg) {
reg.registerInit({
deps: { scheduler: coreServices.scheduler },
async init({ scheduler }) {
scheduler.scheduleTask({
...baseOpts,
id: 'test-plain',
fn: testFnPlain,
});
scheduler.scheduleTask({
...baseOpts,
id: 'test-local',
scope: 'local',
fn: testFnLocal,
});
// Should not run by default
scheduler.scheduleTask({
...baseOpts,
id: 'test-with-initial-delay',
initialDelay: { seconds: 1 },
fn: testFnInitialDelay,
});
scheduler.scheduleTask({
...baseOpts,
id: 'test-manual',
frequency: { trigger: 'manual' },
fn: testFnManual,
});
},
});
},
}),
],
});
expect(testFnPlain).toHaveBeenCalled();
expect(testFnLocal).toHaveBeenCalled();
expect(testFnInitialDelay).not.toHaveBeenCalled();
expect(testFnManual).not.toHaveBeenCalled();
});
it('should not run tasks on startup if skipped', async () => {
const testFnPlain = jest.fn();
await startTestBackend({
features: [
new MockSchedulerService().factory({ skipTaskRunOnStartup: true }),
createBackendPlugin({
pluginId: 'tester',
register(reg) {
reg.registerInit({
deps: { scheduler: coreServices.scheduler },
async init({ scheduler }) {
scheduler.scheduleTask({
...baseOpts,
id: 'test-plain',
fn: testFnPlain,
});
},
});
},
}),
],
});
expect(testFnPlain).not.toHaveBeenCalled();
});
it('should run all tasks on startup if configured', async () => {
const testFnPlain = jest.fn();
const testFnInitialDelay = jest.fn();
const testFnManual = jest.fn();
const testFnLocal = jest.fn();
await startTestBackend({
features: [
mockServices.scheduler.factory({
includeManualTasksOnStartup: true,
includeInitialDelayedTasksOnStartup: true,
}),
createBackendPlugin({
pluginId: 'tester',
register(reg) {
reg.registerInit({
deps: { scheduler: coreServices.scheduler },
async init({ scheduler }) {
scheduler.scheduleTask({
...baseOpts,
id: 'test-plain',
fn: testFnPlain,
});
scheduler.scheduleTask({
...baseOpts,
id: 'test-local',
scope: 'local',
fn: testFnLocal,
});
// Should not run by default
scheduler.scheduleTask({
...baseOpts,
id: 'test-with-initial-delay',
initialDelay: { seconds: 1 },
fn: testFnInitialDelay,
});
scheduler.scheduleTask({
...baseOpts,
id: 'test-manual',
frequency: { trigger: 'manual' },
fn: testFnManual,
});
},
});
},
}),
],
});
expect(testFnPlain).toHaveBeenCalled();
expect(testFnLocal).toHaveBeenCalled();
expect(testFnInitialDelay).toHaveBeenCalled();
expect(testFnManual).toHaveBeenCalled();
});
it('should wait for a specific task to complete', async () => {
const scheduler = new MockSchedulerService();
const taskFn = jest.fn();
scheduler.scheduleTask({
...baseOpts,
id: 'test',
fn: taskFn,
});
const wait = scheduler.waitForTask('test');
const isDone = () =>
Promise.race([wait.then(() => true), setTimeout(1, false)]);
expect(taskFn).not.toHaveBeenCalled();
await expect(isDone()).resolves.toBe(false);
await scheduler.triggerTask('test');
expect(taskFn).toHaveBeenCalled();
await expect(isDone()).resolves.toBe(true);
});
});
@@ -0,0 +1,164 @@
/*
* 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 {
coreServices,
createServiceFactory,
SchedulerService,
SchedulerServiceTaskDescriptor,
SchedulerServiceTaskInvocationDefinition,
SchedulerServiceTaskRunner,
SchedulerServiceTaskScheduleDefinition,
} from '@backstage/backend-plugin-api';
import { createDeferred, DeferredPromise } from '@backstage/types';
export class MockSchedulerService implements SchedulerService {
readonly #tasks = new Map<
string,
SchedulerServiceTaskInvocationDefinition &
SchedulerServiceTaskScheduleDefinition & {
descriptor: SchedulerServiceTaskDescriptor;
}
>();
readonly #runningTasks = new Set<string>();
readonly #deferredTaskCompletions = new Map<string, DeferredPromise<void>>();
/**
* Creates a service factory for this mock scheduler instance, which can be installed in a test backend
*/
factory(options?: {
skipTaskRunOnStartup?: boolean;
includeManualTasksOnStartup?: boolean;
includeInitialDelayedTasksOnStartup?: boolean;
}) {
return createServiceFactory({
service: coreServices.scheduler,
deps: { lifecycle: coreServices.lifecycle },
factory: async ({ lifecycle }) => {
if (!options?.skipTaskRunOnStartup) {
lifecycle.addStartupHook(async () => {
await this.triggerAllTasks({
includeManualTasks: options?.includeManualTasksOnStartup,
includeInitialDelayedTasks:
options?.includeInitialDelayedTasksOnStartup,
});
});
}
return this;
},
});
}
createScheduledTaskRunner(
schedule: SchedulerServiceTaskScheduleDefinition,
): SchedulerServiceTaskRunner {
return {
run: async task => {
await this.scheduleTask({ ...task, ...schedule });
},
};
}
async getScheduledTasks(): Promise<SchedulerServiceTaskDescriptor[]> {
return Array.from(this.#tasks.values()).map(({ descriptor }) => descriptor);
}
async scheduleTask(
task: SchedulerServiceTaskScheduleDefinition &
SchedulerServiceTaskInvocationDefinition,
): Promise<void> {
this.#tasks.set(task.id, {
...task,
descriptor: {
id: task.id,
scope: task.scope ?? 'global',
settings: { version: 1 },
},
});
}
async triggerTask(id: string): Promise<void> {
const task = this.#tasks.get(id);
if (!task) {
throw new Error(`Task ${id} not found`);
}
if (this.#runningTasks.has(id)) {
return;
}
this.#runningTasks.add(id);
try {
await task.fn(new AbortController().signal);
this.#deferredTaskCompletions.get(id)?.resolve();
} catch (error) {
this.#deferredTaskCompletions.get(id)?.reject(error);
} finally {
this.#runningTasks.delete(id);
}
}
/**
* Trigger all tasks that match the given options, and wait for them to complete.
*
* @param options - The options to filter the tasks to trigger
*/
async triggerAllTasks(options?: {
scope?: 'all' | 'global' | 'local';
includeInitialDelayedTasks?: boolean;
includeManualTasks?: boolean;
}): Promise<void> {
const {
scope = 'all',
includeManualTasks = false,
includeInitialDelayedTasks = false,
} = options ?? {};
const selectedTaskIds = new Array<string>();
for (const task of this.#tasks.values()) {
if (task.initialDelay && !includeInitialDelayedTasks) {
continue;
}
if ('trigger' in task.frequency && task.frequency.trigger === 'manual') {
if (includeManualTasks) {
selectedTaskIds.push(task.id);
}
continue;
}
if (scope === 'all' || scope === task.scope) {
selectedTaskIds.push(task.id);
}
}
await Promise.all(selectedTaskIds.map(id => this.triggerTask(id)));
}
/**
* Wait for the task with the given ID to complete.
*
* If the task has not yet been scheduler or started, this will wait for it to be scheduled, started, and completed
*
* @param id - The task ID to wait for
* @returns A promise that resolves when the task is completed
*/
async waitForTask(id: string): Promise<void> {
const existing = this.#deferredTaskCompletions.get(id);
if (existing) {
return existing;
}
const defferred = createDeferred<void>();
this.#deferredTaskCompletions.set(id, defferred);
return defferred;
}
}
@@ -26,7 +26,6 @@ import { permissionsRegistryServiceFactory } from '@backstage/backend-defaults/p
import { rootHealthServiceFactory } from '@backstage/backend-defaults/rootHealth';
import { rootHttpRouterServiceFactory } from '@backstage/backend-defaults/rootHttpRouter';
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler';
import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader';
import {
AuthService,
@@ -57,6 +56,7 @@ import { mockCredentials } from './mockCredentials';
import { MockEventsService } from './MockEventsService';
import { MockPermissionsService } from './MockPermissionsService';
import { simpleMock } from './simpleMock';
import { MockSchedulerService } from './MockSchedulerService';
/** @internal */
function createLoggerMock() {
@@ -496,8 +496,15 @@ export namespace mockServices {
}));
}
export function scheduler(): MockSchedulerService {
return new MockSchedulerService();
}
export namespace scheduler {
export const factory = () => schedulerServiceFactory;
export const factory = (options?: {
skipTaskRunOnStartup?: boolean;
includeManualTasksOnStartup?: boolean;
includeInitialDelayedTasksOnStartup?: boolean;
}) => new MockSchedulerService().factory(options);
export const mock = simpleMock(coreServices.scheduler, () => ({
createScheduledTaskRunner: jest.fn(),
getScheduledTasks: jest.fn(),