DEV Community

Cover image for Writing Well-Structured Unit Test in TypeScript
Miftahul Arifin
Miftahul Arifin

Posted on • Updated on

Writing Well-Structured Unit Test in TypeScript

The purpose of this post is to discover the implementation of writing unit test using Jest, a JavaScript testing Framework, in Sequelize and TypeScript project.

Setup Project

Let's create a new brand project using NPM and Git Versioning.

mkdir my-project
cd /my-project
git init
npm init
Enter fullscreen mode Exit fullscreen mode

Then we will install some dependencies, we will use babel for running Jest using TypeScript

npm install --save sequelize pg pg-hstore
npm install --save-dev typescript ts-node jest babel-jest @types/sequelize @types/jest @babel/preset-typescript @babel/preset-env @babel/core
Enter fullscreen mode Exit fullscreen mode

As we use TypeScript, we need to create tsconfig.json to indicate how transcript TypeScript files from src to dist folders.

//tsconfig.json
{
    "compilerOptions": {
        "module": "commonjs",
        "moduleResolution": "node",
        "target": "es2017",
        "rootDir": "./src",
        "outDir": "./dist",
        "esModuleInterop": false,
        "strict": true,
        "baseUrl": ".",
        "typeRoots": ["node_modules/@types"]
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules", "**/*.test.ts"]
}
Enter fullscreen mode Exit fullscreen mode

Then, we need to add babel.config.js in project folder, so we can run the unit test directly.

//babel.config.js
module.exports = {
    presets: [
        ['@babel/preset-env', {targets: {node: 'current'}}],
        '@babel/preset-typescript',
    ],
};
Enter fullscreen mode Exit fullscreen mode

Okay, now let's start writing the code.

Write Code

We will follow a design pattern with a model, a repository, a database lib, and a service. It will be as simple as possible, so we could write simple unit test with full coverage. The project structure will be like this

my-project/
├──src/
|   ├──bookModel.ts
|   ├──bookRepo.test.ts
|   ├──bookRepo.ts
|   ├──bookService.test.ts
|   ├──bookService.ts
|   └──database.ts
├──babel.config.js
├──package.json
└──tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Firstly, we need to create database.ts, it is a database connection lib in Sequelize.

//database.ts
import { Sequelize } from 'sequelize';

export const db: Sequelize = new Sequelize(
    <string>process.env.DB_NAME,
    <string>process.env.DB_USER,
    <string>process.env.DB_PASSWORD,
    {
        host: <string>process.env.DB_HOST,
        dialect: 'postgres',
        logging: console.log
    }
);
Enter fullscreen mode Exit fullscreen mode

Now, let's define the model. Models are the essence of Sequelize. A model is an abstraction that represents a table in your database. In Sequelize, it is a class that extends Model. We will create one model using Sequelize extending Class Model representing Book Model.

//bookModel.ts
import { db } from './database';
import { Model, DataTypes, Sequelize } from 'sequelize';

export default class Book extends Model {}
Book.init(
    {
        id: {
            primaryKey: true,
            type: DataTypes.BIGINT,
            autoIncrement: true
        },
        title: {
            type: DataTypes.STRING,
            allowNull: false
        },
        author: {
            type: DataTypes.STRING,
            allowNull: false
        },
        page: {
            type: DataTypes.INTEGER,
            allowNull: false,
            defaultValue: 0
        },
        publisher: {
            type: DataTypes.STRING
        },
        quantity: {
            type: DataTypes.INTEGER,
            allowNull: false,
            defaultValue: 0
        },
        created_at: {
            type: DataTypes.DATE,
            defaultValue: Sequelize.fn('now'),
            allowNull: false
        },
        updated_at: {
            type: DataTypes.DATE,
            defaultValue: Sequelize.fn('now'),
            allowNull: false
        }
    },
    {
        modelName: 'books',
        freezeTableName: true,
        createdAt: false,
        updatedAt: false,
        sequelize: db
    }
);
Enter fullscreen mode Exit fullscreen mode

Cool, next we will create a repository layer. It is a strategy for abstracting data access. It provides several methods for interacting with the model.

//bookRepo.ts
import Book from './bookModel';

class BookRepo {
    getBookDetail(bookID: number): Promise<Book | null> {
        return Book.findOne({
            where: {
                id: bookID
            }
        });
    }

    removeBook(bookID: number): Promise<number> {
        return Book.destroy({
            where: {
                id: bookID
            }
        });
    }
}

export default new BookRepo();
Enter fullscreen mode Exit fullscreen mode

Then we will create a service layer. It consists of the business logic of the application and may use the repository to implement certain logic involving the database.
It is better to have separate repository layer and service layer. Having separate layers make the code more modular and decouple database from business logic.

//bookService.ts
import BookRepo from './bookRepo';
import Book from './bookModel';

class BookService {
    getBookDetail(bookId: number): Promise<Book | null> {
        return BookRepo.getBookDetail(bookId);
    }

    async removeBook(bookId: number): Promise<number> {
        const book = await BookRepo.getBookDetail(bookId);
        if (!book) {
            throw new Error('Book is not found');
        }
        return BookRepo.removeBook(bookId);
    }
}

export default new BookService();
Enter fullscreen mode Exit fullscreen mode

Alright, we have done with the business logic. We will not write the controller and router because we want to focus on how to write the unit test.

Write Unit Test

Now we will write the unit test for repository and service layer. We will use AAA (Arrange-Act-Assert) pattern for writing the unit test.
The AAA pattern suggests that we should divide our test method into three sections: arrange, act and assert. Each one of them only responsible for the part in which they are named after. Following this pattern does make the code quite well structured and easy to understand.

Let's write the unit test. We will mock the method from bookModel to isolate and focus on the code being tested and not on the behavior or state of external dependencies. Then we will assert the unit test in some cases such as should be equal, should have been called number times, and should have been called with some parameters.

//bookRepo.test.ts
import BookRepo from './bookRepo';
import Book from './bookModel';

describe('BookRepo', () => {
    beforeEach(() =>{
        jest.resetAllMocks();
    });

    describe('BookRepo.__getBookDetail', () => {
        it('should return book detail', async () => {
            //arrange
            const bookID = 1;
            const mockResponse = {
                id: 1,
                title: 'ABC',
                author: 'John Doe',
                page: 1
            }

            Book.findOne = jest.fn().mockResolvedValue(mockResponse);

            //act
            const result = await BookRepo.getBookDetail(bookID);

            //assert
            expect(result).toEqual(mockResponse);
            expect(Book.findOne).toHaveBeenCalledTimes(1);
            expect(Book.findOne).toBeCalledWith({
                where: {
                    id: bookID
                }
            });
        });
    });

    describe('BookRepo.__removeBook', () => {
        it('should return true remove book', async () => {
            //arrange
            const bookID = 1;
            const mockResponse = true;

            Book.destroy = jest.fn().mockResolvedValue(mockResponse);

            //act
            const result = await BookRepo.removeBook(bookID);

            //assert
            expect(result).toEqual(mockResponse);
            expect(Book.destroy).toHaveBeenCalledTimes(1);
            expect(Book.destroy).toBeCalledWith({
                where: {
                    id: bookID
                }
            });
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

Then, we will write unit test for service layer. Same as repository layer, we will mock repository layer in service layer test to isolate and focus on the code being tested.

//bookService.test.ts
import BookService from './bookService';
import BookRepo from './bookRepo';

describe('BookService', () => {
    beforeEach(() =>{
        jest.resetAllMocks();
    });

    describe('BookService.__getBookDetail', () => {
        it('should return book detail', async () => {
            //arrange
            const bookID = 1;
            const mockResponse = {
                id: 1,
                title: 'ABC',
                author: 'John Doe',
                page: 1
            };

            BookRepo.getBookDetail = jest.fn().mockResolvedValue(mockResponse);

            //act
            const result = await BookService.getBookDetail(bookID);

            //assert
            expect(result).toEqual(mockResponse);
            expect(BookRepo.getBookDetail).toHaveBeenCalledTimes(1);
            expect(BookRepo.getBookDetail).toBeCalledWith(bookID);
        });
    });

    describe('BookService.__removeBook', () => {
        it('should return true remove book', async () => {
            //arrange
            const bookID = 2;
            const mockBookDetail = {
                id: 2,
                title: 'ABC',
                author: 'John Doe',
                page: 1
            };
            const mockResponse = true;

            BookRepo.getBookDetail = jest.fn().mockResolvedValue(mockBookDetail);
            BookRepo.removeBook = jest.fn().mockResolvedValue(mockResponse);

            //act
            const result = await BookService.removeBook(bookID);

            //assert
            expect(result).toEqual(mockResponse);

            // assert BookRepo.getBookDetail
            expect(BookRepo.getBookDetail).toHaveBeenCalledTimes(1);
            expect(BookRepo.getBookDetail).toBeCalledWith(bookID);

            //assert BookRepo.removeBook
            expect(BookRepo.removeBook).toHaveBeenCalledTimes(1);
            expect(BookRepo.removeBook).toBeCalledWith(bookID);
        });

        it('should throw error book is not found', () => {
            //arrange
            const bookID = 2;
            const mockBookDetail = null;
            const errorMessage = 'Book is not found';

            BookRepo.getBookDetail = jest.fn().mockResolvedValue(mockBookDetail);

            //act
            const result = BookService.removeBook(bookID);

            //assert
            expect(result).rejects.toThrowError(errorMessage);
            expect(BookRepo.getBookDetail).toHaveBeenCalledTimes(1);
            expect(BookRepo.getBookDetail).toBeCalledWith(bookID);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

Alright, we have done writing the unit test.
Before running the test, we will add script test in our package.json as follows:

//package.json
...
"scripts": {
    "build": "tsc",
    "build-watch": "tsc -w",
    "test": "jest --coverage ./src"
},
...
Enter fullscreen mode Exit fullscreen mode

Cool, finally we can run the test with this command in our terminal:

npm test
Enter fullscreen mode Exit fullscreen mode

After running, we will get this result telling our unit test is success and fully coverage 🎉

Unit Test Result
Beautiful! ✨

Links:

Top comments (5)

Collapse
 
attkinsonjakob profile image
Jakob Attkinson • Edited

One of the dilemmas I have all the time is whether or not I should test the EPs directly (or resolvers for GraphQL), by providing a testing DB and checking against the expect results OR if I should just test service methods independently (requires more work to mock stuff around).

In theory, if each controller method (REST) or resolver (GQL) is tested, everything should be covered. Edge cases are a bit more rough, but not impossible...

Still figuring out...

Collapse
 
arifintahu profile image
Miftahul Arifin • Edited

To me, testing entry points and service methods would have different scopes. Testing service methods requires fully covered code and condition in every method, we can call it white-box testing. On the other hand, the scope of testing entry points or E2E testing is checking the request and response of the APIs whether expected or not. In E2E testing is like black-box testing, we don't have to know whether it's fully covered the service layer or not.

Good reference : methodpoet.com/unit-tests-vs-end-t...

Collapse
 
andrewbaisden profile image
Andrew Baisden

This was great I know a lot of people that struggle with writing tests.

Collapse
 
jkprasad profile image
jkprasad

Unit test on service layer didn't contain the tests. Instead it contained the same service code

Collapse
 
arifintahu profile image
Miftahul Arifin

Ohh, I missed it, thanks for reminding