DEV Community

Cover image for How I built my first bot using Typescript - Part #2: Unit testing, mocking and spying
skywarth
skywarth

Posted on • Updated on

How I built my first bot using Typescript - Part #2: Unit testing, mocking and spying

Hi everyone,

This the part #2 of the series, make sure to check the rest of them! Here's the first part.

Recently I've published a GitHub app. Darkest-PR is a bot for responding to the actions occurring in your repository by commenting them with quotes from the Darkest Dungeon game. The goal was to make development more fun and enjoyable thanks to the narrations by the Darkest-PR.

In this episode, we'll delve into unit testing and feature testing for my Probot app built with Typescript. We'll be using Vitest as testing framework.

Episodes:

  1. Beginning and architecture
  2. Unit and feature testing, mocking & spying (you're here!)
  3. Code coverage reports
  4. Deployment and CI/CD
  5. Publishing and final remarks

Don't forget to check the repository if you'd like to follow along!

Enacting TDD early on

Image description

One thing I wanted to do in this project was to enact Test-Driven-Development early on, because in TDD you're meant to embrace it in the earlier phases of the project otherwise you wouldn't fully benefit from it. In TDD; you're meant to write tests first, then implement the feature. It's like shoot first ask questions later ๐Ÿ˜„, but in a better way. In TDD, when you write tests cases first, it is expected to be failing, since the feature is not implemented yet. It is the expected flow of TDD. Writing the unit/feature tests that are failing but includes all the necessary assertions per requirements of the feature. Then you write the implementation to accommodate the test requirements, to make it pass.

Image description

Hence I embraced TDD in this project, as early as when the first skeleton of the architecture took form. And what a great decision it was! Because at certain points I made drastic changes, altering decision, or encountered force-majeures which forced my hand to adjust my approach. In such cases I once again found the bliss of TDD. There wasn't tech debt or refactoring cost associated with changes because after each change I was able to run all tests easily and see whether any of the test cases were failing or not. This made the refactoring so much more productive than usual. Development costs were cut significantly.

Image description

Structuring tests

First thing I did before rushing for writing test cases, was to define a clean hierarchy and structure for tests. For starters I elected to have both unit and feature tests, but no integration tests. Also we are going to have certain test data, or 'fixtures' as it is usually called. It would also make sense to distinguish certain modules/classes under distinct folders to categorize them. And the most important of them all, a boilerplate for tests. I usually prepare a boilerplate for my test cases in each project. Test case boilerplates helps you have some sort of inheritance/abstraction, enabling you to have control over all of the test, since they inherit or implement this boilerplate. We'll get to that later on don't worry, in the next section. And ta-da! Here's the final structure for tests

Image description

Deep dive into mocking and spying

Prior to this project I've done many unit testing for various projects of mine. Different languages and frameworks. Now I wanted to see what Vitest offered in terms of mocking and spying. So bit by bit, demo by demo by I started the grasp the concept and how to apply it in Vitest. Here's my key-notes for them:

  • Mocking: you "mock" a function, alter/define its implementation to fit your agenda. Function, not method.
  • Spying: you "spy" a method, method of a class or an object. Alter/define its implementation.

Mocking/spying allow you to register calls and responses. It also enables you to implement them numerous times as needed. It is actually such a strong capability.

I've applied mocking and spying vigorously to see the full extent of its power.

Here's the content of my unit testing stub (or abstract class) which contains all references to mocking and spying:

export class StrategyTestSetup {
    probot!: Probot;
    quoteFacadeGetQuoteSpy!: MockInstance;
    commentFactoryCreateSpy!: MockInstance;
    actionStrategyHandleSpy!: MockInstance;
    createCommentEndpointMock: Mock=vi.fn();
    pullRequestIndexResponseMock: Mock=vi.fn();
    pullRequestReviewIndexResponseMock: Mock=vi.fn();
    getConfigResponseMock: Mock=vi.fn();

    initializeMocks() {
        this.probot = new Probot({
            githubToken: "test",
            Octokit: ProbotOctokit.defaults(function(instanceOptions: any) {
                return {
                    ...instanceOptions,
                    retry: { enabled: false },
                    throttle: { enabled: false },
                };
            }),
        });
        DarkestPR(this.probot);

        this.quoteFacadeGetQuoteSpy = vi.spyOn(QuoteFacade.prototype, 'getQuote');
        this.commentFactoryCreateSpy = vi.spyOn(CommentFactory.prototype, 'create');
        this.createCommentEndpointMock.mockImplementation((param: any) => param);
        this.pullRequestIndexResponseMock.mockImplementation(()=>[]);//default implementation
        this.getConfigResponseMock.mockImplementation(()=>({}));//default implementation
    }

    setupEndpointMocks() {
        const endpointRoot: string = 'https://api.github.com';
        const owner:string='test-owner';
        const repo:string='test-repo';


        const pullNumber:number=555444;

        nock(endpointRoot)
            .persist()
            .get(`/repos/${owner}/${repo}/pulls`)
            .query(true)
            .reply(200, this.pullRequestIndexResponseMock);

        nock(endpointRoot)
            .persist()
            .post(`/repos/${owner}/${repo}/issues/${pullNumber}/comments`, this.createCommentEndpointMock)
            .reply(200);

        nock(endpointRoot)
            .persist()
            .get(`/repos/${owner}/${repo}/contents/.darkest-pr.json`)
            .reply(200, ()=>{
                return {
                    content: Buffer.from(JSON.stringify(this.getConfigResponseMock())).toString('base64')
                }
            });

        nock(endpointRoot)
            .persist()
            .get(`/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`)
            .reply(200, this.pullRequestReviewIndexResponseMock);
    }

    beforeAll() {
        nock.disableNetConnect();
        this.initializeMocks();
        this.setupEndpointMocks();
    }

    afterEach() {
        vi.clearAllMocks();
    }


    performCommonAssertions(expectedCaseSlug:string):{comment:Comment}{
        expect(this.actionStrategyHandleSpy).toHaveBeenCalled();
        expect(this.quoteFacadeGetQuoteSpy).toHaveBeenCalled();
        expect(this.commentFactoryCreateSpy).toHaveBeenCalled();
        const commentInstance = this.commentFactoryCreateSpy.mock.results[0].value;
        expect(commentInstance).toBeInstanceOf(Comment);
        expect(commentInstance.caseSlug).toBe(expectedCaseSlug);

        const sentData = this.createCommentEndpointMock.mock.results[0]?.value ?? {};
        expect(this.createCommentEndpointMock).toHaveBeenCalledOnce();
        expect(sentData).toHaveProperty('body');
        expect(sentData.body).toBeTypeOf('string');
        expect(sentData.body.length).toBeGreaterThan(0);
        return {
            comment:commentInstance
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

Base test class

If you ever wrote unit/feat tests for a class/service, then you know the hassle about redundant code sections and declarations. Since Darkest-PR contains multiple strategy pattern implementations it was paramount to have a base test class which all other tests will extend from. This would enable centralized control over all the tests, and refactoring cost would be greatly reduced. Hence I prepared the StrategyTestSetup base test class which is responsible for setting up test environment before commencing tests. You may have base test class, or stub, or abstract test class for:

  • Common setup operations for test environment
  • Clean-up operations after each test
  • Assertions and evaluations that are common for each test

For these reason, it is a great idea to have a base test class where all other (or certain group of) tests inherit or include from.

Thanks to the StrategyTestSetup base test class, now each of my strategy tests looks like this which is super simple and straight forward:

Image description

Further reducing duplicate code

To eliminate duplicate code and redundancy, you should also use .each() feature of Vitest whenever possible. .each() can be applied to groups, describe() statements and test() statements. It is really flexible extension that allows you to loop tests and feed them predefined inputs. See it in action:

describe("Pull Request opened tests", () => {
    const strategyTestSetup = new StrategyTestSetup();

    beforeAll(() => {
        strategyTestSetup.beforeAll();
    });

    afterEach(() => {
        strategyTestSetup.afterEach();
    });
    describe.each([
        {
            description: "No previous PRs",
            previousPrs: [],
            expectedCaseSlug: CaseSlugs.PullRequest.Opened.Fresh,
        },
        {
            description: "Previously not merged (closed)",
            previousPrs: pullRequestListNotMerged,
            expectedCaseSlug: CaseSlugs.PullRequest.Opened.PreviouslyClosed,
        },
        {
            description: "Previously merged",
            previousPrs: pullRequestListMerged,
            expectedCaseSlug: CaseSlugs.PullRequest.Opened.PreviouslyMerged,
        },
    ])('$description', ({ previousPrs, expectedCaseSlug }) => {
        test('Creates a comment after receiving the event', async () => {
            strategyTestSetup.actionStrategyHandleSpy = vi.spyOn(PullRequestOpenedStrategy.prototype as any, 'handle');
            strategyTestSetup.pullRequestIndexResponseMock.mockImplementation(() => previousPrs);
            await strategyTestSetup.probot.receive({
                id: '123',
                name: 'pull_request',
                payload: pullRequestOpenedPayload as any,
            });
            strategyTestSetup.performCommonAssertions(expectedCaseSlug);
            expect(strategyTestSetup.pullRequestIndexResponseMock).toHaveBeenCalledOnce();
        });
    });
});

Enter fullscreen mode Exit fullscreen mode

And with these gains, and total of 90 test cases written, we conclude the test section of the project. In the next chapter we'll cover the CI/CD and test coverage aspect of the project so stay tuned!

Top comments (3)

Collapse
 
shekharrr profile image
Shekhar Rajput

Is this a series?

Collapse
 
skywarth profile image
skywarth

Yep, follow along the journey. There is more to come. Here's the part #1: dev.to/skywarth/how-i-built-my-fir...

Collapse
 
skywarth profile image
skywarth

Published the third episode, it's about code coverage. Go ahead and check it out: dev.to/skywarth/how-i-built-my-fir...