So far I've been using the hubot-pretend package for the testing of the hubot-command-mapper. But as that test package is no longer maintained / updated, I wanted to switch to something that is more in line with what Hubot itself does: hubot-mock-adapter.
I'm using TypeScript in this blog with some specific prettier settings. These examples should be very portable to vanilla JavaScript.
Bare bones hubot-mock-adapter
As the current example does not seem to work, let's see if we can rebuild it:
import { expect } from "chai"
import { Robot } from "hubot/es2015"
import { TextMessage } from "hubot"
describe.only("Eddie the shipboard computer", function () {
var robot
var user
var adapter
beforeEach(done => {
// create new robot, without http, using the mock adapter
robot = new Robot("hubot-mock-adapter", false, "Eddie")
// start adapter
robot.loadAdapter().then(() => {
// 1. programatically add command:
robot.hear(/computer!/i, res => res.reply("Why hello there"))
// 2. or load coffee script file:
//const coffeeFile = "";
//robot.loadFile(coffeeFile);
// 3. or load local JS file
//const path = ""
//require(path)(robot);
robot.adapter.on("connected", () => {
// create a user
user = robot.brain.userForId("1", {
name: "mocha",
room: "#mocha"
})
adapter = robot.adapter
done()
})
// start the bot
robot.run()
})
})
afterEach(function () {
robot.shutdown()
})
it("responds when greeted", function (done) {
// here's where the magic happens!
adapter.on("reply", (envelope, strings) => {
expect(strings[0]).match(/Why hello there/)
done()
})
adapter.receive(new TextMessage(user, "Computer!", "RND"))
})
})
This test will succeed. But it still has a ton of boiler plate. It does not make the test very easy to read as the flow of control is not directly clear.
Extract bot creation to a function
So, what if we create a function that creates the test bot. We can reuse that function in our tests, making our tests better to read:
async function createTestBot() {
return new Promise<{ robot: Hubot.Robot; user: Hubot.User }>(async done => {
// create new robot, without http, using the mock adapter
const robot = new Robot("hubot-mock-adapter", false, "Eddie")
// start adapter
await robot.loadAdapter()
robot.adapter.on("connected", () => {
// create a user
const user = robot.brain.userForId("1", {
name: "mocha",
room: "#mocha"
})
done({
robot: robot as unknown as Hubot.Robot,
user
})
})
// start the bot
robot.run()
})
}
If we only have a single test case, we can make our test even smaller:
describe.only("Eddie the shipboard computer - createTestBot", function () {
it("responds when greeted", function (done) {
createTestBot().then(({ robot, user }) => {
// 1. programatically add command:
robot.hear(/computer!/i, res => res.reply("Why hello there"))
// here's where the magic happens!
robot.adapter.on("reply", (envelope, strings) => {
expect(strings[0]).match(/Why hello there/)
robot.shutdown()
done()
})
robot.adapter.receive(new TextMessage(user, "Computer!", "RND"))
})
})
})
It looks better, but we're not there yet.
Solving the actual problem: response testing
Solving the problem of creating the test adapter, is only solving one side of problem. We need to make it easier to do response testing. Let return a context that makes it easier for our bot to await the reply:
export class TestBotContext {
constructor(
public readonly robot: Hubot.Robot,
public readonly user: Hubot.User
) {}
async sendAndWaitForResponse(message: string, type: "send" | "reply" = "reply") {
return new Promise<string>(done => {
// here's where the magic happens!
this.robot.adapter.once(type, function (_, strings) {
done(strings[0])
})
const id = (Math.random() + 1).toString(36).substring(7)
const textMessage = new TextMessage(this.user, message, id)
this.robot.adapter.receive(textMessage)
})
}
}
Now we only need to return our context, so we can use it in our tests:
async function createTestBot() {
return new Promise<TestBotContext>(async done => {
// create new robot, without http, using the mock adapter
const robot = new Robot("hubot-mock-adapter", false, "Eddie")
// start adapter
await robot.loadAdapter()
robot.adapter.on("connected", () => {
// create a user
const user = robot.brain.userForId("1", {
name: "mocha",
room: "#mocha"
})
done(new TestBotContext(robot as unknown as Robot.Hubot, user))
})
// start the bot
robot.run()
})
}
Now our tests become far more readable:
describe.only("Eddie the shipboard computer - sendAndWaitForResponse", function () {
it("responds when greeted", async () => {
let context = await createTestBot()
// 1. programatically add command:
context.robot.hear(/computer!/i, res => res.reply("Why hello there"))
let response = await context.sendAndWaitForResponse("Computer!")
expect(response).match(/Why hello there/)
context.robot.shutdown()
})
})
Influence test bot settings
Our bot has more settings we might want to test. For the hubot-command-mapper, I'll need to test the alias and different bot names. I also want to be able to adjust the name of the user or the log level of the bot.
Let's introduce a type for this, so we can extend it later on:
export type TestBotSettings = {
name?: string
alias?: string
logLevel?: string
testUserName?: string
}
Now we can use it in our createTestBot
method:
export async function createTestBot(settings: TestBotSettings | null = null): Promise<TestBotContext> {
process.env.HUBOT_LOG_LEVEL = settings?.logLevel || "silent"
return new Promise<TestBotContext>(async done => {
// create new robot, without http, using the mock adapter
const botName = settings?.name || "hubot"
const botAlias = settings?.alias || null
const robot = new Robot("hubot-mock-adapter", false, botName, botAlias)
await robot.loadAdapter()
robot.adapter.on("connected", () => {
// create a user
const user = robot.brain.userForId("1", {
name: settings?.testUserName || "mocha",
room: "#mocha"
})
const context = new TestBotContext(robot as unknown as Hubot.Robot, user)
done(context)
})
robot.run()
})
}
Note: I changed the default name of the bot to hubot, sorry Eddie.
Support other scenarios
When I look through the test set of our hubot-command-mapper, I see many other scenarios that are not supported by this setup:
- There are command mapper tests that test stuff by just setting a variable to a different value instead of just returning a response. It would be enough just to send a message and then see if the right value change.
- There are commands that do multiple
res.emote
calls, which are triggered assend
commands on the adapter. How do we await them all? And how do we inspect them all?
Let's extend our TestBotContext
with these features:
export class TestBotContext {
public readonly replies: string[] = []
public readonly sends: string[] = []
constructor(
public readonly robot: Hubot.Robot,
public readonly user: Hubot.User
) {
this.robot.adapter.on("reply", (_, strings) => {
this.replies.push(strings.join("\n"))
})
this.robot.adapter.on("send", (_, strings) => {
this.sends.push(strings.join("\n"))
})
}
async sendAndWaitForResponse(message: string, responseType: ResponseType = "reply") {
return new Promise<string>(done => {
this.robot.adapter.once(responseType, function (_, strings) {
done(strings[0])
})
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 ResponseType = "send" | "reply"
We use await this.wait(1)
to give the emitter time to fire. I'm not 100% this will hold in all scenarios.
Let's see our feature in action:
import { createTestBot } from "../common/test-bot"
import { expect } from "chai"
import { map_command } from "../../src"
describe("clear-screen.spec.ts > clear screen example", () => {
it("Scenario", async () => {
let context = await createTestBot()
map_command(context.robot, "clear screen", context => {
for (let i = 0; i < 8; i++) {
context.res.emote(" ")
}
})
await context.send("@hubot clear screen")
expect(context.sends).to.eql([" ", " ", " ", " ", " ", " ", " ", " "])
context.shutdown()
})
})
And here we have a test with a beforeEach
and afterEach
. Notice how Mocha has good support for asynchronous functions for the beforeEach
and the it
:
import { alias, map_command } from "./../../src"
import { createTestBot, TestBotContext } from "../common/test-bot"
import { expect } from "chai"
describe("issues / 3.spec.ts / Testing problems with robot not responding to alias.", async () => {
let context: TestBotContext
beforeEach(async () => {
context = await createTestBot({ name: "namebot", alias: "aliasbot" })
map_command(context.robot, "ping", context => context.res.reply("pong"))
alias(context.robot, { pang: "ping" })
})
afterEach(() => context.shutdown())
it("Should respond to the alias and execute the command", async () => {
let response = await context.sendAndWaitForResponse("@aliasbot ping")
expect(response).to.eql("pong")
})
it("Should respond to the alias and execute the command alias", async () => {
let response = await context.sendAndWaitForResponse("@aliasbot pang")
expect(response).to.eql("pong")
})
})
Where does it stop?
One could even make more features, with new rooms and more users... but where does it stop? I don't feel like building a new hubot-pretend. For now the test class lives with the hubot-command-mapper. It has all the features that make sense for what I'm testing over there. Having such a test bot really improves readability or our tests.
Changelog
- 2023-08-17: added "silent" and
process.env.HUBOT_LOG_LEVEL
value. This value is also deleted upon the shutdown of the bot.