BoltJS is Slack's JavaScript library for creating Slack bots and integrations. This post outlines the process of testing an application built using BoltJS.
At a high level, to test an app written with BoltJs, the goal is to send messages to the app and intercept the responses to verify the correct behavior of the Slack bot's features. The tests are built using the Jest framework. I additionally used Nock to mock the responses of any external APIs the application may rely on within each unit test.
In order to accomplish this, we must provide the BoltJS application with a receiver through which we can send messages to the Slack bot, and then we must mock the Slack WebAPI library using Jest. BoltJS sends the Slack bot's responses that invoke the say() utility through the Slack API's postMessage function, as can be seen in App.ts in BoltJS's source code.
The content of this post was created using BoltJS version 3.14.0.
The first step is to be able to send the Slack bot messages, which will be received and matched by the functions registered with app.message(...) in your BoltJS application. To do this, we need to create a mock receiver through which we can send messages.
The following implementation of the mock receiver is based on IBM's Slack Wrench library, which has an implementation of a mock receiver.
class JestReceiver { init(app) { this.app = app; } async send(body) { const event = { body, ack: jest.fn(), }; await this.app?.processEvent(event); return event; } // For compatibility with Receiver, does nothing start() { return Promise.resolve(); } // For compatibility with Receiver, does nothing stop() { return Promise.resolve(); } } module.exports = JestReceiver;
Here, we define start() and stop() function stubs which are required by the receiver, and then in the send function, we create a Jest mock for the ack() function included in the event. Then, we invoked the BoltJS app's processEvent function using the event we constructed. Thus, we can provide the content of the message we want to send the application by setting the body parameter of the send function. Naturally, the BoltJS app will expect the event payload to be formatted a particular way; more on this in the following section.
Next, we need to initialize the BoltJS application using the mock receiver. To do so, we need to provide the receiver as a parameter to the BoltJS App(...) constructor, as follows:
const { App } = require('@slack/bolt'); module.exports = function startTestApp(receiver) { const fakeAuth = () => Promise.resolve({ botUserId: "BOTUSERID" }); const app = new App({ authorize: fakeAuth, receiver: receiver }); // add any skills here // begin running Bolt app (async () => { await app.start(); })(); return app; }
Here, we pass an instance of the mock receiver into the startTestApp function. Note that BoltJS will complain if the token, appToken, or socketMode parameters are set in the constructor. For this reason, I opted to create a separate function specifically to create a test app, importing the bot's skills from other files. It may be easier in your use case simply to conditionally omit those parameters in your actual bot's App constructor.
We also mock the authorization function to allow the bot to initialize. This fakeAuth function may simply return an empty promise. However, if the bot has any features which depend on the bot being mentioned, we must initialize the bot with a botUserID, as shown above. Other mocked bot initialization values can likewise be set here.
When sending a message to your BoltJS application using the mock receiver, the body of the event must be formatted correctly. Slack Wrench's Fixtures can be leveraged for this. However, I would recommend building these fixtures yourself so they can be customized to fix the needs of your test case. Here, for example, is the default payload used by my tests, with the values for the variables borrowed from Slack Wrench:
static eventDefault = { type: 'event_callback', token: 'TOKEN', team_id: 'TTEAMID', api_app_id: 'API_APP_ID', event_id: 'EVENT_ID', event_time: 1234567890, authed_users: ['UUSERID'], event: { type: 'message', text: '', channel_id: 'CCHANNELID', user_id: 'UUSERID', ts: '0000000000.000000', } }
Now, we can simply add a value for the event.text field and that message will be received by the Slack application.
Another use case is testing messages sent directly to the bot. To accomplish this, we can simply set event.channel_type = "im". Furthermore, to test messages which mention the bot (which the bot may be specifically listening for using the directMention() built in middleware), we can prepend the event.text field with the string "<@BOTUSERID> " (mind the space at the end). Note that BOTUSERID has to match the botUserID specified in the mock authorization function, listed above.
To use these fixtures, I recommend creating helper functions which take any parameters (e.g. the message to send to the bot), deep-copies and modifies the default event with these values, and returns that event object.
In order to mock the Slack WebAPI library, we can insert the following Jest mock in our test file. This code was modified from this StackOverflow answer. Note the addition of a mock for the addAppMetadata function, without which the BoltJS constructor will error. Here, we will create an object which is returned by the WebClient constructor, and this object contains a mock for the postMessage function.
jest.mock('@slack/web-api', () => { const mSlack = { chat: { postMessage: jest.fn() } }; return {WebClient: jest.fn(() => mSlack), addAppMetadata: jest.fn() }; });
Finally, with all the pieces in place, we can create a Jest test as follows:
describe('Test BoltJS Bot', () => { const message = 'hello'; const response = 'Hello. I am a Slack bot.'; beforeEach(() => { this.receiver = new JestReceiver(); this.app = startTestApp(this.receiver); }); afterEach(() => { this.app.stop(); // clear mocks after each test! jest.clearAllMocks(); }); it('Basic Test', async () => { // need to await this since the reciever is an async call await this.receiver.send(Fixtures.messageEvent(message)); expect(this.app.client.chat.postMessage).toHaveBeenCalledWith( expect.objectContaining({ text: response }) ); }); }
Here's a breakdown of this test: first we define a message and the expected response from the bot. Before the test, we construct an instance of the aforementioned mock receiver and pass it as a parameter to the startTestApp function. After the test is over, we stop the app and clear any Jest mocks so the tests do not interfere with each other.
In the actual test, we invoke this.receiver.send() with the message structured using the fixture above. Here, my Fixtures.messageEvent(...) helper function simply inserts the message string into the event.text field and returns it. Note that the send function must be awaited, since the receiver makes an asynchronous call.
Finally, we define what we expect from the test. Here, we expect the app's client's postMessage function, which we mocked above, to be called with an object containing our defined response.
With everything in place, the test will send the message to the mock receiver, which will invoke the slack bot's skill, assuming the bot has a function registered which listens for the message being sent. Then, the bot will respond using the postMessage function within BoltJS, which we've mocked, and therefore, we can read the value passed to it by the bot to ensure the bot responds correctly, and our unit test is complete.
Likewise, we can use these techniques to test other slack bot functionality. For example, to test an incoming slash command to our bot, we can create a fixture which Bolt will view as such. This, and other such use cases, may take some digging to create a proper fixture. For instance, in order for an incoming event to be considered a slash command, Bolt's source code shows that the body's event field must be undefined. Thus, a fixture for a slash command \command test would look something like this:
static commandEvent = { type: 'event_callback', command: '\command', text: 'test', token: 'TOKEN', team_id: 'TTEAMID', channel_id: 'CCHANNELID', api_app_id: 'API_APP_ID', event_id: 'EVENT_ID', event_time: 1234567890, authed_users: ['UUSERID'], event: undefined }
As another example, if we wanted to test the Slack bot's ability to open modals, we would mock the client.views.open function and compare the blocks that were sent by the bot to what we're expecting:
jest.mock('@slack/web-api', () => { const mSlack = { views: { open: jest.fn() } }; return { WebClient: jest.fn(() => mSlack), addAppMetadata: jest.fn() }; });