Trong phần thứ ba của series All things about unit test with Jest, mình sẽ hướng dẫn các bạn cài đặt và viết unit test cho một project nho nhỏ.
- Trong bài viết này mình sẽ chủ yếu focus vào các phần test thôi, vậy nên các phần khác như
controllers
,models
,routes
thì mình sẽ lược bỏ qua và chỉ nêu những cái chính trong đó nhằm để giảm thiểu độ dài bài viết cũng như tăng độ tập trung vào các unit test. - Ngoài ra thì mình còn có
config
thêmcors
cho dự án này =))) mình không định làm đâu, nhưng mà nhằm để mọi người khiclone
project về thì thửrun
hoặctest
API bằngpostman
.
Intro
- Bạn gặp khó khăn trong việc
mock
cácexternal dependency
trong 1unit test
🥹 ? - Bạn mới tiếp xúc và loay hoay với Jest khi viết
unit test
, đừng lo, vì phần này sẽ giải thích cặn kẽ hơn về bản chất củamocking
cũng như cách đểmock
.
Giờ thì hãy cùng mình jump
vào phần này nào.
Thông tin package (cài đặt thêm)
- "cors": "^2.8.5"
- "dotenv": "^16.4.5"
$ npm i cors@2.8.5 dotenv@16.4.5
Các bạn có thể tải toàn bộ source code của phần này ở đây
Cài đặt
1. Design DB
Mình sẽ tạo ra 1 ứng dụng cho việc quản lý sách của chính mình (mang tính chất demo)
Database mình design
khá đơn giản (chủ yếu cho mục đích demo), với 2 table
là user
và book
.
Công cụ sử dụng:
- Mình sẽ sử dụng mysql để lưu trữ dữ liệu và dùng sequelize để làm ORM cho ứng dụng này.
2. Thiết lập kết nối DB
Đầu tiên các bạn cần tải thêm sequelize-cli để ở chế độ áp dụng cho global
:
$ npm i sequelize-cli -g
Và các bạn kiểm tra xem mình đang đứng ở đâu:
$ pwd
/Users/tienminh/Documents/testing_with_jest
$ cd src/
$ sequelize init
Giải thích:
- Ở đây mình sẽ dùng
sequelize-cli
để khởi tạo ra cácfolder và file cần dùng
. - Mình tạo ra trong folder
src
của dự án.
Ngoài ra các bạn hãy tạo thêm file .env
để config các variable environment
cần dùng.
Dưới đây là cấu trúc thư mục:
NOTE:
- Vì mình không muốn push lên github file .env của mình, nên mình sẽ tạo ra file .env.example để cung cấp các tên key cho mọi người, mọi người sẽ tự tạo ra file .env khi clone project về và thêm value vào các key.
Bây giờ bạn hãy sửa file src/config/config.json
thành src/config/config.js
- Sau đó hãy bật file
src/config/config.js
và sửa theo mình: - Sau đó hãy mở tạo file
.sequelizerc
trong foldersrc
và config nó chosequelize-cli
hiểu và sử dụng: Oke, vậy là đãsetup
xong phần kết nốidatabase
.
Giờ mình sẽ dùng lệnh sequelize db:create và các bạn cùng xem sự thay đổi nhé.
NOTE
Mình sẽ sử dụng trực tiếplocal mysql
để chạy.
Trên thực tế, khi viết unit test thì không cần config mấy cái này : ) vì cơ bản mình mock thì đã có thể tự giả định trước kết quả trả về, và đương nhiên hàm thật sẽ không chạy, thay vì đó sẽ chạy hàm override (hàm mà mình mock).
3. Models
Trước tiên các bạn kiểm tra mình đang đứng ở thư mục nào:
$ pwd
/Users/tienminh/Documents/testing_with_jest
// Nếu đang đứng ở thư mục gốc thì hãy cd vào thư mục src, mới có thể chạy lệnh oke
$ cd src
$ sequelize model:generate --name User --attributes email:string,password:string,name:string,mobile_phone:string
$ sequelize model:generate --name Book --attributes user_id:integer,name:string,title:string
Sau khi tạo được các models
, chúng ta sẽ cần khai báo quan hệ của chúng, hãy mở file src/models/book.js
và sửa như này:
Tương tự đối với file src/models/user.js
:
Sau đó các bạn hãy mở file migration
của create_book
ra, và thêm vào dòng mã này cho mình:
Gần xong rồi, còn chút nữa thôi 🥲, các bạn hãy chạy lệnh này cho mình:
$ sequelize db:migrate
Nếu thành công, các bạn sẽ có hiển thị như này nhé
4. Controllers
Về controllers
, nhằm đơn giản hoá việc viết mã, cả ở User Controller
lẫn Book Controller
, mình sẽ chỉ thực hiện 2 công việc là GET
và POST
, tức là tạo mới
và lấy thông tin.
Trong thư mục src/controllers
ta tạo 2 file user.controller.js
và book.controller.js
:
// user.controller.js
const db = require("../models/index.js"); const createUser = async (req, res) => { try { const newUser = await db.User.create({ email: req.body.email, password: req.body.password, name: req.body.name === "" ? "" : req.body.name, mobile_phone: req.body.mobile_phone === "" ? "" : req.body.mobile_phone, }); return res.status(201).json({ data: newUser }) } catch (error) { return res.status(500).json({ message: error.message }); }
} const getUser = async (req, res) => { try { const findUserById = await db.User.findOne({ where: { id: parseInt(req.params.id) } }); if (findUserById) { return res.status(200).json({ data: findUserById.dataValues }); } else throw new Error('User not found!') } catch (error) { return res.status(500).json({ message: error.message }); }
} module.exports = { createUser, getUser
}
// book.controller.js
const db = require("../models/index.js"); const createBook = async (req, res) => { try { const newBook = await db.Book.create({ name: req.body.name, title: req.body.title, user_id: parseInt(req.params.user_id) }); return res.status(201).json({ data: newBook }) } catch (error) { return res.status(500).json({ message: error.message }); }
} const getBook = async (req, res) => { try { let findBookOfUser; if (isNaN(parseInt(req.params.user_id))) throw new Error('Vui lòng truyển đúng số'); else findBookOfUser = await db.Book.findAll({ where: { user_id: parseInt(req.params.user_id) } }); return res.status(200).json({ data: findBookOfUser }); } catch (error) { return res.status(400).json({ message: error.message }); }
} module.exports = { createBook, getBook
}
Ngoài ra mình còn định nghĩa các routes
cho các controllers
này. Các bạn có thể tham khảo tại đây
Hiểu hơn về mocking
1. Modules caching work với nodejs
Theo document của nodejs:
Modules are cached after the first time they are loaded
===> Điều này có nghĩa là nếu ta gọi function imported từ module đó trong cùng 1 file, thì ta luôn nhận về cùng một đối tượng.
Giờ thì hãy cùng mình tìm hiểu cách hoạt động của nó
Đầu tiên hãy tạo file target.js
trong thư mục dự án
// testing_with_jest/target.js
module.exports = { example: () => console.log("I'm the original module"),
};
Sau đó tạo file example.js
trong thư mục dự án
// testing_with_jest/example.js
const targetPath = require.resolve('./target.js'); console.log(require.cache[targetPath]);
const target = require('./target');
console.log(require.cache[targetPath]); target.example();
Giờ thì chúng ta hãy chạy lệnh node example.js
, kết quả:
Giải thích:
- Dòng đầu tiên là
undefined
, nghĩa làmodule
này chưa đượcloaded
vào do chưarequire
ở hiện tại (vì mình có sử dụngrequire.resolve
) - Dòng log thứ hai hiển thị ra thông tin về
cached module
- Dòng log cuối thì chúng ta thấy là nó đã gọi
module gốc
- Có một vài thông tin cần để ý về thông tin của cached module:
- loaded: trạng thái load của module
- exports: nội dung được xuất ra của file
- id: đường dẫn đến file đó.
2. Hiểu về cơ chế hoạt động của việc mock trong Jest
Giờ mình sẽ làm một vài thay đổi nhỏ trong file example.js
, chính xác là mình sẽ override lại cache của module
// example.js
const targetPath = require.resolve('./target.js');
require.cache[targetPath] = { loaded: true, id: targetPath, exports: { example: () => console.log("I'm mocked"), },
}; const target = require('./target');
console.log(require.cache[targetPath]); target.example();
Sau đó run file example.js
:
Các bạn thấy gì ở đây nhỉ? 🤣
Chắc hẳn đến đây các bạn vẫn chưa thấy nó có lợi ích gì lắm, tuy nhiên đây chính xác là những gì mà testing tool sẽ mock, nó làm công việc đó chính là override lại cached module
Giờ thì bạn đã hiểu hơn về câu nói của mình, trong Test double thì hàm thật không thực sự chạy.
NOTE:
- Phần quan trọng nhất đó chính là exports, đây là phần chúng ta sẽ mock
- Hàm gốc example không còn được gọi nữa, thay vào đó chính là hàm mock được gọi và đã in ra kết quả ở dòng log thứ hai: I'm mocked.
3. Làm thế nào để mock imported function
Để mock imported function
, chúng ta sẽ sử dụng jest.mock()
function.
jest.mock(moduleName, factory, options)
có 3 đối số truyền vào, trong đó:
- moduleName(required), đây là đường dẫn đến file
- factory(optional) là 1 hàm dùng để
mock
, nếu không chỉ định thì Jest sẽ tự động mô phỏng module đã nhập - options, mình chưa dùng bao giờ : ) nhưng mà đại khái có thể hiểu là các
config
chojest.mock()
Giờ thì hãy cùng mình mock
nhé.
Mình sẽ tiến hành mock file isEven.js
:
// isEven.spec.js
const isEven = require("./isEven.js");
// The mock factory returns the function () => true
jest.mock("./isEven.js", () => () => true); describe("isEven", () => { it('should not pass, but pass because of the isEven() mock', () => { expect(isEven(3)).toBe(true); }) it('should pass', () => { expect(isEven(4)).toBe(true); })
})
Kết quả: Giải thích:
jest.mock("./isEven.js", () => () => true);
, các bạn có thể thấy hơi lạ ha 🤪 nhưng mà thực chất nó là như này:- Ở đối số thứ hai của
jest.mock()
sẽ nhận về1 factory function
, vàfactory function
đó đang trả ra 1function
luôn trả về giá trịtrue
. - Bởi vì
module isEven
chỉexports
ra1 default
là function.
- Ở đối số thứ hai của
- Ngoài ra các bạn có thể thấy rằng chúng ta đang
mock như vậy
thì đó chính là 1 stub, tuy nhiên stub này luôn trả về true, có thể sẽ có nhiều trường hợp chúng ta không cần như vậy. Và thật vậy, chúng ta hãy xem test case đầu tiên, đáng lý ra nó sẽ không pass, tuy nhiên nhờisEven() mock
mà nó cũng pass.
Thấu hiểu được nỗi niềm đó, sau đây mình sẽ hướng dẫn các bạn cách tuỳ chỉnh kết quả trả về : )
Chúng ta sẽ sửa lại 1 chút như sau:
// isEven.spec.js
const isEven = require("./isEven.js); jest.mock("./isEven.js", () => jest.fn()); describe("isEven", () => { it("should be false", () => { isEven.mockImplementation(() => false); expect(isEven(3)).toBe(false); expect(isEven).toHaveBeenCalledWith(3); }) it("should be true", () => { isEven.mockImplementation(() => true); expect(isEven(4)).toBe(true); expect(isEven).toHaveBeenCalledWith(4); })
})
Giải thích
- Ở đây mình có sử dụng thêm
jest.fn()
function, nhằm để tạo ra 1mock
(thành phần trong Test Double), trong docs Jest có đề cập rằngmock
cũng được biết đến như làspy
- Các bạn có thể thấy rõ rằng ta đã dùng
matcher toHaveBeenCalledWith
,spy
trongTest Double
đã thể hiện một phần ở chỗ này đấy. - Ngoài ra vì mình đã dùng
jest.fn()
thì mình cũng có thểcontrol
hành vi của hàm override sẽ thực thi như nào (đây chính là thể hiện củamock
trongTest Double
). - Ngoài ra các bạn có thể sử dụng
mockReturnValue()
trongJest
, thì nó cũng thể hiện như là stub.
Kết quả:
4. Làm thế nào để mock object với jest
Để có thể mock object trong Jest (trong trường hợp mock 1 module để lấy object của nó thực thi 1 công việc):
const target = require('./target');
jest.mock('./target', () => ({ example: jest.fn(() => console.log("I'm mocked")),
}));
Mọi người để ý nhé, đây chính là stub(ở phần example
trong object
, vì ở đây ta định trước kết quả trả về. Ở đây mình có thể thay vì dùng console.log
thì mình sẽ dùng return "I'm mocked
chẳng hạn.
Giải thích:
- Ở đây chúng ta đã
mock
lạiexample
của filetarget.js
bằng cách kết hợp sử dụngjest.mock
vàjest.fn()
, nó sẽoverride
lại y như cách mình đã giải thích trước đó. - Ngoài ra ở
function
của hàmjest.mock
đang trả về1 object
vì có dấu()
bao quanh{}
, đây làcú pháp short hand bên js
, bạn nào chưa từng codejs
thì sẽ có thể không biết.
5. Làm thế nào để clear mocked function trước mỗi test
Chúng ta khi áp dụng vào trường hợp thực tế, chúng ta sẽ có thể có những mocked function
dùng chung cho nhiều test case
, tuy nhiên chúng ta muốn rằng nó được isolated
với nhau cũng như dễ dàng kiểm soát đầu vào và ra
của mỗi external dependency
.
Chính vì vậy mà chúng ta nên clear mocked function trước mỗi test.
Ví dụ:
const isEven = require("./isEven.js); jest.mock("./isEven.js", () => jest.fn()); describe("isEven", () => { it("should be false", () => { isEven.mockImplementation(() => false); expect(isEven(3)).toBe(false); expect(isEven).toHaveBeenCalledWith(3); isEven.mockClear(); }) it("should be true", () => { isEven.mockImplementation(() => true); expect(isEven(4)).toBe(true); expect(isEven).toHaveBeenCalledWith(4); })
}
Tuy vậy, chẳng nhẽ mỗi khi cần clear ở mỗi unit test
, chúng ta lại cần thêm dòng isEven.mockClear()
hay sao?, nếu có rất nhiều mock
cần clear
sau mỗi unit test thì sao nhỉ?
, thì chúng ta sẽ phải lặp lại các đoạn mã đấy dù chúng giống nhau về cả chức năng
lẫn mã code
Đừng lo, vì Jest cung cấp cho chúng ta các hook
để các thể xử lí vấn đề đó.
Ở đây, mình sẽ sử dụng hook afterEach()
để xử lí, ngoài ra các bạn còn có thể tham khảo thêm các hook
các ở đây.
const isEven = require("./isEven.js); jest.mock("./isEven.js", () => jest.fn()); afterEach(() => { isEven.mockClear();
}) describe("isEven", () => { it("should be false", () => { isEven.mockImplementation(() => false); expect(isEven(3)).toBe(false); expect(isEven).toHaveBeenCalledWith(3); }) it("should be true", () => { isEven.mockImplementation(() => true); expect(isEven(4)).toBe(true); expect(isEven).toHaveBeenCalledWith(4); })
})
Áp dụng viết unit test cho controllers
1. User
// src/tests/controllers/user.controller.spec.js
const { createUser, getUser } = require("../../controllers/user.controller.js");
const db = require("../../models/index.js"); jest.mock("../../models/index.js", () => ({ User: { create: jest.fn(), findOne: jest.fn() }
})) describe("UserController", () => { let req, res; beforeEach(() => { req = { params: { id: '1' }, body: { email: 'test123@gmail.com', password: 'hihihi', name: 'test', mobile_phone: '', } } res = { status: jest.fn(() => res), json: jest.fn(), } }) afterEach(() => { jest.clearAllMocks(); }) describe("create user", () => { it("should create successful", async () => { // Arrange db.User.create.mockReturnValue({ id: 4, email: 'test123@gmail.com', password: 'hihihi', name: 'test', mobile_phone: '', updatedAt: '2024-04-18T08:12:29.361Z', createdAt: '2024-04-18T08:12:29.361Z' }) // Act await createUser(req, res); // Assert // các bạn để ý ở đây, res.status thực chất là 1 spy, // res.json cũng vậy // vì chúng ta đang theo dõi các đối số được gọi và hành vi của chúng expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ data: { id: 4, email: 'test123@gmail.com', password: 'hihihi', name: 'test', mobile_phone: '', updatedAt: '2024-04-18T08:12:29.361Z', createdAt: '2024-04-18T08:12:29.361Z' } }); }) it("should create failure", async () => { // Arrange db.User.create.mockImplementation(() => { throw new Error('Testing in here!'); }) // Act await createUser(req, res); // Assert expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ message: 'Testing in here!' }); }) }) describe("get user", () => { it ("should get user successful!", async () => { // Arrange db.User.findOne.mockImplementation(() => ({ dataValues: { id: 1, email: 'test@gmail.com', password: 'hihihi', name: 'test', mobile_phone: '', createdAt: '2024-04-18T08:10:28.000Z', updatedAt: '2024-04-18T08:10:28.000Z' } })) // Act await getUser(req, res); // Assert expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ data: { id: 1, email: 'test@gmail.com', password: 'hihihi', name: 'test', mobile_phone: '', createdAt: '2024-04-18T08:10:28.000Z', updatedAt: '2024-04-18T08:10:28.000Z' } }) }) it ("should get user failure", async () => { // Arrange db.User.findOne.mockImplementation(() => null); // Act await getUser(req, res); // Assert expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ message: "User not found!" }) }) })
})
Mình chỉ đang áp dụng kiến thức và các cách làm đã giải thích ở các phần trước vào controller đơn giản này, như các bạn đã thấy rằng:
- Ta sẽ chỉ
mock
cácexternal dependency
của 1 unit nào đó. - Bản chất rằng các
unit
sẽ có khả năng phụ thuộc khá cao vàoexternal dependency
. - Bạn cũng thấy được rằng khi viết unit test giúp chúng ta nhận rõ một điều rằng, nếu chúng ta phải
mock
quá nhiềustub
thì đó là dấu hiệu cho thấy bạn nên refactor lại code, vì:- Đoạn mã đó của bạn đang ôm khá nhiều logic, và nó không tuân tuân thủ theo nguyên tắc đầu tiên trong SOLID
2. Book
// src/tests/controllers/book.controller.spec.js
const { createBook, getBook } = require("../../controllers/book.controller.js");
const db = require("../../models/index.js"); jest.mock("../../models/index.js", () => ({ Book: { create: jest.fn(), findAll: jest.fn() }
})) describe("BookController", () => { let req, res; beforeEach(() => { req = { params: { user_id: '1' }, body: { name: 'Book 1', title: 'Câu chuyện về những vì sao', } } res = { status: jest.fn(() => res), json: jest.fn(), } }) afterEach(() => { jest.clearAllMocks(); }) describe("new book", () => { it("should create successful", async () => { // Arrange db.Book.create.mockReturnValue({ id: 1, user_id: 1, name: 'Book 1', title: 'Câu chuyện về những vì sao' }) // Act await createBook(req, res); // Assert expect(res.status).toHaveBeenCalledWith(201); expect(res.json).toHaveBeenCalledWith({ data: { id: 1, user_id: 1, name: 'Book 1', title: 'Câu chuyện về những vì sao' } }); }) it("should create failure", async () => { // Arrange db.Book.create.mockImplementation(() => { throw new Error('Testing in here!'); }) // Act await createBook(req, res); // Assert expect(res.status).toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith({ message: 'Testing in here!' }); }) }) describe("get book of user", () => { it ("should success", async () => { // Arrange db.Book.findAll.mockImplementation(() => { return [ { id: 1, user_id: 1, name: "Book 1", title: "Test 1", createdAt: "2024-04-18T13:48:38.000Z", updatedAt: "2024-04-18T13:48:38.000Z" }, { id: 2, user_id: 1, name: "Book 2", title: "Test 2", createdAt: "2024-04-18T13:48:38.000Z", updatedAt: "2024-04-18T13:48:38.000Z" }, ] }); // Act await getBook(req, res); // Assert expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ data: [ { id: 1, user_id: 1, name: "Book 1", title: "Test 1", createdAt: "2024-04-18T13:48:38.000Z", updatedAt: "2024-04-18T13:48:38.000Z" }, { id: 2, user_id: 1, name: "Book 2", title: "Test 2", createdAt: "2024-04-18T13:48:38.000Z", updatedAt: "2024-04-18T13:48:38.000Z" }, ] }) }) it("should fail", async () => { // Arrange req = { ...req, params: { user_id: 'asdfas', } } // Act await getBook(req, res); // Assert expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ message: "Vui lòng truyển đúng số" }) }) })
})
Kết luận
- Chúng ta đã cùng nhau đi qua và hiểu hơn về việc
mocking
cácexternal dependency
với Jest, mình tin chắc rằng đến đây cũng giúp cho khá nhiều bạn khi mới chập chững viết unit test cho các thành phần trong ứng dụng của mình. - Hơn nữa chúng ta cũng đã chỉ ra và làm rõ hơn các thành phần trong Test Double, ở phần 3 này chúng ta vẫn chưa thấy rõ spy được dùng ở đâu, chỗ nào mà chỉ thấy rằng mock đã thể hiện 1 phần của spy. Vì vậy ở phần kế tiếp, cũng là phần cuối cùng, chúng ta sẽ tiến hành viết unit test cho class. Và ở đây chúng ta sẽ thấy rõ hơn về spy trong Test Double.