Builder Book

  1. Introduction
  2. App structure. Next.js. HOC. Material-UI. Server-side rendering. Styles.
  3. Server. Database. Session. Header and MenuDrop components.
  4. Authentication HOC. Promise. Async/await. Static method for User model. Google OAuth.
  5. Testing with Jest. Debugging with Winston. Transactional emails. In-app notifications.
  6. Book and Chapter models. Internal API. Render chapter.
  7. Github integration. Admin dashboard. Testing Admin UX and Github integration.
  8. Table of Contents. Highlight for section. Hide Header. Mobile browser.
  9. BuyButton component. Buy book logic. ReadChapter page. Checkout flow. MyBooks page. Mailchimp API. Deploy app.

Chapter 4: Testing with Jest. Debugging with Winston. Transactional emails. In-app notifications.

We keep the book up-to-date with the latest frameworks and packages.


In Chapter 4, you'll start with the codebase in the 4-start folder of our builderbook repo and end up with the codebase in the 4-end folder. We'll cover the following topics in this chapter:

  • Testing with Jest

  • Debugging with Winston

  • Transactional emails
    - Set up, keys, env variables
    - sendEmail() method
    - Export and import
    - insertTemplates() method
    - getEmailTemplate() method
    - Update User model
    - Testing

  • In-app notifications
    - Notifier component
    - notify() function
    - Example of usage


In the previous chapters, we:

  • set up and integrated our Next.js app with Material-UI (Chapter 1)
  • created our custom server, session, and User model (Chapter 2)
  • added user athentication with Google OAuth 2.0 (Chapter 3)

In this chapter (Chapter 4), our main goals are:

  • to get familiar with unit testing and error logging
  • set up transactional emails in our app with AWS SES
  • add in-app notifications and tooltips

link Testing with Jest

As our app grows, it takes more time to test our code. When the app's codebase gets large and interconnected, manual testing becomes more time-consuming. To stay productive as a developer, we can create automated tests. You can write a test as small as checking one single object/function (unit test) or as complex as checking multiple interconnected objects (integration test). More on test types.

In this subsection, we get familiar with automated code testing. As an example, we want to test if our code generates the proper slug in different scenarios. We placed our slug-generating code at server/utils/slugify.js. You may remember from Chapter 3 that we used the generateSlug() function to generate a slug for the User model. We will also use this function to generate a slug for the Book and Chapter models (Chapter 6). However, for testing, we will focus only on the User model.

We will use Jest, a popular JavaScript testing library by Facebook. We will write a simple unit test that checks if the generateSlug() function generates the proper slug.

First, we need to decide where to store our unit test. Jest automatically finds files containing test code. We have to either place our files in a __tests__ folder or create a file with a .test.js extension. We use the second option - for no particular reason.

Below, we will create a slugify.test.js file to test our code in slugify.js. We place the former file into test/server/utils/slugify.test.js. Go ahead and create the new folder and file.

To design our unit test, we should look at the generateSlug(Model, name) function inside server/utils/slugify.js and see what this function does.

const _ = require('lodash');
// https://devdocs.io/lodash~4/index#kebabCase
const slugify = text => _.kebabCase(text);

async function createUniqueSlug(Model, slug, count) {
    const user = await Model.findOne({ slug: `${slug}-${count}` }, 'id');

    if (!user) {
        return `${slug}-${count}`;
    }

    return createUniqueSlug(Model, slug, count + 1);
}

async function generateSlug(Model, name, filter = {}) {
    const origSlug = slugify(name);

    const user = await Model.findOne(Object.assign({ slug: origSlug }, filter), 'id');

    if (!user) {
        return origSlug;
    }

    return createUniqueSlug(Model, origSlug, 1);
}

module.exports = generateSlug;

The generateSlug() function does a few things.

  • First, the function converts name into slug using slugify() defined at the top of the file.
  • Then the function checks if a user with the same slug already exists: Model.findOne({ slug: origSlug })
  • If a user with the same slug does not exist, the function returns the if (!user) {return origSlug;}
  • If a user with the same slug does exist, the function adds a number at the end of the slug and returns this new slug: createUniqueSlug(Model, origSlug, 1.

Ok, we understand what the function does. Now what are the scenarios in which we need to test for proper slug creation?

  1. Scenario 1. We should test if the function generates the proper slug when no user exists with the same slug. In this scenario, we basically test if slugify() converts name into slug using _.kebabCase(text) (see Lodash docs). Let's call this test no duplication.

  2. Scenario 2. We should test if the function generates the proper slug when a user with the same slug already exists. Let's call this test one duplication.

  3. Scenario 3. Optionally, we should test if the function generates the proper slug when a user with same slug already exists and another user with that slug appended with -1 exists as well. We'll call this test multiple duplications.

Let's follow the example in Jest's docs on how to write a unit test for a simple function. Here is the function:

function sum(a, b) {
    return a + b;
}

export default sum

For this function, the sum checker test is:

import sum from './sum';

test('sum checker', () => {
    expect(sum(1, 2)).toBe(3);
});

Following the example above, for the test no duplication and function generateSlug(), you get:

test('no duplication', () => {
    expect.assertions(1);

    return generateSlug(MockUser, 'John Jonhson').then((slug) => {
        expect(slug).toBe('john-jonhson');
    });
});

This test says that for a user with name John Jonhson, expect the slug to be john-jonhson.

The only unknown code in the block above might be expect.assertions(1). The number of assertions is the number of expect().toBe() assertions that you expect inside the test.

In our case, the number of assertions is one. By specifying expect.assertions(1); - we tell Jest to call that one assertion that we have in the code expect(slug).toBe('john-jonhson-jr-');. Without specifying expect.assertions(1); - our assertion may or may not be called. If we specify the wrong number of assertions - our test will fail.

When the number of assertions is 2 or more, use expect.assertions(number) to make sure that all assertions are called. You can read more about expect.assertions(number) in the Jest docs.

You just wrote one test - no duplication. Now write our two other tests (one duplication and multiple duplications) and put all three of them into the so called test suite by using Jest's describe(name, fn) syntax:
test/server/utils/slugify.test.js :

const generateSlug = require('../../../server/utils/slugify');

const MockUser = {
    slugs: ['john-jonhson-jr', 'john-jonhson-jr-1', 'john'],
    findOne({ slug }) {
        if (this.slugs.includes(slug)) {
            return Promise.resolve({ id: 'id' });
        }

        return Promise.resolve(null);
    },
};

describe('slugify', () => {
    test('no duplication', () => {
        expect.assertions(1);

        return generateSlug(MockUser, 'John Jonhson.').then((slug) => {
            expect(slug).toBe('john-jonhson');
        });
    });

    test('one duplication', () => {
        expect.assertions(1);

        return generateSlug(MockUser, 'John.').then((slug) => {
            expect(slug).toBe('john-1');
        });
    });

    test('multiple duplications', () => {
        expect.assertions(1);

        return generateSlug(MockUser, 'John Jonhson Jr.').then((slug) => {
            expect(slug).toBe('john-jonhson-jr-2');
        });
    });
});

MockUser returns a user if the generated slug does match a value from the slugs: ['john-jonhson-jr', 'john-jonhson-jr-1', 'john'] array.

MockUser returns null if the generated slug does not match any value from the slugs: ['john-jonhson-jr', 'john-jonhson-jr-1', 'john'] array.

Think of this slugs array as an imitation of MongoDB - our database has 3 users with the slugs john-jonhson-jr, john-jonhson-jr-1, and john.

This setup for a database may look confusing, and it is. Let's discuss 2 examples to better understand the setup.

1) Take a look at the no duplication test. Remember that generateSlug(Model, name) generated the slug john-johnson for a MockUser with the name John Johnson. Since john-johnson does not match any value from the slugs: ['john-jonhson-jr', 'john-jonhson-jr-1', 'john'] array, MockUser returns Promise.resolve(null). This means that in our database, there is no user with the john-johnson slug. Thus, the following code gets executed inside generateSlug():

if (!user) {
    return origSlug;
}

If user does not exist, origSlug is indeed original and becomes our user's slug.

2) Take a look at the one duplication test. Again, this method generates the slug john. Since john does match a value in the slugs: ['john-jonhson-jr', 'john-jonhson-jr-1', 'john'] array, instead of Promise.resolve(null), MockUser returns Promise.resolve({ id: 'id' }). Thus, the following code inside generateSlug() gets executed:

return createUniqueSlug(Model, origSlug, 1);

origSlug is not original, so the createUniqueSlug() function adds -1 to the john slug, thereby outputting john-1.

Alright, now that you know how tests work - it's time for testing.

To run Jest, add this extra command to our scripts in package.json:

"test": "jest"

Go to terminal and run yarn test. Jest will generate the following report:

Builder Book

From this jest report, we see that our test suite called slugify passed. This suite consists of three tests: no duplication, one duplication, and multiple duplications.

To see how the report looks when the test fails, go to server/utils/slugify.js. Find line 24:

return createUniqueSlug(Model, slug, count + 1);

Modify this line to be:

return createUniqueSlug(Model, slug, count + 2);

Go to terminal and run yarn test. Jest will generate a new report:

Builder Book

This report shows that the third test called multiple duplications failed. As a result, the entire test suite failed. Jest specifies the reason for failure:

Expected value to be (using ===):
    "john-jonhson-jr-2"
Received:
    "john-jonhson-jr-3"

In this case, our generateSlug() function failed the test because it generated john-jonhson-jr-3 instead of john-jonhson-jr-2. Don't forget to go back to server/utils/slugify.js and fix the code to pass all tests.

One final note on Jest - saving reports is easy. We simply modify package.json as follows.

Edit the test command in scripts:

"test": "jest --coverage"

Under the scripts section, add a new section:

"jest": {
    "coverageDirectory": "./.coverage"
}

Go to terminal and run yarn test. Jest will save the generated report to .coverage/lcov-report/index.html. Open the index.html file in your browser to see a summary of the report:

Builder Book

You should add the .coverage directory to your .gitignore file to exclude it from your remote repository on Github.

In some situations, integration tests are more appropriate than a group of unit tests. In our app, we theoretically can write an integration test that simultaneously tests static CRUD methods in our Book or User model.

link Debugging with Winston

In this subsection, we will set up simple logging with Winston.

Winston logging allows us categorize server logs by priority. Why is this useful? With a logger such as Winston, we can output a particular level of messages in development mode but not in production mode.

For example, we may want to see error and warning messages in our local app (locally, or in development mode, you see server logs in your terminal) but only see error messages in the logs of our deployed app (in production mode, you log in into your remote server). Winston helps us achieve exactly that.

Once we set up Winston logger, instead of using console.log() for all server logs, we can make some messages more important. For example, errors messages (logger.error()) are more important than non-critical info messages (logger.info()).

You may use some of your info messages for debugging. In that case, you should not log these messages on your production server, but you also don't want to delete them manually before each deploy. You can achive that by configuring Winston to output messages with certain level of importance.

Before we use Winston logger, we should configure it using the winston.createLogger() method. We put this custom configuration into our server/logs.js file. In this book, for the sake of keeping things simple, we specify only 3 options. See the official docs for the entire list of options. Our custom logger looks like:
server/logs.js :

import winston from 'winston';

const logger = winston.createLogger({
    level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
    format: winston.format.simple(),
    transports: [new winston.transports.Console()],
});

export default logger;

To continue reading, buy this book.

We keep the book up-to-date with the latest frameworks and packages.


format_list_bulleted
help_outline