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
- Install dependencies
yarn add supertest jest ts-jest @types/jest @types/supertest -D
- Init file jest.config.js
yarn ts-jest config:init
- 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
- 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();
});
- 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