Bản Refactor trị giá 12.000 USD
Đó là lúc 2:14 sáng thứ Ba, điện thoại của tôi bắt đầu rung bần bật trên tủ đầu giường. Các cảnh báo từ Sentry tràn ngập kênh Slack như thể một con đê vừa vỡ. Một bản dọn dẹp “nhỏ” cho User Profile API đã được merge lúc 5 giờ chiều, và đến nửa đêm, frontend không thể hiển thị dashboard cho 40% người dùng. Thủ phạm là gì? Một lập trình viên đã đổi tên một trường duy nhất từ user_id thành userId để thỏa mãn một quy tắc linter.
Điều trớ trêu nhất là gì? Hệ thống CI/CD của chúng tôi thậm chí không hề “chớp mắt”. Mọi bài test đều vượt qua. Bởi vì chúng tôi chỉ kiểm tra res.status === 200 và xác nhận body là một object, chúng tôi đã hoàn toàn bỏ lỡ thay đổi gây lỗi (breaking change) đó. Việc viết assertion thủ công cho một đối tượng JSON có 50 trường là một công việc “hành xác”, vì vậy chúng tôi đã chọn cách đi tắt. Đêm đó, những lối tắt đó đã khiến chúng tôi mất bốn giờ ngừng hoạt động và rất nhiều khách hàng bực bội.
Snapshot testing thay đổi hoàn toàn cục diện này. Thay vì chọn lọc từng trường để kiểm tra, bạn chụp lại toàn bộ phản hồi API làm điểm tham chiếu. Nếu chỉ cần một ký tự thay đổi trong tương lai, bài test sẽ thất bại. Không còn cảnh phải viết expect(body.name).toBe(...) cho đến khi mỏi nhừ ngón tay nữa.
Thiết lập trong chưa đầy 5 phút
Để thực hiện theo, bạn chỉ cần môi trường Node.js tiêu chuẩn. Chúng ta sẽ sử dụng Vitest—nó nhanh hơn đáng kể so với Jest—và Supertest để mô phỏng lưu lượng truy cập HTTP.
1. Cài đặt các công cụ cần thiết
npm install vitest supertest express --save-dev
2. Xây dựng một Endpoint giả lập (Mock)
Hãy tạo một file app.js. File này mô phỏng một endpoint thực tế trả về dữ liệu người dùng lồng nhau.
// app.js
import express from 'express';
const app = express();
app.get('/api/user/:id', (req, res) => {
res.status(200).json({
id: req.params.id,
username: 'johndoe',
email: '[email protected]',
role: 'admin',
metadata: {
lastLogin: '2023-10-01T10:00:00Z',
preferences: { theme: 'dark', notifications: true }
}
});
});
export default app;
3. Tạo Snapshot đầu tiên
Bây giờ, hãy tạo app.test.js. Thay vì assert từng key, chúng ta sử dụng toMatchSnapshot() để khóa toàn bộ cấu trúc lại.
// app.test.js
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import app from './app';
describe('GET /api/user/:id', () => {
it('khớp với schema người dùng đã lưu', async () => {
const response = await request(app).get('/api/user/123');
expect(response.status).toBe(200);
// Dòng này thay thế cho hàng chục bước kiểm tra thủ công
expect(response.body).toMatchSnapshot();
});
});
Chạy các bài test của bạn với npx vitest. Trong lần chạy đầu tiên, Vitest tạo một thư mục __snapshots__ chứa phản hồi JSON. Mọi lần chạy sau đó sẽ so sánh đầu ra API thực tế với “tiêu chuẩn vàng” đã lưu trữ này.
Tại sao Snapshot lại vượt trội hơn Assertion thủ công
Các assertion truyền thống rất tốt cho logic nghiệp vụ, nhưng chúng cực kỳ mong manh đối với cấu trúc dữ liệu. Nếu API của bạn trả về một object lồng nhau với 20 trường, file test của bạn thường trông giống như một danh sách đi chợ:
expect(res.body.username).toBe('johndoe');
expect(res.body.role).toBe('admin');
expect(res.body.metadata.preferences.theme).toBe('dark');
// ... và cứ thế tiếp tục trong 30 dòng nữa
Cách tiếp cận này rất nguy hiểm. Nếu bạn thêm một trường mới, bạn có thể sẽ quên cập nhật bài test. Nếu bạn xóa một trường, bài test vẫn có thể vượt qua vì bạn không cụ thể tìm kiếm nó. Snapshot testing coi toàn bộ phản hồi là một hợp đồng duy nhất, bất biến.
Khi bạn chạy toMatchSnapshot(), Vitest thực hiện kiểm tra so sánh sâu (deep equality check). Nếu bạn thay đổi username thành user_name trong mã nguồn, Vitest sẽ báo lỗi và hiển thị sự khác biệt (diff) được đánh dấu bằng màu sắc trong terminal của bạn. Nó buộc bạn phải thấy chính xác những gì đã thay đổi trước khi bạn merge code.
Xử lý các “Thành phần biến động” (Dữ liệu động)
Các API thực tế không tĩnh. Chúng trả về các ID tự động tăng, các UUID ngẫu nhiên và các dấu thời gian ISO. Nếu snapshot của bạn mong đợi "lastLogin": "2023-10-01..." và API trả về ngày hôm nay, bài test sẽ thất bại mọi lúc.
Chúng ta giải quyết vấn đề này bằng Property Matchers. Điều này nói với Vitest: “Hãy đảm bảo trường này tồn tại và là một chuỗi, nhưng đừng bận tâm về giá trị cụ thể của nó.”
it('bỏ qua các dấu thời gian biến động', async () => {
const response = await request(app).get('/api/user/123');
expect(response.body).toMatchSnapshot({
metadata: {
lastLogin: expect.any(String) // Bài test giờ đây sẽ bỏ qua giá trị ngày cụ thể
}
});
});
Điều này mang lại sự cân bằng hoàn hảo. Bạn có được sự xác thực cấu trúc nghiêm ngặt mà không phải đau đầu vì các bài test thất bại mỗi khi đồng hồ nhảy số.
Điều gì xảy ra khi các thay đổi là có chủ đích?
Đôi khi bạn thực sự muốn thay đổi API. Nếu bạn đã chủ ý đổi tên một trường, bạn không cần phải viết lại các bài test của mình. Chỉ cần chạy:
npx vitest -u
Flag -u (update) sẽ ghi đè các snapshot cũ bằng dữ liệu mới. Tôi luôn khuyên bạn nên xem lại Git diff của các file snapshot trước khi commit để đảm bảo không có thay đổi ngoài ý muốn nào lọt vào.
Những lời khuyên “xương máu” từ thực tế
Snapshot testing rất mạnh mẽ, nhưng rất dễ lạm dụng. Sau khi quản lý hàng tá microservices, tôi đã rút ra những quy tắc sau để giữ cho bộ test luôn dễ bảo trì:
- Tránh “Hội chứng mệt mỏi vì Snapshot”: Đừng snapshot một health check chỉ trả về
{"status": "ok"}. Sử dụng các assertion đơn giản for những thứ đơn giản. Hãy dành snapshot cho các cấu trúc dữ liệu phức tạp. - Giữ chúng nhỏ gọn: Nếu một endpoint trả về 500 bản ghi, đừng snapshot toàn bộ mảng đó. Hãy snapshot mục đầu tiên và metadata phân trang. Các file snapshot khổng lồ là không thể xem xét nổi trong các Pull Request.
- Coi snapshot như tài liệu: Sử dụng tên bài test rõ ràng như
'GET /orders nên trả về hóa đơn chi tiết'. Điều này giúp file snapshot đóng vai trò như một đặc tả API sống cho nhóm của bạn.
Việc chuyển hướng khỏi các assertion thủ công đã giúp nhóm của tôi tiết kiệm hàng trăm giờ bảo trì. Chúng tôi phát hiện ra các breaking change ngay khi chúng xảy ra, và các bài test cuối cùng cũng phản ánh đúng thực tế dữ liệu. Không còn những cuộc gọi đánh thức lúc 2 giờ sáng vì thiếu các trường JSON nữa.

