- vừa được xem lúc

Part 3: Làm thế nào để mock imported function trong Jest

0 0 5

Người đăng: Minh Lê

Theo Viblo Asia

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êm cors cho dự án này =))) mình không định làm đâu, nhưng mà nhằm để mọi người khi clone project về thì thử run hoặc test API bằng postman.

Intro

  • Bạn gặp khó khăn trong việc mock các external dependency trong 1 unit 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ủa mocking 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 tableuserbook. 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ác folder 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 folder src và config nó cho sequelize-cli hiểu và sử dụng: Oke, vậy là đã setup xong phần kết nối database.
    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ếp local 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à GETPOST, 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.jsbook.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 được loaded vào do chưa require ở hiện tại (vì mình có sử dụng require.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 cho jest.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 1 function luôn trả về giá trị true.
    • Bởi vì module isEven chỉ exports ra 1 default là function.
  • 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 1 mock (thành phần trong Test Double), trong docs Jest có đề cập rằng mock 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 trong Test 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ủa mock trong Test Double).
  • Ngoài ra các bạn có thể sử dụng mockReturnValue() trong Jest, 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ại example của file target.js bằng cách kết hợp sử dụng jest.mockjest.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àm jest.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 code js 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ác external 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ào external 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ều stub 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ác external 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.

Reference

Bình luận

Bài viết tương tự

- vừa được xem lúc

Kiến thức cơ bản về TDD( Test Driven Development )

1. Test Driven Development (TDD) là gì. TDD bắt đầu bằng việc thiết kế và viết test cho mọi chức năng nhỏ của ứng dụng. Theo cách tiếp cận TDD, đầu tiên là test sẽ được viết để validate đoạn code sẽ làm cái gì, làm đúng hay chưa.

0 0 23

- vừa được xem lúc

Viết test trong Rust the idiomatic way

Viết test trong Rust the idiomatic way. Chống chỉ định: cái tiêu đề đặt nữa tây nữa việt là cố ý, để câu view, chứ thực ra không phải tại mình không biết dịch chữ idiomatic ra đâu :v À nhân tiện nói l

0 0 27

- vừa được xem lúc

TDD qua ví dụ thực tế

TDD qua ví dụ thực tế. TDD (Test Driven Development) - tức là một phương pháp lập trình chú trọng vào việc test, "viết test trước viết code sau",.

0 0 35

- vừa được xem lúc

[Android][Testing] Thực chiến Testing Phần 1: Testing có thực sự quan trọng với Dev ?

Xin chào các bạn đã quay trở lại với bài chia sẻ của mình. 1. Testing là gì và tại sao phải thực hiện chúng . .

0 0 68

- vừa được xem lúc

NestJS Coding Practice: Viết tính năng check-in nhận reward với TDD và MongoDB Bucket Pattern - P1 - Cơ bản

Đây là bài viết nằm trong Series NestJS thực chiến, các bạn có thể xem toàn bộ bài viết ở link: https://viblo.asia/s/nestjs-thuc-chien-MkNLr3kaVgA.

0 0 9

- vừa được xem lúc

NestJS Coding Practice: Viết tính năng check-in nhận reward với TDD và MongoDB Bucket Pattern - P2 - Nâng cao

Đây là bài viết nằm trong Series NestJS thực chiến, các bạn có thể xem toàn bộ bài viết ở link: https://viblo.asia/s/nestjs-thuc-chien-MkNLr3kaVgA.

0 0 12