Hướng dẫn viết Unit Test và Integration Test đảm bảo chất lượng code
Unit test kiểm tra một đơn vị logic nhỏ (một hàm, một lớp) tách khỏi thế giới bên ngoài, còn integration test kiểm tra các thành phần thật ghép với nhau như code với database, code với message broker hay hai service gọi nhau. Muốn đảm bảo chất lượng code, nhóm phát triển cần cả hai: unit test cho phản hồi nhanh khi sửa logic, integration test để bắt lỗi ở chỗ nối mà mock không bao giờ phát hiện được.
Bài này đi từ nguyên tắc chọn cái gì để test, cách viết một test dễ đọc, đến cách dựng môi trường integration test và đưa toàn bộ vào pipeline. Ví dụ dùng cú pháp gần với Jest (JavaScript) và pytest (Python), nhưng ý tưởng áp dụng được cho JUnit, Go testing hay xUnit.
Phân biệt rõ hai loại test trước khi viết
Nhiều đội gọi mọi thứ là "unit test", dẫn đến bộ test chạy chậm, phụ thuộc mạng và thỉnh thoảng đỏ không rõ lý do. Một cách phân loại thực dụng:
| Tiêu chí | Unit test | Integration test |
|---|---|---|
| Phạm vi | Một hàm, một lớp, một module logic | Nhiều thành phần thật: code, database, cache, queue, API |
| Phụ thuộc bên ngoài | Không, thay bằng giả lập | Có, chạy bản thật hoặc container |
| Tốc độ | Mili giây mỗi test | Từ vài trăm mili giây đến vài giây |
| Bắt được lỗi gì | Sai logic, sai nhánh điều kiện, sai tính toán | Sai câu SQL, sai mapping, sai cấu hình, sai hợp đồng API |
| Chạy khi nào | Mỗi lần lưu file, mỗi commit | Mỗi pull request, trước khi merge |
Mô hình "kim tự tháp test" khuyên có nhiều unit test ở đáy, ít integration test hơn ở giữa và một số ít test đầu cuối ở đỉnh. Với ứng dụng nặng về CRUD, nhiều đội thấy hình dạng "chiếc cúp" (nhiều integration test hơn) lại hợp lý hơn, vì phần lớn rủi ro nằm ở truy vấn dữ liệu chứ không nằm ở thuật toán. Không có tỷ lệ đúng cho mọi dự án; hãy nhìn xem lỗi production của bạn thường xuất hiện ở đâu.
Chọn đúng thứ cần unit test
Không phải dòng code nào cũng đáng có unit test riêng. Ưu tiên theo thứ tự:
- Logic nghiệp vụ có nhánh: tính phí vận chuyển, áp mã giảm giá, xét điều kiện duyệt đơn. Đây là nơi lỗi gây thiệt hại thật.
- Hàm chuyển đổi dữ liệu: parse ngày tháng, định dạng tiền tệ, chuẩn hóa số điện thoại.
- Các trường hợp biên: danh sách rỗng, giá trị âm, chuỗi có dấu tiếng Việt, múi giờ, năm nhuận.
- Lỗi đã từng xảy ra: mỗi bug sửa xong nên có một test tái hiện nó để không quay lại.
Những thứ ít giá trị khi unit test: getter/setter đơn giản, code chỉ chuyển tiếp lời gọi, cấu hình framework. Nếu kiến trúc tách lớp rõ ràng như hướng dẫn trong bài thiết kế kiến trúc Clean Architecture, phần lõi nghiệp vụ không phụ thuộc framework nên viết unit test rất dễ.
Cấu trúc một unit test dễ đọc
Mẫu phổ biến nhất là Arrange – Act – Assert (chuẩn bị, thực thi, kiểm tra). Mỗi test nên kiểm tra một hành vi và có tên mô tả hành vi đó.
Ví dụ với hàm tính phí giao hàng: tên test kiểu "miễn phí giao hàng khi đơn từ 500.000 đồng trở lên" tốt hơn nhiều so với "test1" hay "testCalculate". Khi test đỏ trên CI, người đọc biết ngay quy tắc nào bị vỡ mà không cần mở code.
- Arrange: tạo đơn hàng có tổng tiền 500.000 đồng, địa chỉ nội thành.
- Act: gọi hàm tính phí.
- Assert: kỳ vọng kết quả bằng 0.
Thêm một test cho 499.999 đồng để khóa ranh giới. Hai test nhỏ này đáng giá hơn một test lớn kiểm tra mười thứ cùng lúc, vì khi nó đỏ bạn không biết điều nào sai.
Một vài thói quen giúp test dễ bảo trì:
- Dùng hàm tạo dữ liệu mẫu (factory, builder) thay vì copy một object 30 trường vào mọi test.
- Không dùng logic phức tạp (vòng lặp, if) trong test; test phải "ngây thơ" để chính nó không có bug.
- Kiểm tra kết quả và hành vi quan sát được, không kiểm tra chi tiết nội bộ như biến private hay thứ tự gọi hàm không quan trọng.
- Cố định thời gian và số ngẫu nhiên bằng cách tiêm đồng hồ (clock) và seed thay vì gọi trực tiếp thời gian hệ thống.
Mock, stub và fake: dùng đúng liều
Giả lập giúp unit test chạy độc lập, nhưng lạm dụng sẽ tạo ra những test "xanh" trong khi code thật hỏng. Ba khái niệm hay bị dùng lẫn:
- Stub: trả về dữ liệu dựng sẵn, ví dụ repository luôn trả về một khách hàng mẫu.
- Mock: ghi lại lời gọi để kiểm tra, ví dụ xác nhận dịch vụ email được gọi đúng một lần với đúng địa chỉ.
- Fake: bản cài đặt đơn giản nhưng chạy thật, ví dụ repository lưu trong bộ nhớ bằng một map.
Nguyên tắc thực tế: chỉ giả lập những gì bạn không sở hữu hoặc chậm/đắt (cổng thanh toán, gửi SMS, API bên thứ ba). Đừng mock chính các lớp nội bộ của mình chỉ để test được; nếu một lớp khó test mà không mock năm phụ thuộc, đó là tín hiệu thiết kế cần tách nhỏ. Fake thường bền hơn mock vì test không gắn với cách gọi cụ thể.
Dựng integration test với hạ tầng thật
Integration test có giá trị nhất khi chạy với bản thật của database, cache hay broker, cùng phiên bản với production. Cách phổ biến hiện nay là dùng container: thư viện Testcontainers (có bản cho Java, Go, Node.js, Python, .NET) khởi động PostgreSQL, Redis, Kafka trong Docker khi test bắt đầu và dọn dẹp khi kết thúc.
Quy trình gợi ý cho một service có database:
- Khởi động container database một lần cho cả bộ test để tiết kiệm thời gian.
- Chạy migration y hệt production để schema đồng nhất. Một schema chuẩn chỉnh như trong bài thiết kế database schema cho hệ thống lớn sẽ giúp phát hiện sớm lỗi ràng buộc khóa ngoại hay unique.
- Mỗi test tự tạo dữ liệu cần thiết, chạy trong transaction rồi rollback, hoặc xóa bảng sau mỗi test.
- Gọi qua lớp ngoài cùng của service (HTTP handler hoặc use case) và kiểm tra kết quả trong database.
Tránh dùng database nhúng khác loại (ví dụ SQLite thay cho PostgreSQL) cho integration test: khác biệt về kiểu dữ liệu, hàm ngày giờ và cách xử lý khóa sẽ che mất đúng những lỗi bạn cần bắt.
Test hợp đồng giữa các service
Khi hệ thống có nhiều service, việc dựng toàn bộ để test đầu cuối rất tốn kém. Contract testing (ví dụ công cụ Pact) cho phép bên gọi ghi lại kỳ vọng về request/response, và bên cung cấp chạy kiểm tra rằng mình vẫn đáp ứng các kỳ vọng đó. Cách này phát hiện việc đổi tên trường hay đổi kiểu dữ liệu trước khi deploy.
Đưa test vào quy trình hằng ngày
Test chỉ có giá trị khi được chạy thường xuyên và kết quả được tôn trọng. Một số thiết lập nên có:
- Unit test chạy ở máy dev bằng chế độ watch và chạy lại trên CI ở mọi commit.
- Integration test chạy trên mọi pull request; nhánh chính bị chặn merge nếu test đỏ.
- Báo cáo coverage để thấy vùng chưa được test, nhưng không ép một con số cứng. Coverage cao không đồng nghĩa test tốt; một test không có assert vẫn tăng coverage.
- Theo dõi test "chập chờn" (flaky): test lúc xanh lúc đỏ phải được sửa hoặc cách ly ngay, nếu không cả đội sẽ quen bấm chạy lại và bỏ qua cảnh báo thật.
Trong quy trình Scrum hay Kanban, nên đưa "có test cho thay đổi" vào Definition of Done thay vì để cuối sprint mới bổ sung. Bài so sánh Agile, Scrum và Kanban có nói thêm cách đội gắn tiêu chí chất lượng vào luồng công việc.
Những dấu hiệu bộ test đang có vấn đề
Hãy xem lại chiến lược test khi gặp các tình huống sau:
- Đổi tên một hàm nội bộ làm đỏ hàng chục test: test đang gắn vào chi tiết cài đặt thay vì hành vi.
- Bộ test chạy quá lâu nên không ai chạy ở máy: tách unit và integration thành hai lệnh, chạy song song, dùng lại container.
- Test xanh nhưng production vẫn lỗi ở cùng chỗ: mock đã che mất hành vi thật; bổ sung integration test cho đường đi đó.
- Không ai dám xóa test cũ: test không còn mô tả yêu cầu nào thì nên xóa, giống như xóa code chết.
Bắt đầu với dự án chưa có test
Với codebase cũ không có test, đừng đặt mục tiêu phủ toàn bộ. Cách tiếp cận an toàn:
- Viết vài integration test bao quanh các luồng quan trọng nhất (đăng nhập, đặt hàng, thanh toán) để có "lưới an toàn" ở mức hành vi.
- Mỗi khi sửa bug hoặc thêm tính năng, viết test cho phần chạm tới.
- Khi cần refactor đoạn code rối, viết characterization test ghi lại hành vi hiện tại trước, rồi mới tách nhỏ.
- Dần dần tách logic nghiệp vụ ra khỏi controller, ORM để unit test được.
Sau vài tháng, vùng code hay thay đổi nhất sẽ tự nhiên có test dày nhất, đúng nơi cần bảo vệ nhất.
Câu hỏi thường gặp
Nên viết test trước hay sau khi viết code?
Viết trước (TDD) giúp thiết kế API gọn và đảm bảo mọi hành vi đều có test, nhưng không bắt buộc. Điều quan trọng là test được viết cùng thay đổi, trong cùng pull request, chứ không để dồn về sau.
Coverage bao nhiêu phần trăm là đủ?
Không có con số chung. Hãy dùng coverage để tìm vùng quan trọng chưa được test, ví dụ module thanh toán, thay vì chạy theo một chỉ tiêu cứng khiến đội viết test hình thức.
Integration test có cần kết nối API bên thứ ba thật không?
Thường là không. Dùng sandbox của nhà cung cấp hoặc một server giả lập (như WireMock) trả về phản hồi mẫu. Có thể giữ vài test chạy định kỳ với sandbox thật để phát hiện khi nhà cung cấp đổi API.
Test end-to-end qua giao diện có thay được integration test không?
Không nên. Test giao diện chậm, dễ chập chờn và khó chỉ ra lỗi nằm ở đâu. Hãy dùng chúng cho vài luồng chính, phần lớn kiểm tra nên nằm ở tầng unit và integration.
Bài liên quan

So sánh Agile, Scrum và Kanban: chọn cách làm nào cho đội phần mềm
Agile là triết lý, Scrum và Kanban là hai cách thực hành. Bài so sánh vai trò, nhịp làm việc, chỉ số đo lường và gợi ý chọn mô hình theo loại dự án.

So sánh message queue: RabbitMQ, Kafka và Redis Pub/Sub
RabbitMQ, Kafka hay Redis Pub/Sub? So sánh mô hình lưu trữ, đảm bảo giao nhận, thứ tự tin nhắn, vận hành và tình huống nên dùng từng giải pháp.
So sánh nền tảng CMS WordPress, Strapi, Directus cho doanh nghiệp
WordPress, Strapi hay Directus? So sánh kiến trúc, đối tượng biên tập, khả năng mở rộng, bảo mật, chi phí vận hành để doanh nghiệp chọn CMS phù hợp.
So sánh ngôn ngữ backend Java, Python, Go, Node.js năm 2026
Java, Python, Go hay Node.js cho backend? So sánh hiệu năng, mô hình xử lý đồng thời, hệ sinh thái, tuyển dụng tại Việt Nam và loại dự án hợp với từng ngôn ngữ.