Bài 33.1: Unit testing với Jest trong TypeScript

Chào mừng bạn trở lại với chuỗi bài viết về Lập trình Web Front-end! Hôm nay, chúng ta sẽ cùng nhau đi sâu vào một khía cạnh cực kỳ quan trọng giúp nâng cao chất lượng mã nguồn của bạn: Unit Testing với Jest trong môi trường TypeScript.

Tại sao Unit Testing lại quan trọng?

Trong thế giới phát triển phần mềm đầy biến động, việc đảm bảo mã nguồn hoạt động đúng như mong đợi là điều thiết yếu. Unit Testing, hay kiểm thử đơn vị, là phương pháp kiểm tra các phần nhỏ nhất có thể tách biệt của ứng dụng (các "đơn vị" như hàm, phương thức, lớp) một cách độc lập.

  • Bắt lỗi sớm: Unit test giúp phát hiện lỗi ngay từ giai đoạn phát triển, trước khi chúng kịp lan rộng và gây khó khăn cho việc sửa chữa sau này.
  • Tự tin khi Refactor: Khi có một bộ unit test vững chắc, bạn có thể tự tin hơn khi thay đổi cấu trúc mã (refactor) mà không sợ làm hỏng chức năng hiện có.
  • Tài liệu sống: Các bài test thường mô tả cách các "đơn vị" mã hoạt động trong các trường hợp khác nhau, đóng vai trò như một dạng tài liệu hữu ích.
  • Thiết kế tốt hơn: Việc viết test trước khi viết mã (Test-Driven Development - TDD) hoặc song song với việc viết mã thường dẫn đến thiết kế mô-đun và dễ kiểm thử hơn.
Tại sao lại là Jest và TypeScript?
  • Jest: Là một framework kiểm thử JavaScript được phát triển bởi Facebook (nay là Meta), rất phổ biến trong cộng đồng React, nhưng có thể dùng để test hầu hết các ứng dụng JavaScript/TypeScript. Điểm mạnh của Jest là tốc độ, cấu hình đơn giản và đi kèm đầy đủ các công cụ cần thiết (runner, assertion library, mocking).
  • TypeScript: Mang lại sự an toàn về kiểu dữ liệu. Khi kết hợp với Jest, bạn có thể viết các bài test mạnh mẽ, tận dụng lợi thế của kiểu dữ liệu để bắt lỗi ngay cả trong mã test của mình.

Sự kết hợp giữa Jest (mạnh mẽ, nhanh chóng) và TypeScript (an toàn kiểu dữ liệu) tạo nên một bộ đôi hoàn hảo để xây dựng các bài unit test đáng tin cậy cho ứng dụng Frontend của bạn.

Bắt đầu với Jest và TypeScript

Để bắt đầu, bạn cần cài đặt các thư viện cần thiết. Giả sử bạn đã có một dự án TypeScript:

npm install --save-dev jest ts-jest @types/jest
# Hoặc với yarn:
# yarn add --dev jest ts-jest @types/jest
  • jest: Core framework của Jest.
  • ts-jest: Bộ chuyển đổi giúp Jest hiểu và chạy mã TypeScript.
  • @types/jest: Định nghĩa kiểu TypeScript cho Jest, giúp bạn có gợi ý mã và kiểm tra kiểu trong file test.

Tiếp theo, bạn cần cấu hình Jest để làm việc với ts-jest. Cách đơn giản nhất là thêm cấu hình vào file package.json:

{
  // ... các cấu nh khác của project
  "scripts": {
    "test": "jest"
  },
  "jest": {
    "preset": "ts-jest"
  }
}

Hoặc tạo file jest.config.js ở thư mục gốc của dự án:

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node', // Hoặc 'jsdom' nếu bạn test mã liên quan đến DOM
};

Bây giờ, bạn đã sẵn sàng viết bài test đầu tiên!

Các Khái niệm Cốt lõi: describe, it (hoặc test), và expect

Bộ ba này là nền tảng của mọi bài test trong Jest:

  1. describe(name, fn): Dùng để gom nhóm các bài test liên quan lại với nhau. name là tên nhóm test (thường là tên hàm, lớp, hoặc module bạn test), fn là một hàm chứa các bài test cụ thể.
  2. it(name, fn) hoặc test(name, fn): Định nghĩa một bài test cụ thể. name là mô tả bài test này kiểm tra điều gì, fn là hàm chứa logic kiểm tra thực tế.
  3. expect(value): Dùng để tạo ra một "assertion" (lời khẳng định). Bạn gọi expect với giá trị mà bạn muốn kiểm tra, sau đó sử dụng các "matcher" để so sánh giá trị đó với giá trị mong đợi.

Hãy xem một ví dụ đơn giản:

Giả sử bạn có một hàm TypeScript tính tổng hai số:

// src/math.ts
/**
 * Tính tổng hai số nguyên.
 * @param a Số hạng thứ nhất.
 * @param b Số hạng thứ hai.
 * @returns Tổng của a và b.
 */
export function add(a: number, b: number): number {
  return a + b;
}

Đây là cách bạn viết unit test cho hàm add sử dụng Jest và TypeScript:

// src/math.test.ts
import { add } from './math'; // Import hàm cần test

// Gom nhóm các test cho hàm 'add'
describe('add function', () => {

  // Bài test cụ thể: kiểm tra khi cộng 2 số dương
  it('should return the sum of two positive numbers', () => {
    // Khẳng định: mong đợi kết quả của add(2, 3) là 5
    expect(add(2, 3)).toBe(5);
  });

  // Bài test cụ thể: kiểm tra khi có số âm
  it('should handle negative numbers correctly', () => {
    expect(add(-1, 5)).toBe(4);
  });

  // Bài test cụ thể: kiểm tra khi có số 0
  it('should return the other number when adding zero', () => {
    expect(add(10, 0)).toBe(10);
  });
});
  • Giải thích:
    • Chúng ta import hàm add từ file math.ts.
    • describe('add function', ...) nhóm tất cả các bài test liên quan đến hàm add.
    • Mỗi khối it(...) là một trường hợp test riêng biệt (ví dụ: cộng số dương, cộng số âm). Tên của it nên mô tả rõ ràng điều gì đang được kiểm tra.
    • expect(add(2, 3)) tạo ra một đối tượng Expect cho giá trị add(2, 3).
    • .toBe(5) là một "matcher". Nó so sánh giá trị từ expect với giá trị 5 sử dụng so sánh nghiêm ngặt (===).
Các Matcher Thông dụng trong Jest

Jest cung cấp rất nhiều matcher để bạn so sánh các giá trị theo nhiều cách khác nhau. Dưới đây là một số matcher phổ biến:

  • .toBe(value): So sánh nghiêm ngặt (===). Tốt nhất cho các giá trị nguyên thủy (số, chuỗi, boolean, null, undefined).
  • .toEqual(value): So sánh đệ quy nội dung của các đối tượng hoặc mảng. Rất hữu ích khi test các đối tượng phức tạp.
  • .not: Phủ định matcher tiếp theo. Ví dụ: expect(value).not.toBe(expected).
  • .toBeNull(), .toBeUndefined(), .toBeDefined(): Kiểm tra giá trị là null, undefined hay được định nghĩa.
  • .toBeTruthy(), .toBeFalsy(): Kiểm tra giá trị theo ngữ cảnh boolean.
  • .toContain(item): Kiểm tra xem một item có tồn tại trong mảng hoặc chuỗi không.
  • .toHaveLength(number): Kiểm tra độ dài của mảng hoặc chuỗi.
  • .toMatch(regexp | string): Kiểm tra xem một chuỗi có khớp với biểu thức chính quy hoặc chuỗi con không.
  • .toThrow(error?): Kiểm tra xem một hàm có ném ra lỗi hay không.

Ví dụ về các Matcher khác:

Giả sử bạn có các dữ liệu và hàm sau:

// src/data.ts
export const users = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 3, name: 'Charlie' }
];

export function getUserById(id: number) {
  const user = users.find(u => u.id === id);
  if (!user) {
    throw new Error(`User with id ${id} not found.`);
  }
  return user;
}

Bài test sử dụng nhiều matcher khác nhau:

// src/data.test.ts
import { users, getUserById } from './data';

describe('data module', () => {

  it('users array should contain Alice', () => {
    // Kiểm tra xem mảng users có chứa một đối tượng có name là 'Alice' không
    expect(users).toEqual(expect.arrayContaining([
      expect.objectContaining({ name: 'Alice' })
    ]));
    // Hoặc đơn giản hơn nếu bạn chỉ cần kiểm tra tên:
    const userNames = users.map(u => u.name);
    expect(userNames).toContain('Alice');
  });

  it('users array should have 3 elements', () => {
    expect(users).toHaveLength(3);
  });

  it('getUserById(2) should return Bob', () => {
    const bob = getUserById(2);
    // Sử dụng toEqual để so sánh nội dung đối tượng
    expect(bob).toEqual({ id: 2, name: 'Bob' });
  });

  it('getUserById(99) should throw an error', () => {
    // Sử dụng toThrow để kiểm tra việc ném lỗi
    expect(() => getUserById(99)).toThrow('User with id 99 not found.');
    // Hoặc chỉ cần kiểm tra có ném lỗi hay không
    expect(() => getUserById(99)).toThrow();
  });
});
  • Giải thích:
    • expect.arrayContainingexpect.objectContaining giúp kiểm tra xem một mảng hoặc đối tượng có chứa các phần tử/thuộc tính mong muốn hay không, rất linh hoạt.
    • toHaveLength kiểm tra kích thước mảng/chuỗi.
    • toEqual được dùng để so sánh các đối tượng phức tạp hơn .toBe.
    • .toThrow được dùng để kiểm tra các trường hợp ngoại lệ. Lưu ý bạn cần bọc hàm gọi ném lỗi trong một hàm ẩn danh () => ... để Jest có thể bắt được lỗi.
Testing Asynchronous Code (Async/Await)

Trong Frontend, làm việc với các thao tác bất đồng bộ (như gọi API) là rất phổ biến. Jest hỗ trợ mạnh mẽ việc test mã bất đồng bộ, đặc biệt là với async/await.

Giả sử bạn có một hàm bất đồng bộ:

// src/api.ts
/**
 * Mô phỏng việc lấy dữ liệu từ API.
 * @returns Promise resolves with some data string.
 */
export async function fetchDataFromApi(): Promise<string> {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve('Successfully fetched data');
    }, 100); // Giả lập độ trễ mạng 100ms
  });
}

/**
 * Mô phỏng lỗi khi gọi API.
 * @returns Promise rejects with an error.
 */
export async function fetchErrorFromApi(): Promise<never> {
    return new Promise((_, reject) => {
      setTimeout(() => {
        reject(new Error('Failed to fetch data'));
      }, 50); // Giả lập lỗi sau 50ms
    });
  }

Bài test cho mã bất đồng bộ sử dụng async/await:

// src/api.test.ts
import { fetchDataFromApi, fetchErrorFromApi } from './api';

describe('Async API calls', () => {

  // Sử dụng async/await trong bài test
  it('fetchDataFromApi should resolve with data', async () => {
    // await promise để lấy giá trị resolved
    const data = await fetchDataFromApi();
    expect(data).toBe('Successfully fetched data');
  });

  // Kiểm tra Promise bị reject
  it('fetchErrorFromApi should reject with an error', async () => {
    // Sử dụng try...catch để bắt lỗi reject
    try {
      await fetchErrorFromApi();
      // Nếu await không ném lỗi (promise không bị reject), bài test sẽ thất bại ở đây
      // Bạn có thể thêm fail() hoặc expect(true).toBe(false) để làm rõ
      // fail('Expected fetchErrorFromApi to throw'); // Cần import fail từ '@jest/globals'
      expect(true).toBe(false); // Cách đơn giản để báo thất bại nếu không ném lỗi
    } catch (error) {
      // Kiểm tra lỗi bị bắt
      expect(error).toBeInstanceOf(Error);
      expect((error as Error).message).toBe('Failed to fetch data');
    }
  });

  // Cách khác kiểm tra Promise resolve/reject bằng matchers .resolves/.rejects
  it('fetchDataFromApi should resolve with data using .resolves', () => {
    // Trả về Promise để Jest chờ
    return expect(fetchDataFromApi()).resolves.toBe('Successfully fetched data');
  });

  it('fetchErrorFromApi should reject with an error using .rejects', () => {
    // Trả về Promise để Jest chờ
    return expect(fetchErrorFromApi()).rejects.toThrow('Failed to fetch data');
  });
});
  • Giải thích:
    • Bài test được đánh dấu async để có thể sử dụng await.
    • Khi test Promise resolve, bạn có thể await kết quả rồi kiểm tra, hoặc sử dụng matcher .resolves. Cách dùng .resolves thường ngắn gọn hơn.
    • Khi test Promise reject, bạn có thể dùng try...catch hoặc matcher .rejects. Matcher .rejects cũng thường ngắn gọn và rõ ràng hơn.
    • Quan trọng: Khi sử dụng .resolves hoặc .rejects, bạn phải return Promise đó từ bài test (it) để Jest biết phải đợi Promise đó hoàn thành.
Mocking Dependencies

Trong Unit Testing, mục tiêu là kiểm tra một đơn vị mã một cách độc lập. Điều này có nghĩa là bạn cần "giả lập" (mock) các phụ thuộc của đơn vị đó (ví dụ: gọi API khác, truy cập cơ sở dữ liệu, sử dụng hàm từ module khác). Jest có hệ thống mocking rất mạnh mẽ.

Cách đơn giản nhất để mock là sử dụng jest.fn(), tạo ra một hàm giả lập (mock function):

// src/service.ts
import { fetchDataFromApi } from './api'; // Giả sử fetchDataFromApi là phụ thuộc

export async function processUserData(userId: number): Promise<string> {
  // Giả sử hàm này cần gọi API để lấy dữ liệu
  const apiData = await fetchDataFromApi();
  return `Processed data for user ${userId}: ${apiData.toUpperCase()}`;
}

Bài test cho processUserData sử dụng mocking fetchDataFromApi:

// src/service.test.ts
import { processUserData } from './service';
import { fetchDataFromApi } from './api'; // Vẫn import để có thể mock

// Mock toàn bộ module ./api
jest.mock('./api');

// Ép kiểu cho hàm mock để có thể gọi các phương thức của Jest mock function
const mockFetchDataFromApi = fetchDataFromApi as jest.Mock;

describe('processUserData', () => {

  // Reset mock trước mỗi test để đảm bảo độc lập
  beforeEach(() => {
    mockFetchDataFromApi.mockClear(); // Xóa lịch sử gọi
  });

  it('should process user data correctly using mocked API data', async () => {
    // Cấu hình mock function để trả về một giá trị cụ thể cho test này
    // .mockResolvedValue là cho Promise resolve
    mockFetchDataFromApi.mockResolvedValue('mocked api response');

    const result = await processUserData(123);

    // Khẳng định:
    // 1. Hàm fetchDataFromApi đã được gọi 1 lần
    expect(mockFetchDataFromApi).toHaveBeenCalledTimes(1);
    // 2. Kết quả xử lý là đúng
    expect(result).toBe('Processed data for user 123: MOCKED API RESPONSE');
  });

  it('should handle API error when processing user data', async () => {
    // Cấu hình mock function để ném lỗi (Promise reject)
    mockFetchDataFromApi.mockRejectedValue(new Error('Mock API Error'));

    // Kiểm tra xem processUserData có ném lỗi khi API lỗi không
    await expect(processUserData(456)).rejects.toThrow('Mock API Error');
  });
});
  • Giải thích:
    • jest.mock('./api'); chỉ thị cho Jest tạo ra một mock cho module ./api. Mặc định, Jest sẽ tự động tạo ra các mock function cho tất cả các export của module đó.
    • Chúng ta ép kiểu fetchDataFromApi sang jest.Mock để TypeScript hiểu rằng đây là một mock function và có các phương thức như .mockResolvedValue, .mockRejectedValue, .toHaveBeenCalledTimes, v.v.
    • beforeEach(() => { mockFetchDataFromApi.mockClear(); }); đảm bảo rằng trạng thái của mock (như số lần gọi) được reset trước mỗi bài test, tránh ảnh hưởng lẫn nhau.
    • .mockResolvedValue('mocked api response'): Cấu hình mock function để khi được gọi, nó sẽ trả về một Promise resolve với giá trị 'mocked api response'.
    • .mockRejectedValue(new Error('Mock API Error')): Cấu hình mock function để khi được gọi, nó sẽ trả về một Promise reject với lỗi được cung cấp.
    • expect(mockFetchDataFromApi).toHaveBeenCalledTimes(1): Kiểm tra xem mock function có được gọi chính xác 1 lần trong quá trình chạy processUserData không. Đây là cách kiểm tra "hành vi" (behavior) của mã.

Mocking là một chủ đề rộng, nhưng ví dụ này cho thấy sức mạnh cơ bản của nó trong việc cô lập đơn vị cần test.

Tổ chức File Test

Thông thường, các file test được đặt cùng cấp hoặc trong thư mục __tests__ cạnh file mã nguồn mà chúng test, và có tên theo quy tắc ten_file.test.ts hoặc ten_file.spec.ts.

Ví dụ:

src/
├── components/
│   ├── Button.ts
│   ├── Button.test.ts // Test cho Button.ts
│   └── __tests__/   // Hoặc có thể dùng thư mục __tests__
│       └── Button.test.ts
├── utils/
│   ├── formatters.ts
│   └── formatters.test.ts // Test cho formatters.ts
└── index.ts

Cách tổ chức này giúp dễ dàng tìm thấy các bài test tương ứng với mã nguồn và Jest cũng tự động tìm kiếm các file theo quy tắc này.

Chạy Test

Sau khi viết các file test (ví dụ: src/math.test.ts), bạn chỉ cần mở terminal trong thư mục gốc của dự án và chạy lệnh:

npm test
# Hoặc
# yarn test

Jest sẽ tìm tất cả các file test (mặc định là .test.ts, .spec.ts hoặc các file trong thư mục __tests__), chạy chúng và báo cáo kết quả chi tiết (bao nhiêu test passed, failed, coverage...).

Bạn cũng có thể chạy một file test cụ thể:

npm test src/math.test.ts

Hoặc chạy test theo tên (sử dụng flag -t hoặc --testNamePattern):

npm test -t "should handle negative numbers correctly"

Comments

There are no comments at the moment.