DEV Community

Cover image for Mastering Unit Testing: A Comprehensive Guide
Jayant
Jayant

Posted on

Mastering Unit Testing: A Comprehensive Guide

What

In Unit test we test the functions, endpoints, components individually. In this we just test the functionality of the code that we have written and mock out all the external services [ DB Call, Redis] etc.

How

We are going to use [Vitest](https://vitest.dev/) for doing the testing as it has more smooth Typescript support rather than Jest. You can use any of them the code will look the same for both.

Some Testing Jargons

  • Test suite - It is a collection of the test cases of a particular module. we use describe function, it helps to orgainse our test cases into groups.
describe("Testing Sum Module", () => {
    // Multiple test cases for testing sum module
});

describe("Testing Multiplication  Module", () => {
    // Multiple test cases for testing Multiplication module
});
Enter fullscreen mode Exit fullscreen mode
  • Test case - It is a individual unit of testing, defined using it or test.
describe("Testing Sum Module", () => {
    // Multiple test cases for testing sum module
    it("should give 1,2 sum to be 3", () => {
        // It checks that result matches or not
        expect(add(1, 2))toBe(3);
    });
});
Enter fullscreen mode Exit fullscreen mode
  • Mocking - It is used to mock out various external calls like DB calls. To do this we use vi.fn(). It creates a mock functions and returns undefined.
// db contains the code that we have to mock.
// it is good practice to keep the content that we have to mock in a seprate file
// vi.mock() is hoisted on the top of the test file.
vi.mock("../db", () => {
    return {
        // prismaClient is imported from db file
        // we want to mock the prismaClient.sum.create() function
        prismaClient:{
            sum:{
                create:vi.fn();
            }
        }
    };
});
Enter fullscreen mode Exit fullscreen mode
  • Spy - It is basically used to spy on a function call, means as we are mocking the db call but we don't know that right arguments are passed to that db call or not, so to check that we use spy.
// create is the method
// prismaClient.sum is the object
vi.spyOn(prismaClient.sum, "create");
// Now to check write arguments are passed on to the create method or not we can do this
expect(prismaClient.sum.create).toHaveBeenCalledWith({
    data: {
        a: 1,
        b: 2,
        result: 3,
    },
});
Enter fullscreen mode Exit fullscreen mode

-Mocking Return Values - Sometimes you want to use values returned by an async operation/external calls. Right now we can't use any of the values as we are mocking that call. To do so we have to use mockResolvedValue.

//this is not the actual prismaClient object. This is the Mocked version of prismaClient that we are using that's why we are able to use mockResolvedValue.
prismaClient.sum.create.mockResolvedValue({
    id: 1,
    a: 1,
    b: 1,
    result: 3,
});
Enter fullscreen mode Exit fullscreen mode

Examples

Unit test of an Express APP
In an express we write the app.listen in seprate file, cuz when we try to run the test everytime it will start a server & we can't hard code any PORT [ what if PORT is in use ]. So we use superset which automatically creates an use and throw server.
we create an seperate bin.ts or main.ts file which will do the app.listen.

  • Run these Commands
npm init -y
npx tsc --init
npm install express @types/express zod
npm i -D vitest
// supertest allow us to make server
npm i supertest @types/supertest
// used for deep-mocking, by using this we don't have to tell which method to mock, we can mock whole prisma client
npm i -D vitest-mock-extended
Enter fullscreen mode Exit fullscreen mode

Change rootDir and srcDir

"rootDir": "./src",
"outDir": "./dist",
Enter fullscreen mode Exit fullscreen mode

Add a script to test in package.json

"test": "vitest"
Enter fullscreen mode Exit fullscreen mode

Adding an DB

npm i prisma
npx prisma init
Enter fullscreen mode Exit fullscreen mode

Add this basic schema in schema.prisma

model Sum {
  id          Int @id   @default(autoincrement())
  a           Int
  b           Int
  result      Int
}
Enter fullscreen mode Exit fullscreen mode

Generate the client (notice we don’t need to migrate since we wont actually need a DB)

npx prisma generate
Enter fullscreen mode Exit fullscreen mode

Create src/db.ts which exports the prisma client. This is needed because we will be mocking this file out eventually

import { PrismaClient } from "@prisma/client";
export const prismaClient = new PrismaClient();
Enter fullscreen mode Exit fullscreen mode

src/Index.ts

import express from "express";
import { z } from "zod";
import { prismaClient } from "./db";

export const app = express();

app.use(express.json());

const sumInput = z.object({
    a: z.number(),
    b: z.number(),
});

app.post("/sum", async (req, res) => {
    const parsedResponse = sumInput.safeParse(req.body);
    if (!parsedResponse.success) {
        return res.status(411).json({ message: "Invalid Input" });
    }
    // const a = req.body.a;
    // const b = req.body.b;
    const answer = parsedResponse.data.a + parsedResponse.data.b;
    // we want to mock this as empty function
    const response = await prismaClient.sum.create({
        // kya gurantee hai ki yeh data aise hi hoga , agar koi contributer isme change kar de to
        // to solve this issue we have spy that
        // abhi agar hum isme wrong input bhi pass karege tab bhi ye koi error nhi dega
        // so we have to use spies
        data: {
            a: parsedResponse.data.a,
            b: parsedResponse.data.b,
            result: answer,
        },
    });
    console.log(response.Id);
    // agar user try karega to return something else it will give them a error.
    // res.json({ answer, id: response.b });
    res.json({ answer, id: response.Id });
});

// isme sab kuch headers mai pass hoga
app.get("/sum", (req, res) => {
    const parsedResponse = sumInput.safeParse({
        a: Number(req.headers["a"]),
        b: Number(req.headers["b"]),
    });
    if (!parsedResponse.success) {
        return res.status(411).json({ message: "Invalid Input" });
    }
    const answer = parsedResponse.data.a + parsedResponse.data.b;

    res.json({ answer });
});
Enter fullscreen mode Exit fullscreen mode

Create __mocks__/db.ts in the src folder, same folder in which db.ts resides. Its a type of convention, vitest looks for any __mocks__ file to know what to mock.

import { PrismaClient } from "@prisma/client";
import { mockDeep } from "vitest-mock-extended";

export const prismaClient = mockDeep<PrismaClient>();
Enter fullscreen mode Exit fullscreen mode

index.test.ts

import { describe, it, expect, vi } from "vitest";
import request from "supertest";
import { app } from "../index";
import { prismaClient } from "../__mocks__/db";

// vi.mock("../db", () => ({
//  prismaClient: { sum: { create: vi.fn() } },
// }));

vi.mock("../db");

// // Mocking the return value using mockResolvedValue
// prismaClient.sum.create.mockResolvedValue({
//  Id: 1,
//  a: 1,
//  b: 2,
//  result: 3,
// });
describe("POST /sum", () => {
    it("Should return the sum of 2,3 to be 6", async () => {
        // Mocking the return value using mockResolvedValue
        prismaClient.sum.create.mockResolvedValue({
            Id: 1,
            a: 1,
            b: 2,
            result: 3,
        });

        vi.spyOn(prismaClient.sum, "create");
        const res = await request(app).post("/sum").send({
            a: 1,
            b: 2,
        });

        expect(prismaClient.sum.create).toBeCalledWith({
            data: {
                a: 1,
                b: 2,
                result: 3,
            },
        });
        expect(prismaClient.sum.create).toBeCalledTimes(1);
        expect(res.status).toBe(200);
        expect(res.body.answer).toBe(3);
        expect(res.body.id).toBe(1);
    });

    it("Should return sum of 2 negative numbers", async () => {
        // Mocking the return value using mockResolvedValue
        prismaClient.sum.create.mockResolvedValue({
            Id: 1,
            a: 1,
            b: 2,
            result: 3,
        });

        vi.spyOn(prismaClient.sum, "create");
        const res = await request(app).post("/sum").send({
            a: -10,
            b: -20,
        });

        expect(prismaClient.sum.create).toBeCalledWith({
            data: {
                a: -10,
                b: -20,
                result: -30,
            },
        });
        expect(prismaClient.sum.create).toBeCalledTimes(1);

        expect(res.status).toBe(200);
        expect(res.body.answer).toBe(-30);
        expect(res.body.id).toBe(1);
    });

    it("If wrong input is provided, it should return 411 with a msg", async () => {
        const res = await request(app).post("/sum").send({
            a: "abcd",
            b: 2,
        });

        expect(res.status).toBe(411);
        expect(res.body.message).toBe("Invalid Input");
    });
});

describe("GET /sum", () => {
    it("should return the sum of 2,3 to be 5", async () => {
        const res = await request(app)
            .get("/sum")
            .set({
                a: "2",
                b: "3",
            })
            .send();

        expect(res.status).toBe(200);
        expect(res.body.answer).toBe(5);
    });
});
Enter fullscreen mode Exit fullscreen mode

Now Run npm run test to test your code.

Implementing CI Pipeline

Create an .github/workflows/test.yml file

This below code will automatically tests the if any code is pushed on main branch or for any pull request.

name: Testing on CI
on:
  pull_request:
    branches:
      - main
  push:
    branches:
      - main

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up Node.js
        uses: actions/setup-node@v2
        with:
          node-version: 20

      - name: Install dependencies
        run: npm install && npx prisma generate

      - name: Run tests
        run: npm test
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
jay818 profile image
Jayant

Let me know, If I miss something 😊

Collapse
 
ritikbanger profile image
Ritik Banger

could you share an example, where an api consumes secret manager data, as well as the secret manager data contains db credentials and then query some data from a table?