Notes Used to Craft this Lesson
So, what about if our service has a dependency? For example, our NotificationService depends on the AdonisJS logger.
import { inject } from "@adonisjs/core"; import { Logger } from "@adonisjs/core/logger"; @inject() export default class NotificationService { constructor(protected logger: Logger) {} // 👈 /** * Simulates sending a notification with the given message. * This is a placeholder for an actual notification service. * It will log the message and then wait for 5 milliseconds before resolving. * * @param message - The message to be sent in the notification. */ async send(message: string) { this.logger.info("Simulating notification %s", message); await new Promise((resolve) => setTimeout(resolve, 5)); } }Copied!
- app
- services
- notification_service.ts
Remember, the unit test suite doesn't boot the AdonisJS HTTP Server; however, we'll still be able to utilize the IoC Container to manage these dependencies, so we'll keep this a unit test.
node ace make:test services/notification_service --suite=unitCopied!
Great, within here let's test that our NotificationService is receiving the logger. This isn't something you'd typically test, but with it, we can demonstrate a few dependency options we have with AdonisJS.
import { test } from "@japa/runner"; test.group("Services notification service", () => { test("inject the adonisjs logger", async ({ assert }) => {}); });Copied!
Our first option, as we saw in the last lesson, is to instantiate an instance of our service manually.
++import NotificationService from "#services/notification_service"; import { test } from "@japa/runner"; test.group("Services notification service", () => { test("inject the adonisjs logger", async ({ assert }) => { const notificationService = new NotificationService() }); });Copied!
We can then confirm all is working by asserting that our service's send method runs without issue.
import NotificationService from "#services/notification_service"; import { test } from "@japa/runner"; test.group("Services notification service", () => { test("inject the adonisjs logger", async ({ assert }) => { const notificationService = new NotificationService(); ++assert.doesNotThrow(() => notificationService.send("hello")); }); });Copied!
The issue here, however, is that our service requires the AdonisJS logger. Without it, our test fails because logger does not have a value. No worries, we can grab the logger from the AdonisJS Container via its binding.
import NotificationService from "#services/notification_service"; import { Logger } from "@adonisjs/core/logger"; import app from "@adonisjs/core/services/app"; import { test } from "@japa/runner"; test.group("Services notification service", () => { test("inject the adonisjs logger", async ({ assert }) => { const logger = await app.container.make(Logger) const notificationService = new NotificationService(logger) assert.doesNotThrow(() => notificationService.send('hello')) }); });Copied!
Another option we have, since the logger is already bound to the AdonisJS Container, is to just let the container resolve the binding for us and instantiate an instance of our service all in one go!
import NotificationService from "#services/notification_service"; import { Logger } from "@adonisjs/core/logger"; import app from "@adonisjs/core/services/app"; import { test } from "@japa/runner"; test.group("Services notification service", () => { test("inject the adonisjs logger", async ({ assert }) => { const notificationService = await app.container.make(NotificationService) assert.doesNotThrow(() => notificationService.send('hello')) }); });Copied!
Next, if our logger is logging to a file or external service, then we wouldn't want our tests to actually send those logs out.
So, what we'd want to do instead is define a mock logger to stand in place of the actual logger dependency for our test. This can also be great if we need to assert against something that wouldn't otherwise be exposed, for example, if we wanted to confirm a log message is actually sent.
We can keep it simple and mock the specific method we need to test. If you need reusability, you can also place these within their own mocks folder inside your tests directory. To make the typing here happy, we'll extend the actual Logger and override the specific method used in our test.
test.group("Services notification service", () => { // ... test("log an info message prior to sending notification", async ({ assert, }) => { // mock logger that overrides the method we'll be using in this test class MockService extends Logger { messages: { message: string; values: string[] }[] = []; info(message: string, ...values: string[]) { this.messages.push({ message, values }); } } }); });Copied!
Great, now what we want to do is swap this mock logger in place of the dependency-injected logger using the AdonisJS container.
test.group("Services notification service", () => { // ... test("log an info message prior to sending notification", async ({ assert, }) => { class MockService extends Logger { messages: { message: string; values: string[] }[] = []; info(message: string, ...values: string[]) { this.messages.push({ message, values }); } } const mockService = new MockService({}); // note that you can also instantiate the class directly in the callback if the instance isn't needed for assertions app.container.swap(Logger, () => mockService); }); });Copied!
Now, whenever the AdonisJS container comes across a Logger dependency, it'll use the provided MockService instead of the actual logger.
For the container to properly perform this swap, though, we do want to use the container itself to make our NotificationService.
test.group("Services notification service", () => { // ... test("log an info message prior to sending notification", async ({ assert, }) => { class MockService extends Logger { messages: { message: string; values: string[] }[] = []; info(message: string, ...values: string[]) { this.messages.push({ message, values }); } } const mockService = new MockService({}); app.container.swap(Logger, () => mockService); const notificationService = await app.container.make(NotificationService); }); });Copied!
Perfect, that's all the prep work we need, leaving us with our act and assert steps!
test.group("Services notification service", () => { // ... test("log an info message prior to sending notification", async ({ assert, }) => { class MockService extends Logger { messages: { message: string; values: string[] }[] = []; info(message: string, ...values: string[]) { this.messages.push({ message, values }); } } const mockService = new MockService({}); app.container.swap(Logger, () => mockService); const notificationService = await app.container.make(NotificationService); await notificationService.send("hello"); assert.deepEqual(mockService.messages, [ { message: "Simulating notification %s", values: ["hello"], }, ]); }); });Copied!
Finally, when we perform a swap on our container, that is going to persist beyond this test. However, we want our tests to be isolated and without side effects. To fix this, we can add a cleanup step.
As we covered previously, we could chain off our test with a cleanup hook.
test.group("Services notification service", () => { test("log an info message prior to sending notification", async ({ assert, }) => { class MockService extends Logger { messages: { message: string; values: string[] }[] = []; info(message: string, ...values: string[]) { this.messages.push({ message, values }); } } const mockService = new MockService({}); app.container.swap(Logger, () => mockService); const notificationService = await app.container.make(NotificationService); await notificationService.send("hello"); assert.deepEqual(mockService.messages, [ { message: "Simulating notification %s", values: ["hello"], }, ]); }).cleanup(() => app.container.restore(Logger)); });Copied!
Calling restore here will remove the previously added swap specifically for the logger, note that there is also restoreAll to restore multiple or all bindings.
And there we have it, we're now using our own mock logger in place of the actual logger, and we're confirming that our info method is called via our assertion.
Now that we have the completed mock picture, a mock is when we use a minimal replication and assert that something was called. We'll be talking about Fakes next, which are similar to Mocks, but instead deal with replication the whole picture. So, if we had replicated the entire logger, that would've made it a Fake instead of a Mock. But, don't get too hung up on the semantics of it, at the end of the day, both deal with mimicking behaviors.