Testing Node.js dengan Jest dan Supertest

Testing Node.js dengan Jest dan Supertest

Software Testing adalah suatu proses untuk mengevaluasi fungsionalitas perangkat lunak dengan maksud untuk menemukan apakah perangkat lunak yang dikembangkan sudah memenuhi persyaratan yang ditentukan atau tidak dan juga untuk memastikan bahwa perangkat lunak bebas dari cacat atau bug sehingga dapat menghasilkan produk yang berkualitas. Jadi bisa dibilang testing merupakan bagian yang cukup penting dalam pengembangan aplikasi, semakin cepat kita memulai test, semakin baik. Sebelumnya saya juga pernah bahas mengenai beberapa jenis testing disini

Kali ini saya akan membahas bagaimana cara menulis script unit testing pada Node.js/Express.js dengan menggunakan Jest dan Supertest.

Ada pepatah tak kenal maka tak sayang, maka dari itu kita coba kenalan dulu dengan beberapa terminologi berikut:

  • Test Runner - sebuah library atau tool yang digunakan untuk mengetes suatu baris kode atau file, contohnya Jest, Mocha, dll.

  • Jest - Jest merupakan JavaScript testing framework yang dikembangkan oleh facebook. Tool ini sangat mudah digunakan dengan konfigurasi yang minimal dan sudah terdapat fitur test runner, assertion library dan mocking support.

  • Supertest - Sebuah library untuk testing Node.js HTTP servers. Dengan supertest kita memungkinkan melakukan HTTP requests seperti GET, POST, PATCH, PUT, DELETE ke HTTP servers.

Setup Jest

  1. Install dependencies
yarn add supertest jest ts-jest @types/jest @types/supertest -D
  1. Init file jest.config.js
yarn ts-jest config:init
  1. Pada file jest.config.js isi dengan konfigurasi seperti ini
// jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  testMatch: ["**/**/*.test.ts"],
  verbose: true,
  forceExit: true,
  clearMocks: true,
  resetMocks: true,
  restoreMocks: true,
  setupFilesAfterEnv: ["<rootDir>/setup-tests.ts"],
  collectCoverage: true,
  coverageReporters: ["json", "html"],
};

Beberapa hal yang perlu diperhatikan

  • testMatch: The glob patterns Jest uses to detect test files.

  • clearMocks: Automatically clear mock calls, instances, contexts and results before every test.

  • resetMocks: Automatically reset mock state before every test. Equivalent to calling jest.resetAllMocks() before each test. This will lead to any mocks having their fake implementations removed but does not restore their initial implementation.

  • restoreMocks: Automatically restore mock state and implementation before every test. Equivalent to calling jest.restoreAllMocks() before each test. This will lead to any mocks having their fake implementations removed and restores their initial implementation.

Konfigurasi lengkapnya bisa lihat di https://jestjs.io/docs/configuration

  1. Buat global setup files dengan nama setup-tests.ts file ini nantinya akan dieksekusi setelah env di-load dan dijlankan pada pada setiap file testing
// setup-test.ts
import { MongoMemoryServer } from "mongodb-memory-server";
import mongoose from "mongoose";

beforeAll(async () => {
  // sebelum dieksekusi akan melakukan koneksi ke mongodb
  // setupFilesAfterEnv => beforeAll dijalankan ketika env sudah di-load, dan isi NODE_ENV=test
  const mongoServer = await MongoMemoryServer.create();
  await mongoose.connect(mongoServer.getUri());
});

afterAll(async () => {
  // setelah selesai dieksekusi semua koneksi akan ditutup
  await mongoose.disconnect();
  await mongoose.connection.close();
});
  1. Tambahkan script untuk testing pada file package.json
{
    "test": "jest --detectOpenHandles --setupFiles dotenv/config --runInBand",
}

Membuat test case

Kali ini kita akan coba mengetes API endpoint untuk GET users, dan pada implementasinya kita akan melakukan koneksi langsung ke mongodb memory server (kode terdapat pada file setup-tests.ts)

Buatlah file testing dengan nama __test__/product.test.ts (semua file test harus berada pada folder ___test___ dan penamaan file harus terdapat *.test.[ts/js] atau *.spec.[ts/js])

// product.test.ts
import mongoose from "mongoose";
import supertest from "supertest";
import { createProduct } from "../service/product.service";
import { signJwt } from "../utils/jwt.utils";
import createServer from "../utils/server";

const app = createServer();

const userId = new mongoose.Types.ObjectId().toString();

export const productPayload = {
  user: userId,
  title: "Canon EOS 1500D DSLR Camera with 18-55mm Lens",
  description:
    "Designed for first-time DSLR owners who want impressive results straight out of the box, capture those magic moments no matter your level with the EOS 1500D. With easy to use automatic shooting modes, large 24.1 MP sensor, Canon Camera Connect app integration and built-in feature guide, EOS 1500D is always ready to go.",
  price: 879.99,
  image: "https://i.imgur.com/QlRphfQ.jpg",
};
export const userPayload = {
  _id: userId,
  email: "jane.doe@example.com",
  name: "Jane Doe",
};

describe("product", () => {
  describe("get product", () => {
    // describe.only ==> Only runs the tests inside this describe for the current file
    // describe.skip ==> Skips running the tests inside this describe for the current file
    describe("given the product does exist", () => {
      it("should return a 200 status and the product", async () => {
        // add product
        const product = await createProduct(productPayload);

        // call HTTP request to get products
        const { statusCode } = await supertest(app).get(`/api/products/${product.productId}`);

        // status code expected to be 200
        expect(statusCode).toBe(200);
      });
    });
  });

  describe("create product", () => {
    describe("given the user not logged in", () => {
      it("should return a 403", async () => {
        // call HTTP request without bearer token
        const { statusCode } = await supertest(app).post(`/api/products`);

        // expected to be unauthorized
        expect(statusCode).toBe(403);
      });
    });

    describe("given the user logged in", () => {
      it("should return a 200 and create product", async () => {
        // sign payload to get token
        const jwt = signJwt(userPayload);

        // call HTTP request with payload and bearer token
        const { statusCode, body } = await supertest(app)
          .post(`/api/products`)
          .set("Authorization", `Bearer ${jwt}`)
          .send(productPayload);

        // status code expected to be
        expect(statusCode).toBe(200);

        // response body expected have property title with value is productPayload.title
        expect(body).toHaveProperty("title", productPayload.title);
        // response body expected have property title with value is any string
        expect(body).toHaveProperty("_id", expect.any(String));
      });
    });
  });
});

Setelah membuat test case lalu kita eksekusi Jest, pada hal ini saya sudah menulis code untuk get users sedemikian rupa, sehingga semua test case diatas passed, repository sudah terlampir dibawah jadi kalian bisa explore.

Jalankan perintah yarn test maka jest akan menjalankan test case yang sudah kita tulis, dan kita bisa melihat hasil test pengetesan pada terminal serta coverage pada folder coverage

Mocking

Ada kondisi dimana kita tidak ingin mengetes dengan koneksi ke database, memanggil API pada service lain, atau terdapat fungsi yang memanggil fungsi lainnya dengan proses yang cukup kompleks, padahal kita hanya ingin mengetes 1 fungsi saja, tidak peduli response api dan behavior fungsi lainnya seperti apa, nah disinilah peran mocking berada.

Mocking (Meniru, imitasi) adalah sebuah cara pada sebuah test untuk meniru atau menyerupai sebuah fungsi untuk menggantikan fungsi yang tidak kita inginkan untuk ditest atau bahkan susah untuk ditest.

Misalnya, Fungsi A akan ditest. Pada implementasinya, fungsi A memanggil fungsi B yang mana fungsi B banyak memanggil fungsi lain yang tidak diketahui implementasi kodenya (misal mungkin saja berupa fungsi pemanggilan API, helper, query ke db, dsb.).

Untuk mengakalinya akan lebih baik kita dapat mengimitasi fungsi B, seolah fungsi B telah mengeluarkan keluaran yang benar. Sehingga kita dapat fokus untuk menguji fungsi A saja.

Perlu diketahui bahwa pada proses mocking ini tidak hanya dilakukan untuk mocking sebuah fungsi. Kita bisa juga membuat mocking berupa object.

Dalam membuat mocking, kita meng-override implementasi fungsi-fungsi yang dipanggil sehingga langsung mengembalikan nilai yang diharapkan. Dengan ini kita tidak perlu memprediksi keluaran yang dikelurkan oleh fungsi B.

Contoh kita buat file user.test.ts dengan test case user registration sebagai berikut

// user.test.ts
import mongoose from "mongoose";
import supertest from "supertest";
import createServer from "../utils/server";
import * as UserService from "../service/user.service";
import * as SessionService from "../service/session.service";
import { createUserSessionHandler } from "../controller/session.controller";

const app = createServer();

const userId = new mongoose.Types.ObjectId().toString();

const userPayload = {
  _id: userId,
  email: "jane.doe@example.com",
  name: "Jane Doe",
};

const userInput = {
  email: "test@example.com",
  name: "Jane Doe",
  password: "Password123",
  passwordConfirmation: "Password123",
};

const sessionPayload = {
  _id: new mongoose.Types.ObjectId().toString(),
  user: userId,
  valid: true,
  userAgent: "PostmanRuntime/7.28.4",
  createdAt: new Date("2021-09-30T13:31:07.674Z"),
  updatedAt: new Date("2021-09-30T13:31:07.674Z"),
  __v: 0,
};

describe("user", () => {
  describe(`user registration`, () => {
    describe("given the username and password are valid", () => {
      it("should return a 200", async () => {
        // Sometimes you only want to watch a method be called, but keep the original implementation. Other times you may want to mock the implementation, but restore the original later in the suite.

        // In these cases, you can use jest.spyOn
        //  @ts-ignore
        const createUserServiceMock = jest.spyOn(UserService, "createUser");

        //  override the implementation
        //  @ts-ignore
        createUserServiceMock.mockImplementation(() => "Yeay!");
        expect(UserService.createUser(userInput)).toEqual("Yeay!");
        createUserServiceMock.mockReset();

        const { statusCode, body } = await supertest(app).post("/api/users").send(userInput);

        expect(createUserServiceMock).toHaveBeenCalledTimes(1);
        expect(createUserServiceMock).toHaveBeenCalledWith(userInput);
        expect(statusCode).toBe(200);
        expect(body).toHaveProperty("email", userInput.email);
      });

      it("should return the user payload", async () => {
        // membuat mock function createUser pada UserService agar nilai yang dikembalikan sesuai dengan yang kita inginkan
        // Mocking is a technique to isolate test subjects by replacing dependencies with objects that you can control and inspect. A dependency can be anything your subject depends on, but it is typically a module that the subject imports.
        //  @ts-ignore
        const createUserServiceMock = jest.spyOn(UserService, "createUser").mockReturnValueOnce(userPayload);

        //  memasukan user input pada handler
        const { statusCode, body } = await supertest(app).post("/api/users").send(userInput);

        //  mengecek apakah function createUser terpanggil 1x
        expect(createUserServiceMock).toHaveBeenCalledTimes(1);
        //  mengecek apakah function createUser terpanggil dengan parameter userInput
        expect(createUserServiceMock).toHaveBeenCalledWith(userInput);
        //  mengecek handler apakah memiliki status code 200
        expect(statusCode).toBe(200);
        //  mengecek response handler, response-nya sudah kita mocking diatas menjadi userPayload
        expect(body).toEqual(userPayload);
      });
    });

    describe("given the password do not match", () => {
      it("should return a 400", async () => {
        // @ts-ignore
        const createUserServiceMock = jest.spyOn(UserService, "createUser").mockReturnValueOnce(userPayload);

        // test validasi jika password & passwordConfirmation does not match
        const { statusCode } = await supertest(app)
          .post("/api/users")
          .send({ ...userInput, passwordConfirmation: "doesnotmatch" });

        expect(statusCode).toBe(400);
        // test function createUser pada UserService harusnya tidak terpanggil
        expect(createUserServiceMock).not.toHaveBeenCalled();
      });
    });

    describe("given the user service throws", () => {
      it("should return a 409 error", async () => {
        const msgErr = "oh no! :(";

        // mocking fungsi create user seolah2 terjadi error dengan pesan error yang kita inginkan
        const createUserServiceMock = jest.spyOn(UserService, "createUser").mockRejectedValueOnce(msgErr);

        const { statusCode } = await supertest(app).post("/api/users").send(userInput);

        expect(statusCode).toBe(409);
        expect(createUserServiceMock).toHaveBeenCalled();
      });
    });
  });

  describe(`create user session`, () => {
    describe("given the username and password are valid", () => {
      it("should return a signed accessToken & refresh token", async () => {
        // @ts-ignore
        jest.spyOn(UserService, "validatePassword").mockReturnValue(userPayload);

        // @ts-ignore
        jest.spyOn(SessionService, "createSession").mockReturnValue(sessionPayload);

        // mocking request handler
        const req = {
          get: () => {
            return "a user agent";
          },
          body: {
            email: "test@example.com",
            password: "Password123",
          },
        };

        // mocking fungsi send pada response handler / controller
        const send = jest.fn();

        // mocking response handler
        const res = {
          send,
        };

        // memanggil session.controller/createUserSessionHandler
        // @ts-ignore
        await createUserSessionHandler(req, res);

        // mengecek fungsi send terpanggil dengan parameter object yang berisi accessToken & refreshToken
        expect(send).toHaveBeenCalledWith({
          accessToken: expect.any(String),
          refreshToken: expect.any(String),
        });
      });
    });
  });
});

Saya sudah buat fungsi yang akan ditest, ketika test dieksekusi console akan menampilkan seperti berikut

Segitu saja sedikit penjelasan mengenai testing, berikut repository contoh testing Express.js dengan Jest dan Supertest:

https://github.com/nauvalsh/unit-testing-express

Terdapat 2 branch

  • main: typeorm, postgresql

  • main-mongo: mongodb, mongoose

https://github.com/katesroad/TDD-Node-Express/tree/master/server

  • master: sequelize, postgresql