Bài 20.4: Snapshot testing trong TypeScript

Bài 20.4: Snapshot testing trong TypeScript
Chào mừng bạn đến với một bài viết quan trọng trong hành trình xây dựng ứng dụng Front-end mạnh mẽ và ổn định của chúng ta! Hôm nay, chúng ta sẽ cùng nhau "chụp ảnh" mã nguồn của mình theo một cách đặc biệt, đó chính là Snapshot Testing. Đặc biệt, chúng ta sẽ khám phá sức mạnh của nó khi kết hợp với ngôn ngữ TypeScript mà chúng ta đang sử dụng.
Trong thế giới phát triển Front-end, đặc biệt là với các framework component-based như React, Angular hay Vue, việc đảm bảo giao diện người dùng (UI) luôn nhất quán và không bị thay đổi ngoài ý muốn là một thách thức lớn. Một thay đổi nhỏ trong code của một component có thể vô tình ảnh hưởng đến cấu trúc hiển thị của nó, dẫn đến lỗi UI khó phát hiện. Đây chính là lúc Snapshot Testing tỏa sáng!
Snapshot Testing là gì?
Đơn giản mà nói, Snapshot Testing là một kỹ thuật kiểm thử mà ở lần chạy đầu tiên, nó sẽ lưu lại "ảnh chụp" (snapshot) về output của một đoạn code cụ thể (ví dụ: cấu trúc HTML của một component, một đối tượng JSON phức tạp, một cấu trúc dữ liệu). Lần chạy kế tiếp, bài test sẽ so sánh output hiện tại với ảnh chụp đã lưu.
Nếu output hiện tại khác với ảnh chụp, bài test sẽ thất bại. Điều này cảnh báo cho bạn biết rằng có điều gì đó đã thay đổi trong output của đoạn code đó. Sự thay đổi này có thể là có chủ ý (bạn đã sửa đổi component theo thiết kế mới) hoặc ngoài ý muốn (một bug đã làm thay đổi cấu trúc).
Công cụ phổ biến nhất hỗ trợ Snapshot Testing, đặc biệt là trong hệ sinh thái JavaScript/TypeScript, chính là Jest.
Cơ chế hoạt động: "Chụp ảnh" và So sánh
Khi bạn chạy một bài test sử dụng snapshot lần đầu tiên, Jest sẽ:
- Thực thi đoạn code được test (ví dụ: render một React component).
- Serialize output của đoạn code đó thành một chuỗi (thường là chuỗi định dạng dễ đọc như cấu trúc HTML, JSON, v.v.).
- Lưu chuỗi này vào một file có đuôi
.snap
(ví dụ:MyComponent.test.tsx.snap
) nằm trong thư mục__snapshots__
cùng cấp với file test. Đây chính là "ảnh chụp" đầu tiên của bạn.
// Lần chạy đầu tiên
jest
(Kết quả: Jest tạo file .snap)
Ở những lần chạy tiếp theo:
- Jest lại thực thi đoạn code được test.
- Serialize output hiện tại.
- So sánh chuỗi output hiện tại với chuỗi đã lưu trong file
.snap
.
// Các lần chạy sau
jest
(Kết quả: Jest so sánh output hiện tại với .snap)
- Nếu giống nhau: Bài test thành công.
- Nếu khác nhau: Bài test thất bại. Jest sẽ hiển thị sự khác biệt (diff) giữa output mới và snapshot cũ, giúp bạn dễ dàng nhận ra thay đổi.
Tại sao Snapshot Testing là "người bảo vệ thầm lặng" cho UI của bạn (đặc biệt với TypeScript)?
Khi làm việc với các ứng dụng Front-end phức tạp bằng TypeScript, Snapshot Testing mang lại nhiều lợi ích:
- Phát hiện thay đổi UI ngoài ý muốn: Đây là ưu điểm lớn nhất. Một thay đổi nhỏ trong logic render của component TypeScript có thể làm thay đổi cấu trúc HTML. Snapshot test sẽ ngay lập tức báo động, giúp bạn phát hiện bug UI trước khi người dùng nhìn thấy.
- Đơn giản và nhanh chóng: Viết snapshot test thường đơn giản hơn so với việc viết các assertion cụ thể cho từng phần tử trong UI (ví dụ:
expect(getByText('...')).toBeInTheDocument()
). Bạn chỉ cần render component và gọitoMatchSnapshot()
. - Bắt trọn cấu trúc đầy đủ: Snapshot test lưu lại toàn bộ cấu trúc serialized của output, không bỏ sót phần nào. Điều này hữu ích khi bạn muốn đảm bảo toàn bộ template hoặc cấu trúc dữ liệu không bị thay đổi đột ngột.
- Tự tin refactor code: Khi bạn refactor (tái cấu trúc) component TypeScript của mình mà không muốn thay đổi hành vi hoặc output, snapshot test là một "lưới an toàn" tuyệt vời. Nếu test vượt qua sau khi refactor, bạn có thể tự tin rằng cấu trúc output vẫn giữ nguyên.
- Hỗ trợ TypeScript: Mặc dù bản thân snapshot testing là một kỹ thuật độc lập, việc sử dụng nó trong dự án TypeScript rất phổ biến. Bạn test code TypeScript của mình (components, hàm xử lý dữ liệu) và Jest (kết hợp với các loader như
ts-jest
) sẽ xử lý phần serialization. Kiểu dữ liệu mạnh mẽ của TypeScript giúp bạn định nghĩa rõ ràng input/output, làm cho việc test và hiểu snapshot trở nên dễ dàng hơn.
Thiết lập cơ bản (với Jest và TypeScript)
Để sử dụng Snapshot Testing trong dự án TypeScript của bạn, bạn cần cài đặt:
- Jest: Framework test.
- ts-jest: Bộ tiền xử lý (preprocessor) giúp Jest hiểu code TypeScript.
- Thư viện render cho UI (nếu test component): Ví dụ:
@testing-library/react
hoặc Enzyme cho React.
npm install --save-dev jest ts-jest @types/jest @testing-library/react @testing-library/jest-dom react react-dom @types/react
# hoặc yarn add --dev ...
Bạn cũng cần một file cấu hình Jest (thường là jest.config.js
hoặc jest.config.ts
) để chỉ định Jest sử dụng ts-jest
cho các file .ts
và .tsx
.
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom', // Hoặc 'node' tùy vào bạn test gì
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], // Nếu dùng jest-dom
};
Viết Snapshot Test: Ví dụ Minh Họa
Hãy xem một vài ví dụ để hiểu rõ hơn cách viết snapshot test trong TypeScript.
Ví dụ 1: Test một hàm xử lý dữ liệu phức tạp
Snapshot testing không chỉ dành riêng cho UI. Bạn có thể dùng nó để kiểm tra cấu trúc của các đối tượng hoặc mảng dữ liệu được tạo ra bởi các hàm phức tạp.
Giả sử bạn có một hàm TypeScript xử lý mảng dữ liệu:
// src/utils.ts
interface Product {
id: number;
name: string;
price: number;
tags: string[];
}
export function formatProducts(products: Product[]): { id: number; display: string; }[] {
return products
.filter(p => p.price > 50)
.map(p => ({
id: p.id,
display: `${p.name} (${p.tags.join(', ')})`,
}))
.sort((a, b) => a.display.localeCompare(b.display));
}
Đây là bài test sử dụng snapshot cho hàm này:
// src/utils.test.ts
import { formatProducts } from './utils';
describe('formatProducts', () => {
it('nên format các sản phẩm chính xác', () => {
const initialProducts = [
{ id: 1, name: 'Laptop', price: 1200, tags: ['electronics', 'computer'] },
{ id: 2, name: 'Mouse', price: 25, tags: ['electronics'] },
{ id: 3, name: 'Keyboard', price: 75, tags: ['electronics'] },
{ id: 4, name: 'Monitor', price: 300, tags: ['electronics', 'display'] },
];
const formatted = formatProducts(initialProducts);
// Sử dụng toMatchSnapshot() để kiểm tra cấu trúc và nội dung của mảng kết quả
expect(formatted).toMatchSnapshot();
});
});
Giải thích:
- Chúng ta import hàm
formatProducts
. - Ta tạo một mảng dữ liệu đầu vào.
- Gọi hàm và lưu kết quả vào biến
formatted
. expect(formatted).toMatchSnapshot();
Đây là câu lệnh thần kỳ. Lần đầu chạy test này, Jest sẽ serialize mảngformatted
và lưu vào file.snap
. Các lần sau, nó sẽ kiểm tra xem mảng kết quả có cấu trúc và nội dung giống hệt bản lưu trong snapshot không.- Nếu bạn vô tình thay đổi logic filter, map hoặc sort, snapshot test sẽ thất bại, báo cho bạn biết output đã thay đổi.
Ví dụ 2: Test một React Component
Đây là trường hợp sử dụng phổ biến nhất của Snapshot Testing.
Giả sử bạn có một component nút bấm đơn giản bằng TypeScript/React:
// src/components/Button.tsx
import React from 'react';
interface ButtonProps {
label: string;
onClick?: () => void;
disabled?: boolean;
}
const Button: React.FC<ButtonProps> = ({ label, onClick, disabled }) => {
return (
<button
onClick={onClick}
disabled={disabled}
style={{ padding: '10px', borderRadius: '5px' }} // Ví dụ style inline
>
{label}
</button>
);
};
export default Button;
Đây là bài test sử dụng snapshot cho component này:
// src/components/Button.test.tsx
import React from 'react';
import { render } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
it('renders correctly', () => {
const { asFragment } = render(<Button label="Click Me" />);
// asFragment() trả về một representation của DOM có thể serialize
expect(asFragment()).toMatchSnapshot();
});
it('renders correctly when disabled', () => {
const { asFragment } = render(<Button label="Disabled Button" disabled />);
expect(asFragment()).toMatchSnapshot();
});
// Bạn có thể thêm các test case khác cho các prop khác nhau
// để đảm bảo mọi trạng thái render đúng
});
Giải thích:
- Chúng ta import
render
từ@testing-library/react
(hoặc thư viện tương tự) và componentButton
. - Trong mỗi
it
block, chúng ta render component với các props khác nhau (ví dụ: có hoặc không códisabled
). render(<Button ... />)
sẽ tạo ra cấu trúc DOM ảo của component.asFragment()
cung cấp một "bản sao" của cấu trúc DOM này dưới dạng mà Jest có thể dễ dàng serialize.expect(asFragment()).toMatchSnapshot();
so sánh cấu trúc HTML được tạo ra với snapshot đã lưu.
Khi chạy lần đầu, Jest sẽ tạo các file .snap
chứa cấu trúc HTML tương ứng với từng test case. Ví dụ, file .snap
cho test case đầu tiên có thể trông giống thế này (đã rút gọn):
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Button renders correctly 1`] = `
<DocumentFragment>
<button
style="padding: 10px; border-radius: 5px;"
>
Click Me
</button>
</DocumentFragment>
`;
exports[`Button renders correctly when disabled 1`] = `
<DocumentFragment>
<button
disabled=""
style="padding: 10px; border-radius: 5px;"
>
Disabled Button
</button>
</DocumentFragment>
`;
(Lưu ý: Cấu trúc file .snap có thể hơi khác tùy thuộc vào version Jest và thư viện render bạn dùng, nhưng ý tưởng là serialize output thành chuỗi)
Nếu sau này bạn vô tình thay đổi tag button
thành div
, hoặc sửa đổi structure bên trong, snapshot test sẽ thất bại, báo hiệu rằng UI đã thay đổi.
Cập nhật Snapshot
Nếu bạn cố ý thay đổi component hoặc logic (ví dụ: thêm một class CSS, thay đổi thứ tự các phần tử), đương nhiên snapshot test sẽ thất bại vì output mới khác với snapshot cũ. Trong trường hợp này, bạn cần cập nhật (update) snapshot.
Bạn có thể làm điều này bằng cách chạy lệnh test với flag -u
:
jest -u
# hoặc
npm test -- -u
# hoặc
yarn test -u
Lệnh này sẽ chạy lại các bài test, và với những test toMatchSnapshot()
bị lỗi, Jest sẽ ghi đè file .snap
cũ bằng output mới.
Lưu ý quan trọng: Hãy luôn luôn kiểm tra (review) sự khác biệt (diff) mà Jest hiển thị trước khi quyết định cập nhật snapshot. Đảm bảo sự thay đổi đó là có chủ ý và chính xác. Cập nhật snapshot một cách mù quáng sẽ làm mất đi ý nghĩa của Snapshot Testing, vì bạn đang chấp nhận bất kỳ sự thay đổi nào!
Khi nào nên và không nên dùng Snapshot Testing?
Snapshot Testing là một công cụ mạnh mẽ, nhưng nó không phải là viên đạn bạc cho mọi vấn đề testing.
Nên dùng khi:
- Test các React/Vue/Angular component để đảm bảo cấu trúc HTML render không bị thay đổi ngoài ý muốn.
- Test output của các hàm xử lý dữ liệu phức tạp trả về các đối tượng hoặc mảng có cấu trúc cụ thể.
- Bạn muốn có một "lưới an toàn" nhanh chóng để phát hiện các regression bug (lỗi tái phát) liên quan đến cấu trúc output.
Không nên dùng khi:
- Test hành vi người dùng: Snapshot testing không kiểm tra cách component phản ứng với tương tác (click, nhập liệu), các sự kiện bất đồng bộ, hay các logic phức tạp dựa trên trạng thái. Đối với những thứ này, bạn cần Unit Tests và Integration Tests truyền thống (sử dụng
@testing-library/user-event
hoặc tương tự). - Test chi tiết từng phần tử: Nếu bạn cần kiểm tra một thuộc tính cụ thể của một phần tử (ví dụ:
expect(button).toHaveAttribute('disabled')
), Unit Test với các assertion chi tiết sẽ rõ ràng hơn và dễ đọc hơn snapshot. Snapshot lưu toàn bộ cấu trúc, có thể khó tìm kiếm một chi tiết cụ thể. - Snapshot quá lớn và khó đọc: Nếu file
.snap
của bạn trở nên quá lớn do component phức tạp, việc review sự khác biệt sẽ rất khó khăn, làm giảm hiệu quả của kỹ thuật này. Cố gắng giữ component và snapshot của bạn tập trung vào một trách nhiệm cụ thể. - Thay thế hoàn toàn các loại test khác: Snapshot Testing là bổ sung, không phải thay thế Unit Tests, Integration Tests hay End-to-End Tests. Một chiến lược test toàn diện cần kết hợp nhiều phương pháp.
Comments