Part 3: Làm thế nào để mock imported function trong Jest
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,routesthì 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ó configthêmcorscho dự án này =))) mình không định làm đâu, nhưng mà nhằm để mọi người khicloneproject về thì thửrunhoặctestAPI bằngpostman.
Intro
- Bạn gặp khó khăn trong việc mockcácexternal dependencytrong 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ủamockingcũ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:
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 srccủ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.jsvà sửa theo mình:![Hình ảnh config database]() 
- Sau đó hãy mở tạo file .sequelizerctrong foldersrcvà config nó chosequelize-clihiểu và sử dụng:![Config .sequelizerc]() Oke, vậy là đã Oke, vậy là đãsetupxong 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
Tương tự đối với file src/models/user.js:
 Sau đó các bạn hãy mở file
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:
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:
Giải thích:
- Dòng đầu tiên là undefined, nghĩa làmodulenày chưa đượcloadedvà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ỉ? 🤣
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 configchojest.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:
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 1functionluôn trả về giá trịtrue.
- Bởi vì module isEvenchỉexportsra1 defaultlà 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ậythì đó 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() mockmà 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ằngmockcũng được biết đến như làspy
- Các bạn có thể thấy rõ rằng ta đã dùng matcher toHaveBeenCalledWith,spytrongTest 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ểcontrolhành vi của hàm override sẽ thực thi như nào (đây chính là thể hiện củamocktrongTest 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 đã mocklạiexamplecủa filetarget.jsbằng cách kết hợp sử dụngjest.mockvàjest.fn(), nó sẽoverridelại y như cách mình đã giải thích trước đó.
- Ngoài ra ở functioncủa hàmjest.mockđang trả về1 objectvì có dấu()bao quanh{}, đây làcú pháp short hand bên js, bạn nào chưa từng codejsthì 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ỉ mockcácexternal dependencycủa 1 unit nào đó.
- Bản chất rằng các unitsẽ 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 mockquá nhiềustubthì đó 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 mockingcácexternal dependencyvớ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
All rights reserved
 
  
 

