bolt-js icon indicating copy to clipboard operation
bolt-js copied to clipboard

Adding unit tests examples to the documentation

Open szymon-szym opened this issue 5 years ago • 9 comments

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.

szymon-szym avatar Jan 25 '20 22:01 szymon-szym

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.

aoberoi avatar Jan 29 '20 03:01 aoberoi

Now that we're on version 2, can anyone share some examples of how they're doing unit testing?

RayBB avatar May 21 '20 14:05 RayBB

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

seratch avatar May 22 '20 01:05 seratch

@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 avatar May 24 '20 20:05 szymon-szym

@szymon-szym some examples would be appreciated!

RayBB avatar May 24 '20 20:05 RayBB

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.

dschinkel avatar Jun 09 '20 05:06 dschinkel

@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 <--
            ],
        },
    });
}

szymon-szym avatar Oct 07 '20 07:10 szymon-szym

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 🙂

SpencerKaiser avatar Oct 07 '20 15:10 SpencerKaiser

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

github-actions[bot] avatar Dec 05 '21 00:12 github-actions[bot]