bolt-js
bolt-js copied to clipboard
Adding unit tests examples to the documentation
Description
Hi all,
Thanks for a great framework!
It would be very useful if you could add to the documentation just a few examples of unit tests for bolt app. I suppose that writing tests is not that complicated if you have some experience, but it would be a great help for people who are new to mocking, stubbing etc.
What type of issue is this? (place an x
in one of the [ ]
)
- [ ] bug
- [ ] enhancement (feature request)
- [ ] question
- [x] documentation related
- [ ] testing related
- [ ] discussion
Requirements (place an x
in each of the [ ]
)
- [x] I've read and understood the Contributing guidelines and have done my best effort to follow them.
- [x] I've read and agree to the Code of Conduct.
- [x] I've searched for any related issues and avoided creating a duplicate issue.
Thanks for using our project and giving us your feedback!
I agree that some guidance around how to write tests for apps would be really helpful. We're making some progress towards establishing an easier technique in #353. Those changes will result in a Bolt v2, which we're hoping to release in a few weeks. So an update to our documentation about testing would make the most sense with or after that.
Now that we're on version 2, can anyone share some examples of how they're doing unit testing?
This library hasn't come up with Slack feature-specific testing supports yet but this 3rd party project may be worth checking. https://github.com/IBM/slack-wrench/tree/master/packages
@RayBB a simple flow which works for me:
- use a named functions in the listeners
- call those functions in tests with a specific input
- spy on mocked app web client and check args it was called with
- match those args with a jest snapshot if needed
I can attach some code snippets if they may be useful
But for sure using the tool mentioned by seratch would be more straightforward
@szymon-szym some examples would be appreciated!
Here's an example of how to fake a real call. Keep it simple, you don't always need a mocking framework try simple js, it usually works and pays off.
// stub the real call
app.client.chat.postMessage = () => Promise.resolve({ called: true});
it('only sends one welcome message', async () => {
app.client.chat.postMessage = () => Promise.resolve({ called: true});
const channel = '';
const user: SlackUser = {
token: '',
trigger_id: '',
view: undefined,
id: '',
team_id: ''
};
const addedCrafter = {
rows: [
{
crafter_email: '',
crafter_name_first: '',
crafter_name_last: '',
crafter_slack_received_welcome_msg: false,
crafter_slack_user_id: user.id
}
]
}
fakeDB.client = createClient(addedCrafter)
fakeDatabase(fakeDB.client);
let response = await sendWelcomeDMMessage(channel, user);
expect(response.called).toBe(true);
addedCrafter.rows[0].crafter_slack_received_welcome_msg = true;
fakeDB.client = createClient(addedCrafter)
fakeDatabase(fakeDB.client);
response = await sendWelcomeDMMessage(channel, user);
expect(response.called).toBe(false);
});
Code under test:
async function sendWelcomeDMMessage(channel: string, user: SlackUser) {
const crafterModel: CrafterModel = {
email: '',
firstName: '',
lastName: '',
slackUserId: user.id,
receivedWelcomeMsg: true
}
const crafter: CrafterModel = await add(crafterModel)
if(crafter.receivedWelcomeMsg){
return Promise.resolve({called: false });
}
return app.client.chat.postMessage( {
token,
text: 'Welcome!!!',
channel: channel
})
}
so rather than using something like nock
to intercept a real uri
which I couldn't get working with bolt but could with my own code not using bolt and just creating my own custom code, if you're testing your code it really don't matter how postMessage works. You don't want to go in depth mocking code you don't own. "Don't mock what you don't own" is a good rule to follow and follow that as much as you possibly can. In this case I got away with minimal stubbing of the 3rd party lib (bolt) by only having to stub postMessage
. In this case the stub is a spy, and verifying that postMessage was called.
You just care that the code inside your function (your behavior) (in my case sendWelcomeMessage
) works and this was really just spying to see if it sent a request via postMessage
in this particular case.
Hope this helps anyone out there.
@RayBB It was a while, but still it might be useful I am aware that there are strong opinions around mocking, choose whatever works for you :)
Here is a test:
/**
* @jest-environment node
*/
/* eslint-disable*/
import SlackApp from '../app';
import * as HomeTab from '../handlers/app-home';
import { ExpressReceiver } from '@slack/bolt';
import { appHomeMainMenu } from '../handlers/app-home';
jest.mock('@slack/bolt/dist/ExpressReceiver');
jest.mock('../box_svc'); //<-- this is part of specific implementation of may SlackApp object, not relevant to this test
const mockClientWebPublish = jest.fn();
const mockClientUsersInfo = jest.fn();
jest.mock('../app', () => {
return jest.fn().mockImplementation(() => {
return {
app: {
client: {
views: {
publish: mockClientWebPublish,
},
users: {
info: mockClientUsersInfo,
},
},
},
};
});
});
let mockApp: SlackApp;
HomeTab.lib.getUserObject = jest.fn(() => {
console.log('mocked get user called')
return Promise.resolve({
user: {
profile: {
first_name: 'test name',
},
},
})
});
const fakeSlackArgs = {
ack: () => {},
body: {
user: {
id: '123',
},
},
context: {
botToken: 'ABC',
},
payload: {},
view: {},
};
describe('Opening home tab', () => {
beforeAll(async () => {
const mockExpressReceiver = new ExpressReceiver({ signingSecret: '1234' });
mockApp = new SlackApp(mockExpressReceiver);
});
it('Should publish expected view in the home tab', async () => {
mockClientWebPublish.mockClear()
await appHomeMainMenu(mockApp.app, (fakeSlackArgs as any))
expect(mockClientWebPublish.mock.calls[0]).toMatchSnapshot()
});
});
Here is a code to be checked:
export async function appHomeMainMenu(
app: App,
args: SlackActionMiddlewareArgs & SlackEventMiddlewareArgs & { context: Context },
): Promise<WebAPICallResult> {
logger.info('NEW app_home_opened called');
try {
await args.ack();
} catch (error) {
logger.info(`app home called from the event - no ack() in the args ${error}`);
}
let userId: string;
try {
userId = args.body.event.user;
logger.info(`getting user id from the event`);
} catch {
userId = args.body.user.id;
logger.info(`getting user id from the action`);
}
logger.debug(`app home user Id: ${userId}`);
/*eslint-disable-next-line @typescript-eslint/no-explicit-any */
const userRes: any = await lib.getUserObject(app, args, userId);
const userName: string = userRes.user.profile.first_name || '';
logger.info('user data fetched');
return await app.client.views.publish({
token: args.context.botToken,
user_id: userId,
view: {
type: 'home',
callback_id: 'home_view',
blocks: [
--> snip <--
],
},
});
}
I recently created an open source app called Asking for a Friend, which was written in TypeScript, lifted with Eslint and Prettier, and has 99% test coverage via Jest. I figured out a new pattern for the declaration of listener methods and it's worked really well for me when it comes to testing. Here's an example:
postAnonymousQuestion.ts
(Functionality to test)
/* eslint-disable camelcase */
import { Middleware, SlackShortcutMiddlewareArgs, SlackShortcut } from '@slack/bolt';
import logger from '../../logger';
import { app } from '../../app';
import getRequiredEnvVar from '../../utils/getRequiredEnvVar';
import { getPostAnonymousQuestionModalBlocks } from '../blocks/postAnonymousQuestion';
import { callbackIds } from '../constants';
export const postAnonymousQuestion: Middleware<SlackShortcutMiddlewareArgs<SlackShortcut>> = async ({
shortcut,
ack,
}) => {
ack();
try {
await app.client.views.open({
token: getRequiredEnvVar('SLACK_TOKEN'),
trigger_id: shortcut.trigger_id,
view: {
callback_id: callbackIds.postQuestionAnonymouslySubmitted,
type: 'modal',
title: {
type: 'plain_text',
text: 'Ask Question Anonymously',
},
blocks: getPostAnonymousQuestionModalBlocks(),
submit: {
type: 'plain_text',
text: 'Ask Question',
},
},
});
} catch (error) {
logger.error('Something went wrong publishing a view to Slack: ', error);
}
};
postQuestionAnonymously.test.ts
(Tests to cover above functionality)
/* eslint-disable camelcase, @typescript-eslint/no-explicit-any, import/first */
import 'jest';
import supertest from 'supertest';
import { createHash } from '../utils/slack';
import logger from '../../../logger';
const signingSecret = 'Secret';
process.env.SLACK_SIGNING_SECRET = signingSecret;
import { receiver, app } from '../../../app';
import { callbackIds } from '../../../slack/constants';
const trigger_id = '1234';
const mockShortcutPayload: any = {
type: 'shortcut',
team: { id: 'XXX', domain: 'XXX' },
user: { id: 'XXX', username: 'XXX', team_id: 'XXX' },
callback_id: callbackIds.postAnonymousQuestion,
trigger_id,
};
const viewsOpenSpy = jest.spyOn(app.client.views, 'open').mockImplementation();
const loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation();
describe('ignore action listener', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('handles the shortcut and opens a modal', async () => {
const timestamp = new Date().valueOf();
const signature = createHash(mockShortcutPayload, timestamp, signingSecret);
await supertest(receiver.app)
.post('/slack/events')
.send(mockShortcutPayload)
.set({
'x-slack-signature': signature,
'x-slack-request-timestamp': timestamp,
})
.expect(200);
expect(viewsOpenSpy).toBeCalled();
const args = viewsOpenSpy.mock.calls[0][0];
expect(args.trigger_id).toEqual(trigger_id);
});
it("logs an error if the modal can't be opened", async () => {
const timestamp = new Date().valueOf();
const signature = createHash(mockShortcutPayload, timestamp, signingSecret);
viewsOpenSpy.mockRejectedValueOnce(null);
await supertest(receiver.app)
.post('/slack/events')
.send(mockShortcutPayload)
.set({
'x-slack-signature': signature,
'x-slack-request-timestamp': timestamp,
})
.expect(200);
expect(viewsOpenSpy).toBeCalled();
expect(loggerErrorSpy).toBeCalled();
});
});
Check out that project if you like the pattern above 🙂
👋 It looks like this issue has been open for 30 days with no activity. We'll mark this as stale for now, and wait 10 days for an update or for further comment before closing this issue out.