diff --git a/index.d.ts b/index.d.ts index d36142a4..992ba249 100644 --- a/index.d.ts +++ b/index.d.ts @@ -86,6 +86,7 @@ import * as METRIC from "./src/metrics/constants"; // --- MIDDLEWARES --- import type { CallMiddlewareHandler, Middleware } from "./src/middleware"; +import * as Testing from "./src/testing"; // --- SERVICE REGISTRY --- @@ -177,6 +178,7 @@ declare namespace Moleculer { export { MetricTypes, MetricReporters, MetricRegistry, METRIC }; export { CallMiddlewareHandler, Middleware }; + export { Testing }; export { Registry, Discoverers, EndpointList, Endpoint, ActionEndpoint, EventEndpoint }; diff --git a/index.js b/index.js index 7d005cf3..eb08dbc0 100644 --- a/index.js +++ b/index.js @@ -42,6 +42,7 @@ module.exports = { Discoverers: require("./src/registry/discoverers"), Middlewares: require("./src/middlewares"), + Testing: require("./src/testing"), Errors: require("./src/errors"), diff --git a/index.mjs b/index.mjs index cd6cecb8..ee0b7f86 100644 --- a/index.mjs +++ b/index.mjs @@ -27,6 +27,7 @@ export const Strategies = mod.Strategies; export const TracerExporters = mod.TracerExporters; export const Transit = mod.Transit; export const Transporters = mod.Transporters; +export const Testing = mod.Testing; export const Utils = mod.Utils; export const Validator = mod.Validator; export const Validators = mod.Validators; diff --git a/src/testing/event-catcher.js b/src/testing/event-catcher.js new file mode 100644 index 00000000..51aa62a2 --- /dev/null +++ b/src/testing/event-catcher.js @@ -0,0 +1,107 @@ +/* + * moleculer + * Copyright (c) 2026 MoleculerJS (https://github.com/moleculerjs/moleculer) + * MIT Licensed + */ + +"use strict"; + +const { isFunction } = require("../utils"); + +function normalizeOptions(opts) { + if (typeof opts === "number") return { timeout: opts }; + return opts || {}; +} + +module.exports = function EventCatcherMiddleware(broker) { + const events = []; + const waiters = []; + + function matches(expected, actual) { + if (expected == null) return true; + if (isFunction(expected)) return expected(actual); + if (expected instanceof RegExp) return expected.test(actual.name); + return expected === actual.name; + } + + function resolveWaiters(event) { + for (let i = waiters.length - 1; i >= 0; i--) { + const waiter = waiters[i]; + if (matches(waiter.expected, event)) { + clearTimeout(waiter.timer); + waiters.splice(i, 1); + waiter.resolve(event); + } + } + } + + function record(name, payload, opts, type) { + const event = { + name, + payload, + opts, + type, + timestamp: Date.now() + }; + + events.push(event); + resolveWaiters(event); + } + + broker.events = { + getEvents() { + return events.slice(); + }, + + clear() { + events.length = 0; + }, + + find(expected) { + return events.find(event => matches(expected, event)); + }, + + waitFor(expected, opts) { + opts = normalizeOptions(opts); + const timeout = opts.timeout == null ? 1000 : opts.timeout; + const existing = events.find(event => matches(expected, event)); + + if (existing) return broker.Promise.resolve(existing); + + return new broker.Promise((resolve, reject) => { + const waiter = { expected, resolve }; + waiter.timer = setTimeout(() => { + const idx = waiters.indexOf(waiter); + if (idx !== -1) waiters.splice(idx, 1); + reject(new Error(`Event '${expected}' was not emitted within ${timeout}ms.`)); + }, timeout); + waiters.push(waiter); + }); + } + }; + + return { + name: "EventCatcher", + + emit(next) { + return (eventName, payload, opts) => { + record(eventName, payload, opts, "emit"); + return next(eventName, payload, opts); + }; + }, + + broadcast(next) { + return (eventName, payload, opts) => { + record(eventName, payload, opts, "broadcast"); + return next(eventName, payload, opts); + }; + }, + + broadcastLocal(next) { + return (eventName, payload, opts) => { + record(eventName, payload, opts, "broadcastLocal"); + return next(eventName, payload, opts); + }; + } + }; +}; diff --git a/src/testing/index.d.ts b/src/testing/index.d.ts new file mode 100644 index 00000000..de83b5cf --- /dev/null +++ b/src/testing/index.d.ts @@ -0,0 +1,42 @@ +import type { BrokerOptions, CallingOptions } from "../service-broker"; +import type ServiceBroker = require("../service-broker"); +import type { ServiceSchema } from "../service"; +import type { Middleware } from "../middleware"; + +export interface CaughtEvent { + name: string; + payload: TPayload; + opts?: Record; + type: "emit" | "broadcast" | "broadcastLocal"; + timestamp: number; +} + +export interface TestingBroker extends ServiceBroker { + events: { + getEvents(): CaughtEvent[]; + clear(): void; + find( + expected?: string | RegExp | ((event: CaughtEvent) => boolean) + ): CaughtEvent | undefined; + waitFor( + expected?: string | RegExp | ((event: CaughtEvent) => boolean), + opts?: number | { timeout?: number } + ): Promise; + }; + mockAction(actionName: string, handler: Function | any): this; + clearActionMocks(): this; + getMockedActionCalls(actionName?: string): Array<{ + actionName: string; + params: any; + opts: CallingOptions; + result?: any; + }>; +} + +export interface TestBrokerOptions extends BrokerOptions { + mockServices?: ServiceSchema[] | Record>; +} + +export declare function createBroker(opts?: TestBrokerOptions): TestingBroker; +export declare function EventCatcher(broker: ServiceBroker): Middleware; +export declare function MockingCalls(broker: ServiceBroker): Middleware; diff --git a/src/testing/index.js b/src/testing/index.js new file mode 100644 index 00000000..b5729b10 --- /dev/null +++ b/src/testing/index.js @@ -0,0 +1,47 @@ +/* + * moleculer + * Copyright (c) 2026 MoleculerJS (https://github.com/moleculerjs/moleculer) + * MIT Licensed + */ + +"use strict"; + +const _ = require("lodash"); + +const ServiceBroker = require("../service-broker"); +const EventCatcher = require("./event-catcher"); +const MockingCalls = require("./mocking-calls"); + +function toServiceSchemas(mockServices) { + if (!mockServices) return []; + if (Array.isArray(mockServices)) return mockServices; + + return Object.keys(mockServices).map(name => { + const service = mockServices[name]; + return { + name, + ...service + }; + }); +} + +function createBroker(opts = {}) { + const { mockServices, middlewares, ...brokerOptions } = opts; + const broker = new ServiceBroker( + _.defaultsDeep({}, brokerOptions, { + logger: false, + test: true, + middlewares: [EventCatcher, MockingCalls].concat(middlewares || []) + }) + ); + + toServiceSchemas(mockServices).forEach(schema => broker.createService(schema)); + + return broker; +} + +module.exports = { + createBroker, + EventCatcher, + MockingCalls +}; diff --git a/src/testing/mocking-calls.js b/src/testing/mocking-calls.js new file mode 100644 index 00000000..1d7ad204 --- /dev/null +++ b/src/testing/mocking-calls.js @@ -0,0 +1,55 @@ +/* + * moleculer + * Copyright (c) 2026 MoleculerJS (https://github.com/moleculerjs/moleculer) + * MIT Licensed + */ + +"use strict"; + +const { isFunction } = require("../utils"); + +module.exports = function MockingCallsMiddleware(broker) { + const mocks = new Map(); + const calls = []; + + function normalizeMock(handler) { + if (isFunction(handler)) return handler; + return () => handler; + } + + broker.mockAction = function mockAction(actionName, handler) { + mocks.set(actionName, normalizeMock(handler)); + return broker; + }; + + broker.clearActionMocks = function clearActionMocks() { + mocks.clear(); + calls.length = 0; + return broker; + }; + + broker.getMockedActionCalls = function getMockedActionCalls(actionName) { + const selected = actionName ? calls.filter(call => call.actionName === actionName) : calls; + return selected.slice(); + }; + + return { + name: "MockingCalls", + + call(next) { + return (actionName, params, opts = {}) => { + if (!mocks.has(actionName)) return next(actionName, params, opts); + + const call = { actionName, params, opts }; + calls.push(call); + + return broker.Promise.resolve() + .then(() => mocks.get(actionName).call(broker, params, opts, call)) + .then(result => { + call.result = result; + return result; + }); + }; + } + }; +}; diff --git a/test/unit/testing.spec.js b/test/unit/testing.spec.js new file mode 100644 index 00000000..c45d7460 --- /dev/null +++ b/test/unit/testing.spec.js @@ -0,0 +1,120 @@ +"use strict"; + +const { ServiceBroker, Testing } = require("../.."); + +describe("Test Testing helpers", () => { + /** @type {import("../../src/testing").TestingBroker} */ + let broker; + + afterEach(async () => { + if (broker) { + await broker.stop(); + broker = null; + } + }); + + it("should create a quiet test broker with testing helpers", () => { + broker = Testing.createBroker(); + + expect(broker).toBeInstanceOf(ServiceBroker); + expect(broker.options.logger).toBe(false); + expect(broker.options.test).toBe(true); + expect(broker.events).toBeDefined(); + expect(broker.mockAction).toBeInstanceOf(Function); + }); + + it("should register mock services", async () => { + broker = Testing.createBroker({ + mockServices: { + greeter: { + actions: { + hello(ctx) { + return `Hello ${ctx.params.name}`; + } + } + } + } + }); + + await broker.start(); + + await expect(broker.call("greeter.hello", { name: "Ada" })).resolves.toBe("Hello Ada"); + }); + + it("should mock dependent action calls and keep call history", async () => { + broker = Testing.createBroker({ + mockServices: { + posts: { + actions: { + async list() { + return this.broker.call("users.list", { active: true }); + } + } + } + } + }); + + broker.mockAction("users.list", params => [{ id: 1, active: params.active }]); + + await broker.start(); + + await expect(broker.call("posts.list")).resolves.toEqual([{ id: 1, active: true }]); + expect(broker.getMockedActionCalls("users.list")).toEqual([ + expect.objectContaining({ + actionName: "users.list", + params: { active: true }, + result: [{ id: 1, active: true }] + }) + ]); + }); + + it("should capture emitted events and wait for matching events", async () => { + broker = Testing.createBroker({ + mockServices: { + users: { + actions: { + create(ctx) { + this.broker.emit("user.created", { id: ctx.params.id }); + return true; + } + } + } + } + }); + + await broker.start(); + broker.events.clear(); + + const waited = broker.events.waitFor("user.created"); + await broker.call("users.create", { id: 5 }); + + await expect(waited).resolves.toEqual( + expect.objectContaining({ + name: "user.created", + payload: { id: 5 }, + type: "emit" + }) + ); + expect(broker.events.getEvents()).toHaveLength(1); + }); + + it("should clear mocks and captured events", async () => { + broker = Testing.createBroker(); + broker.mockAction("users.list", []); + + await broker.start(); + broker.events.clear(); + + await broker.call("users.list"); + broker.emit("user.created", { id: 1 }); + + expect(broker.getMockedActionCalls()).toHaveLength(1); + expect(broker.events.getEvents()).toHaveLength(1); + + broker.clearActionMocks(); + broker.events.clear(); + + expect(broker.getMockedActionCalls()).toHaveLength(0); + expect(broker.events.getEvents()).toHaveLength(0); + }); +});