Last year I had to upgrade the hubot-command-mapper to Hubot 9 and I had to revisit testing using the hubot-mock-adapter. This week I'm looking into upgrading some projects to Hubot 11, which is an ESM edition. Unfortunately, the hubot-mock-adapter is not compatible with Hubot 11 / ESM.
I've decided to upgrade the hubot-command-mapper and the bot-zero projects to ESM, just like Hubot 11. This blog will focus on how to upgrade the unit tests. In that sense it is a short rewrite of Hubot testing revisited.
Disclaimer: I'm on TypeScript, so I'm not writing ESM directly.
Package / TypeScript / Mocha changes
First, we need to set "type": "module" in our package.json.
Then, we need to change our tsconfig.json to make it generate modules (here is a link to the old one).
{
"compilerOptions": {
"module": "NodeNext",
"target": "ES2022",
"outDir": "dist",
"esModuleInterop": true,
"declaration": true,
"moduleResolution": "nodenext"
}
}I needed to drop the typeAcquisition.enable to get the proper type hints in VSCode. If you keep it there, it will still try to use the @typing/hubot, which are super out of date and not even needed.
Now, to make Mocha behave properly I've changed the test script of my package.json into:
"test": "mocha -n=loader=ts-node/esm -n=es-module-specifier-resolution=node test/**/*.spec.ts --exit"A Mock Adapter
To start, we need to airlift the MockAdapter from the Hubot project and make it compatible with TypeScript. I've added the result to file test-adapter.ts.
import { Adapter, Envelope, Robot } from "hubot"
export class MockAdapter extends Adapter {
name: string
constructor(robot: Robot) {
super(robot)
this.name = "MockAdapter"
}
async send(envelope: Envelope, ...strings: string[]) {
super.emit("send", envelope, ...strings)
}
async reply(envelope: Envelope, ...strings: string[]) {
super.emit("reply", envelope, ...strings)
}
async topic(envelope: Envelope, ...strings: string[]) {
super.emit("topic", envelope, ...strings)
}
async play(envelope: Envelope, ...strings: string[]) {
super.emit("play", envelope, ...strings)
}
run() {
// This is required to get the scripts loaded
super.emit("connected")
}
close() {
super.emit("closed")
}
}
export default {
use(robot: Robot) {
return new MockAdapter(robot)
}
}Not sure why, but the this.emit of the original class would not compile, so I had to use super.emit.
A Test Bot
Next, we'll create a test-bot.ts which helps us to create a scoped test bot that holds everything it needs together, without interfering with other tests:
import { Robot, TextMessage, User } from "hubot"
import mockAdapter from "./test-adapter.js"
export type ResponseType = "send" | "reply"
export class TestBotContext {
public readonly replies: string[] = []
public readonly sends: string[] = []
constructor(
public readonly robot: Robot,
public readonly user: User
) {
this.robot.adapter.on("reply", (_, msg) => {
this.replies.push(msg)
})
this.robot.adapter.on("send", (_, msg) => {
this.sends.push(msg)
})
}
async sendAndWaitForResponse(message: string, responseType: ResponseType = "reply") {
return new Promise<string>(done => {
this.robot.adapter.once(responseType, function (_, msg) {
done(msg)
})
this.send(message)
})
}
async send(message: string) {
const id = (Math.random() + 1).toString(36).substring(7)
const textMessage = new TextMessage(this.user, message, id)
this.robot.adapter.receive(textMessage)
await this.wait(1)
}
async wait(ms: number) {
return new Promise<void>(done => {
setTimeout(() => done(), ms)
})
}
shutdown(): void {
this.robot.shutdown()
delete process.env.HUBOT_LOG_LEVEL
}
}
export type TestBotSettings = {
name?: string
alias?: string
logLevel?: string
testUserName?: string
}
export async function createTestBot(settings: TestBotSettings | null = null): Promise<TestBotContext> {
process.env.HUBOT_LOG_LEVEL = settings?.logLevel || "silent"
return new Promise<TestBotContext>(done => {
// create new robot, without http, using the mock adapter
const botName = settings?.name || "hubot"
const botAlias = settings?.alias || null
const robot = new Robot(mockAdapter as any, false, botName, botAlias)
robot.loadAdapter().then(() => {
// create a user
const user = robot.brain.userForId("1", {
name: settings?.testUserName || "mocha",
room: "#mocha"
})
const context = new TestBotContext(robot as unknown as Robot, user)
done(context)
})
robot.run()
})
}The main powerhouse it the ability to wait for a response with sendAndWaitForResponse.
A sample test
Now, let's create a simple test that will use the name and the alias of the bot to respond to a ping and validate if a proper pong response is returned.
import { expect } from "chai"
import { TestBotContext, createTestBot } from "./test-bot.js"
describe.only("test-bot testing", async () => {
let context: TestBotContext
beforeEach(async () => {
context = await createTestBot({ name: "namebot", alias: "aliasbot" })
context.robot.respond(/ping/, context => context.reply("pong"))
})
afterEach(() => context.shutdown())
it("Should respond to the bot name and execute the command", async () => {
let response = await context.sendAndWaitForResponse("@namebot ping")
expect(response).to.eql("pong")
})
it("Should respond to the alias name and execute the command", async () => {
let response = await context.sendAndWaitForResponse("@aliasbot ping")
expect(response).to.eql("pong")
})
})Final thoughts
The move to ESM is pretty messy, as we need to convert many of the imports (even in TypeScript). I'm not 100% sure if it is worth it, but as the main Hubot project made the switch, it might make sense to switch the rest over as well.
Oh well... ask me again in a few weeks 😓.
The hubot-command-mapper can be found on GitHub.
Further reading
Here are some sources that might interest you:
- ES Modules, Typescript and Mocha - some notes on how to convert a TypeScript code base to ESM.