Hubot testing revisited

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:

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.
expand_less