+2

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, 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. Hình ảnh thiết kế database 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: 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: Hình ảnh config database
  • Sau đó hãy mở tạo file .sequelizerc trong folder src và config nó cho sequelize-cli hiểu và sử dụng: Config .sequelizerc 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: Screenshot 2024-04-18 at 14.34.21.png Tương tự đối với file src/models/user.js: Screenshot 2024-04-18 at 20.49.17.png 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: Screenshot 2024-04-18 at 20.50.00.png 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é Screenshot 2024-04-18 at 14.43.57.png

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ả: Ảnh kết quả của run file example.js 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: 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ả: Screenshot 2024-04-18 at 12.34.53.png 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ả: Screenshot 2024-04-18 at 13.06.54.png

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


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.