Chương 56. Test để tự tin đổi hệ thống
Từ đầu sách đến giờ, ta đã nói rất nhiều về cách thiết kế và vận hành hệ thống:
API.
Database.
Queue.
Cache.
Microservices.
Observability.
Alerting.
Failure modes.
Backup và restore.
Nhưng còn một chuyện rất đời thường:
Ngày mai ta phải sửa code.
Thêm tính năng.
Sửa bug.
Đổi schema.
Tối ưu query.
Thay provider AI.
Tách service.
Đổi cách tính điểm.
Câu hỏi là:
Làm sao biết mình không làm hỏng thứ đang chạy?
Đó là vai trò của testing.
Testing không phải để có con số coverage đẹp.
Testing cũng không phải để chứng minh hệ thống không bao giờ lỗi.
Testing là để tăng mức tự tin khi thay đổi hệ thống.
Thông điệp chính của chương:
> Test tốt không phải là test nhiều nhất. Test tốt là test đúng rủi ro, đúng tầng, chạy đủ nhanh, dễ hiểu khi fail, và bảo vệ những hành vi quan trọng nhất của hệ thống.
---
56.1. Một tình huống: sửa cách chấm điểm
Hãy quay lại AI Judge.
Ban đầu hệ thống tính điểm như sau:
AI trả về score từ 0 đến 10.
Hệ thống lưu thẳng score đó.
Sau một thời gian, product muốn đổi:
Nếu bài nộp trễ, trừ 10%.
Nếu bài thiếu file bắt buộc, tối đa 5 điểm.
Nếu AI không chắc chắn, chuyển sang needs_review.
Nghe không quá lớn.
Nhưng khi sửa code, ta có thể làm hỏng nhiều thứ:
Bài đúng hạn bị trừ điểm nhầm.
Bài trễ bị trừ hai lần.
Điểm vượt quá 10.
Điểm âm.
needs_review không được set.
Feedback không khớp với điểm.
Job retry ghi đè kết quả đã duyệt thủ công.
Nếu không có test, mỗi lần sửa là mỗi lần cầu may.
Ta có thể mở UI, tạo vài bài mẫu, bấm thử.
Việc đó giúp một phần.
Nhưng không đủ.
Vì hệ thống có quá nhiều trường hợp nhỏ.
Test giúp ta giữ lại hiểu biết về các trường hợp đó trong code.
Khi ai đó sửa logic sau này, test sẽ nhắc:
Trường hợp này từng quan trọng.
Đừng làm hỏng nó.
---
56.2. Test là trí nhớ của hệ thống
Một cách dễ hiểu:
Test là trí nhớ có thể chạy lại.
Khi team phát hiện một bug, sửa bug xong mà không viết test, hệ thống chỉ dựa vào trí nhớ con người.
Vài tháng sau, người khác sửa code.
Bug cũ quay lại.
Không ai nhớ.
Nếu có test, bug cũ trở thành một hàng rào.
Ví dụ:
Bug: bài nộp trễ bị trừ điểm hai lần khi job retry.
Fix: lưu final_score idempotent theo grading_result_id.
Test: retry cùng job hai lần không trừ điểm hai lần.
Lần sau ai sửa logic chấm điểm, test này chạy lại.
Nếu bug quay lại, test fail.
Đây là giá trị thật của test.
Không phải "có test cho chuyên nghiệp".
Mà là:
Biến bài học đau thành cơ chế bảo vệ.
---
56.3. Test không chứng minh hệ thống đúng tuyệt đối
Một hiểu lầm phổ biến:
Có test nghĩa là hệ thống đúng.
Không đúng.
Test chỉ chứng minh:
Với những trường hợp ta đã nghĩ đến,
hệ thống đang hành xử như mong đợi.
Nếu ta quên một trường hợp, test không cứu được.
Nếu expectation trong test sai, test còn làm ta tự tin sai.
Ví dụ:
Test kiểm tra bài trễ bị trừ 5%.
Nhưng rule thật là trừ 10%.
Test pass.
Nhưng hệ thống vẫn sai nghiệp vụ.
Vì vậy test tốt cần hiểu domain.
Không chỉ hiểu code.
Với AI Judge, test tốt phải biết:
Thế nào là bài hợp lệ.
Thế nào là nộp trễ.
Khi nào được retry.
Khi nào cần needs_review.
Khi nào không được ghi đè điểm thủ công.
Testing không thay thế tư duy.
Nó lưu lại tư duy đã rõ.
---
56.4. Ba tầng test quen thuộc
Khi nói về test, ta thường gặp ba tầng:
Unit test.
Integration test.
End-to-end test.
Ba tầng này không phải để học thuộc định nghĩa.
Hãy hiểu chúng bằng câu hỏi:
Ta đang kiểm tra một mảnh nhỏ, vài mảnh nối với nhau, hay cả luồng người dùng?
Unit test kiểm tra một đơn vị nhỏ.
Ví dụ:
Hàm tính final_score.
Hàm kiểm tra submission có trễ không.
Hàm parse response từ AI.
Hàm quyết định retry hay fail.
Integration test kiểm tra nhiều phần tích hợp với nhau.
Ví dụ:
API tạo submission và ghi database.
Worker lấy job, gọi fake AI provider, ghi result.
Service đọc database và object storage cùng nhau.
End-to-end test kiểm tra luồng gần giống người dùng thật.
Ví dụ:
Học sinh đăng nhập.
Mở assignment.
Upload bài.
Bấm nộp.
Thấy trạng thái queued.
Sau khi worker chạy, thấy điểm.
Không có tầng nào luôn tốt nhất.
Mỗi tầng có giá và lợi ích khác nhau.
---
56.5. Unit test: rẻ, nhanh, gần logic
Unit test thường rẻ nhất.
Nó chạy nhanh.
Nó ít phụ thuộc hạ tầng.
Khi fail, thường dễ biết lỗi nằm ở đâu.
Ví dụ ta có logic:
final_score = ai_score - late_penalty
Unit test có thể kiểm tra:
Bài đúng hạn không bị trừ.
Bài trễ bị trừ đúng.
Điểm không vượt quá 10.
Điểm không nhỏ hơn 0.
Nếu thiếu file bắt buộc, điểm tối đa là 5.
Những test này không cần database thật.
Không cần queue thật.
Không cần gọi AI thật.
Chúng chỉ cần input và output.
Đây là nơi rất tốt để test nghiệp vụ thuần.
Điểm yếu của unit test là nó không chứng minh các phần thật sự nối với nhau đúng.
Một hàm tính điểm có thể đúng.
Nhưng API có thể truyền sai dữ liệu vào hàm đó.
Hoặc database lưu nhầm field.
Vì vậy unit test cần, nhưng không đủ.
---
56.6. Unit test nên dùng cho phần nào?
Unit test đặc biệt hợp với logic có luật rõ.
Ví dụ:
Tính điểm.
Tính deadline.
Tính quyền truy cập.
Chọn trạng thái job tiếp theo.
Parse và validate response AI.
Quyết định retry.
Chuẩn hóa dữ liệu đầu vào.
Nếu logic càng quan trọng và càng nhiều nhánh, càng nên có unit test.
Ví dụ với retry:
HTTP 500 -> retry.
HTTP 429 -> retry với delay.
HTTP 401 -> không retry.
Input invalid -> không retry.
Timeout lần 1 -> retry.
Timeout quá số lần -> failed.
Những rule này nếu chỉ đọc code rất dễ sót.
Unit test biến chúng thành bảng hành vi rõ ràng.
Nhưng không nên unit test mọi dòng code.
Ví dụ getter/setter đơn giản, mapping quá mỏng, hoặc wrapper không có logic riêng thường không đáng test riêng.
Test cũng có chi phí bảo trì.
Test rẻ, nhưng không miễn phí.
---
56.7. Integration test: kiểm tra các mảnh ghép
Integration test trả lời câu hỏi:
Các phần khi nối với nhau có chạy đúng không?
Ví dụ AI Judge:
API nhận submission.
Database lưu submission.
Job được đưa vào queue.
Worker lấy job.
AI provider giả trả score.
Database lưu grading result.
API đọc result trả về cho UI.
Đây không còn là một hàm nhỏ.
Nó là một luồng qua nhiều thành phần.
Integration test bắt được lỗi mà unit test thường không thấy:
Migration thiếu column.
Query sai relation.
Transaction không commit.
Queue message thiếu field.
Serializer đổi tên field.
Permission middleware chặn nhầm.
Worker đọc sai trạng thái.
Integration test đắt hơn unit test.
Nó cần database test.
Có thể cần queue test.
Có thể cần fake provider.
Nó chạy chậm hơn.
Nhưng nó rất đáng giá ở các ranh giới quan trọng.
---
56.8. Fake, mock, stub: hiểu đơn giản
Khi test, ta thường không muốn gọi mọi thứ thật.
Ví dụ không nên để test gọi Gemini API thật mỗi lần chạy.
Vì:
Tốn tiền.
Chậm.
Không ổn định.
Phụ thuộc internet.
Provider có thể đổi response.
Ta dùng các bản thay thế.
Stub là bản trả lời cố định.
Ví dụ:
Khi gọi AI, luôn trả score = 8.
Fake là bản giả nhưng có hành vi gần thật hơn.
Ví dụ:
Fake AI provider có thể trả success, timeout, 429, invalid JSON tùy test case.
Mock thường dùng để kiểm tra một thứ có được gọi như mong đợi không.
Ví dụ:
Khi job fail quá số lần, notification service phải được gọi một lần.
Điểm cần cẩn thận:
Test với fake/mock quá nhiều có thể xa thực tế.
Nếu fake provider trả response không giống provider thật, test pass nhưng production fail.
Vì vậy fake/mock phải được thiết kế có chủ đích.
Đặc biệt với AI response, nên lưu vài response mẫu thật đã được ẩn dữ liệu nhạy cảm để test parser.
---
56.9. End-to-end test: giống người dùng, nhưng đắt
End-to-end test kiểm tra luồng từ đầu đến cuối.
Ví dụ:
Mở trình duyệt.
Đăng nhập.
Chọn lớp.
Mở bài tập.
Upload file.
Nộp bài.
Chờ trạng thái.
Xem kết quả.
E2E test có giá trị vì nó kiểm tra trải nghiệm thật.
Nó bắt được lỗi kiểu:
Button bị disable sai.
Frontend gọi nhầm API.
Auth flow hỏng.
Upload file lỗi.
Backend response đổi nhưng UI chưa cập nhật.
CSS che mất nút quan trọng.
Nhưng E2E test đắt.
Nó chạy chậm.
Nó dễ flaky.
Flaky nghĩa là lúc pass lúc fail dù code không đổi.
Nguyên nhân có thể là:
Chờ không đủ lâu.
Animation.
Network chậm.
Database test chưa sạch.
Thứ tự test ảnh hưởng nhau.
Worker async chưa xử lý xong.
Nếu quá nhiều E2E test, team có thể chậm lại rất mạnh.
Mỗi pull request chờ 40 phút.
Test fail ngẫu nhiên.
Mọi người bắt đầu bấm rerun thay vì tin test.
Khi đó test không còn tạo tự tin.
Nó tạo mệt.
---
56.10. Vì sao quá nhiều E2E test làm team chậm?
E2E test hấp dẫn vì nó giống người dùng thật.
Nhiều người nghĩ:
Cứ test từ UI là chắc nhất.
Nhưng nếu mọi thứ đều test qua UI, ta phải trả giá cao.
Ví dụ muốn kiểm tra 20 rule tính điểm.
Nếu test bằng E2E:
Mỗi rule phải login.
Tạo assignment.
Upload bài.
Chờ worker.
Xem điểm.
Rất chậm.
Trong khi cùng 20 rule đó có thể là 20 unit test chạy dưới một giây.
E2E nên dùng cho luồng sống còn và tích hợp lớn.
Không nên dùng để kiểm tra từng nhánh nhỏ của logic.
Một nguyên tắc thực dụng:
Nếu một hành vi có thể test bằng unit test rõ ràng, đừng vội đưa lên E2E.
Nếu một lỗi chỉ xuất hiện khi các phần nối với nhau, dùng integration test.
Nếu muốn biết luồng người dùng quan trọng có sống không, dùng E2E.
---
56.11. Test pyramid
Test pyramid là một hình ảnh quen thuộc.
Nó nói:
Nhiều unit test.
Một lượng vừa integration test.
Ít E2E test.
Lý do rất thực dụng:
Unit test rẻ và nhanh.
Integration test đắt hơn nhưng bắt lỗi nối ghép.
E2E test giống thật hơn nhưng chậm và dễ flaky.
Pyramid không phải luật tuyệt đối.
Một hệ thống nhiều logic backend có thể cần rất nhiều unit test.
Một hệ thống chủ yếu là workflow UI có thể cần nhiều E2E hơn.
Một microservices system có thể cần contract test nhiều hơn.
Điểm quan trọng không phải hình tam giác.
Điểm quan trọng là:
Đừng dùng loại test đắt để kiểm tra thứ test rẻ đã kiểm tra tốt.
---
56.12. Test phần nghiệp vụ quan trọng trước
Nếu team mới bắt đầu viết test, đừng hỏi:
Làm sao đạt 90% coverage?
Hãy hỏi:
Nếu phần này sai, hậu quả có lớn không?
Với AI Judge, phần nên test trước:
Không mất bài nộp.
Không chấm nhầm bài của user khác.
Không ghi đè điểm đã duyệt thủ công.
Không retry vô hạn.
Không trừ điểm sai.
Không để điểm vượt range.
Không cho user xem bài lớp khác.
Không tạo nhiều kết quả trùng khi job retry.
Đây là các hành vi có rủi ro thật.
Ngược lại, một đoạn code đổi màu badge từ xám sang xanh có thể không cần test tự động phức tạp.
Test nên đi theo rủi ro.
Không đi theo lòng tham coverage.
---
56.13. Coverage hữu ích nhưng dễ bị hiểu sai
Coverage là tỷ lệ code được test chạy qua.
Ví dụ:
80% coverage.
Nghe tốt.
Nhưng coverage không nói test có kiểm tra đúng không.
Ví dụ:
Test gọi hàm calculate_score.
Nhưng không assert kết quả.
Coverage vẫn tăng.
Hoặc:
Test chỉ kiểm tra happy path.
Không kiểm tra lỗi.
Coverage có thể vẫn cao.
Coverage thấp là tín hiệu đáng chú ý.
Nhưng coverage cao không tự động đồng nghĩa với chất lượng cao.
Tốt hơn là dùng coverage như bản đồ:
Vùng quan trọng nào chưa được chạm đến?
Nhánh lỗi nào chưa được test?
Code mới có test không?
Đừng biến coverage thành trò chơi số.
Khi metric trở thành mục tiêu duy nhất, người ta sẽ tối ưu metric thay vì chất lượng.
---
56.14. Happy path và sad path
Happy path là trường hợp mọi thứ diễn ra thuận lợi.
Ví dụ:
User hợp lệ.
File hợp lệ.
AI trả response đúng.
Database ghi thành công.
Điểm hiển thị đúng.
Sad path là trường hợp có lỗi.
Ví dụ:
File quá lớn.
AI timeout.
AI trả JSON hỏng.
Database conflict.
User không có quyền.
Job retry quá số lần.
Submission bị xóa trong lúc worker xử lý.
Nhiều hệ thống có test happy path khá ổn.
Nhưng production thường đau ở sad path.
Vì vậy test nên có cả hai.
Với AI Judge, các sad path đáng test:
Provider timeout.
Provider trả 429.
Provider trả response thiếu score.
Worker retry job cũ.
Job chạy hai lần.
Submission đã được giáo viên sửa điểm thủ công.
Object storage mất file.
Sad path không phải phần phụ.
Nó là nơi failure modes bắt đầu.
---
56.15. Test idempotency
Idempotency đã xuất hiện nhiều lần trong sách.
Trong testing, nó cực kỳ quan trọng.
Nếu một thao tác có thể chạy lại, test phải chứng minh chạy lại không tạo hậu quả xấu.
Ví dụ:
Cùng một grading job chạy hai lần.
Kết quả mong muốn:
Không tạo hai điểm chính thức.
Không trừ điểm hai lần.
Không gửi hai notification quan trọng.
Không ghi đè điểm thủ công.
Đây là test rất đáng có cho hệ thống dùng queue.
Vì queue và worker trong thực tế có thể gặp:
Retry.
Duplicate delivery.
Worker crash sau khi ghi database nhưng trước khi ack message.
Nếu chỉ test một lần chạy thành công, ta bỏ qua một rủi ro rất thật.
Một hệ thống event-driven hoặc queue-based mà không test idempotency thường khá mong manh.
---
56.16. Test permission và bảo mật nghiệp vụ
Permission bug thường nghiêm trọng hơn bug giao diện.
Ví dụ:
Học sinh xem được bài của lớp khác.
Giáo viên sửa được rubric của trường khác.
Admin cấp thấp xem dữ liệu nhạy cảm.
User tải được file không thuộc về mình.
Những lỗi này có thể không làm hệ thống sập.
Nhưng làm mất niềm tin rất nhanh.
Vì vậy nên có test cho permission quan trọng.
Không chỉ test:
User đúng quyền làm được.
Mà còn test:
User sai quyền không làm được.
Ví dụ:
Teacher A không xem được submission của class B.
Student không gọi được endpoint regrade.
User đã bị remove khỏi lớp không còn xem được bài.
Permission test thường phù hợp ở integration test.
Vì permission hay nằm giữa auth, database relation, API và business rule.
---
56.17. Test database migration
Migration là nơi dễ gây sự cố dữ liệu.
Vì vậy migration cũng cần test.
Không chỉ test code sau migration.
Mà test chính migration.
Ví dụ:
Từ schema cũ có bảng grading_results.
Migration thêm rubric_version.
Dữ liệu cũ phải được điền version mặc định đúng.
Test migration nên kiểm tra:
Schema cũ lên schema mới có chạy không?
Dữ liệu cũ còn đọc được không?
Giá trị default đúng không?
Constraint mới có làm hỏng dữ liệu cũ không?
Rollback có cần không?
Migration có chạy quá lâu không?
Với production lớn, migration còn cần test trên dữ liệu gần giống thật.
Không phải cứ chạy pass trên database rỗng là an toàn.
Database rỗng rất dễ tính.
Production data thì đầy lịch sử, ngoại lệ, dữ liệu bẩn, và kích thước lớn.
---
56.18. Test async workflow
Nhiều hệ thống hiện đại không xử lý mọi thứ trong một request.
AI Judge cũng vậy:
User submit.
API lưu bài.
Queue tạo job.
Worker chấm.
Result cập nhật sau.
UI hiển thị trạng thái.
Test async workflow khó hơn test request đồng bộ.
Vì kết quả không có ngay.
Ta phải kiểm tra trạng thái theo thời gian:
received -> queued -> grading -> graded
hoặc:
received -> queued -> grading -> failed/needs_review
Test async tốt nên tránh chờ mù.
Chờ mù là:
sleep 10 seconds
Nếu máy chậm hơn, test fail.
Nếu máy nhanh hơn, test vẫn mất 10 giây vô ích.
Tốt hơn là chờ theo điều kiện:
Chờ đến khi submission có status = graded, tối đa 30 giây.
Async test cần cẩn thận với dữ liệu dọn dẹp.
Nếu job test cũ còn trong queue, nó có thể làm test sau fail khó hiểu.
---
56.19. Test AI không giống test hàm thường
Với hệ thống dùng AI, testing có thêm khó khăn.
AI output có thể không ổn định tuyệt đối.
Cùng một prompt, model có thể trả lời khác nhau.
Nếu test mong output chính xác từng chữ, test sẽ dễ fail.
Vì vậy cần tách hai lớp.
Lớp deterministic nên test chặt:
Validate input.
Tạo prompt có đủ trường.
Parse JSON response.
Clamp score vào range.
Áp late penalty.
Lưu result idempotent.
Lớp AI quality nên đánh giá khác:
Evaluation set.
Golden examples.
Rubric-based evaluation.
Human review.
Regression evaluation theo bộ mẫu.
Không nên để unit test gọi AI thật và kỳ vọng câu trả lời y hệt.
Nhưng cũng không nên bỏ qua testing AI.
Cách thực dụng:
Unit/integration test phần hệ thống quanh AI.
Evaluation riêng cho chất lượng chấm của AI.
Giám sát production bằng metrics và sampling review.
---
56.20. Test không thay thế monitoring
Một hệ thống có test tốt vẫn cần monitoring.
Vì test chạy trước production.
Monitoring nhìn production thật.
Test có thể bỏ sót:
Dữ liệu thật quá đa dạng.
Traffic thật cao hơn dự đoán.
Provider bên ngoài thay đổi.
Một region mạng lỗi.
Database phình lên theo thời gian.
User dùng tính năng theo cách lạ.
Monitoring bắt những chuyện xảy ra sau khi deploy.
Ví dụ:
Test pass.
Deploy xong provider 429 tăng.
Queue age tăng.
p95 latency xấu.
Cost AI tăng bất thường.
Test và monitoring không thay thế nhau.
Chúng bổ sung nhau.
Test giúp tự tin trước khi thay đổi.
Monitoring giúp biết thay đổi đó sống thế nào ngoài đời.
Một team trưởng thành cần cả hai.
---
56.21. Test tốt phải dễ hiểu khi fail
Một test fail mà không ai hiểu vì sao thì giá trị thấp.
Ví dụ thông báo fail:
Expected true to be false.
Không giúp nhiều.
Tốt hơn:
Expected late submission to receive 10% penalty, but final_score stayed 8.0.
Tên test cũng nên nói hành vi.
Ví dụ tốt:
does_not_apply_late_penalty_twice_when_grading_job_retries
Ví dụ kém:
test_case_17
Test là tài liệu sống.
Người đọc test nên hiểu:
Điều kiện là gì?
Hành động là gì?
Kết quả mong muốn là gì?
Vì sao hành vi này quan trọng?
Nếu test khó đọc hơn code thật, test sẽ bị ghét.
Khi test bị ghét, team sẽ ít chăm sóc nó.
---
56.22. Test data phải được thiết kế
Test cần dữ liệu.
Dữ liệu test kém làm test khó hiểu và dễ vỡ.
Ví dụ:
User1, User2, Assignment1, Submission1
Không nói gì về tình huống.
Tốt hơn:
student_in_class_a
teacher_of_class_a
teacher_of_class_b
late_submission
manually_reviewed_result
Dữ liệu nên kể câu chuyện.
Test data cũng cần tránh phụ thuộc lẫn nhau.
Nếu test A tạo dữ liệu và test B dựa vào dữ liệu đó, thứ tự test trở thành bẫy.
Khi chạy riêng test B, nó fail.
Tốt hơn:
Mỗi test tự tạo dữ liệu cần thiết.
Hoặc dùng fixture rõ ràng, độc lập.
Với dữ liệu lớn, có thể dùng dataset mẫu.
Nhưng phải biết dataset đó chứa gì.
Một dataset "khổng lồ và bí ẩn" làm test fail rất khó debug.
---
56.23. Flaky test là kẻ phá niềm tin
Flaky test là test lúc pass lúc fail không rõ lý do.
Flaky test rất nguy hiểm.
Vì nó làm team học thói quen xấu:
Test fail à? Chạy lại chắc được.
Sau một thời gian, khi test fail thật, mọi người cũng nghĩ là flaky.
Nguyên nhân thường gặp:
Phụ thuộc thời gian thật.
Phụ thuộc thứ tự test.
Không dọn dữ liệu.
Async chưa xong đã assert.
Gọi service ngoài thật.
Random không cố định seed.
Môi trường test thiếu tài nguyên.
Flaky test nên được xử lý nghiêm túc.
Không nên để nó sống mãi trong CI.
Nếu chưa sửa ngay, ít nhất phải đánh dấu, cô lập, và có kế hoạch sửa.
Một bộ test đáng tin còn quan trọng hơn một bộ test rất lớn.
---
56.24. Test trong CI
CI là nơi test chạy tự động khi code thay đổi.
Mục tiêu:
Phát hiện lỗi trước khi merge/deploy.
Một pipeline thực dụng có thể chia lớp:
Mỗi commit/pull request:
- Unit test.
- Integration test quan trọng.
- Lint/type check nếu có.
Trước deploy hoặc theo lịch:
- E2E test chính.
- Migration test.
- Security scan.
- Evaluation AI nếu phù hợp.
Không nhất thiết mọi test phải chạy trên mọi commit.
Nếu test quá chậm, team sẽ né test.
Quan trọng là có feedback nhanh cho lỗi thường gặp.
Ví dụ:
Unit test dưới 1-2 phút.
Integration test vài phút.
E2E quan trọng chạy ở bước sau.
Tốc độ feedback là một phần của chất lượng engineering.
---
56.25. Khi nào nên viết test?
Có vài thời điểm rất đáng viết test.
Khi sửa bug:
Viết test tái hiện bug.
Thấy test fail.
Sửa code.
Thấy test pass.
Khi thêm logic nghiệp vụ quan trọng:
Tính điểm.
Permission.
Billing.
Retry.
State transition.
Khi sửa phần dễ lan:
Auth.
Database migration.
Queue worker.
API contract.
Shared library.
Khi refactor:
Test giúp biết hành vi cũ còn giữ không.
Không phải mọi thay đổi đều cần test mới.
Nhưng nếu thay đổi chạm vào tiền, dữ liệu, quyền, điểm số, deadline, hoặc đường sống chính của hệ thống, nên có test.
---
56.26. Test case nên bắt đầu từ câu hỏi nghiệp vụ
Một test tốt thường bắt đầu không phải từ code, mà từ câu hỏi:
Người dùng hoặc hệ thống cần đảm bảo điều gì?
Ví dụ kém:
Test hàm applyPenalty.
Ví dụ tốt hơn:
Bài nộp trễ bị trừ đúng một lần, kể cả khi job chấm retry.
Câu thứ hai nói rõ rủi ro.
Nó gần nghiệp vụ hơn.
Và giúp chọn tầng test tốt hơn.
Có thể cần:
Unit test cho tính penalty.
Integration test cho retry job.
Không nhất thiết phải E2E.
Khi bắt đầu từ nghiệp vụ, test thường bền hơn.
Khi bắt đầu từ implementation, test dễ vỡ mỗi khi refactor.
---
56.27. Test càng gần implementation càng dễ vỡ
Nếu test kiểm tra quá sâu vào chi tiết bên trong, refactor sẽ làm test fail dù hành vi không đổi.
Ví dụ:
Test bắt buộc hàm A phải gọi hàm B đúng 1 lần.
Nếu sau này refactor không còn hàm B nhưng kết quả vẫn đúng, test fail.
Đó là test quá gắn với implementation.
Không phải lúc nào mock cũng sai.
Nhưng nên cẩn thận.
Ưu tiên test hành vi quan sát được:
Input này -> output này.
Request này -> database có trạng thái này.
Job retry -> chỉ có một grading result chính thức.
User sai quyền -> API trả 403.
Test hành vi bền hơn test chi tiết bên trong.
Mục tiêu là bảo vệ điều user/business cần.
Không phải đóng băng cấu trúc code hiện tại.
---
56.28. Một chiến lược test thực dụng cho AI Judge
Nếu phải chọn cách test AI Judge từ đầu, ta có thể làm như sau.
Unit test:
Tính điểm cuối.
Late penalty.
Clamp score.
Parse AI response.
Validate rubric.
Retry decision.
State transition của grading job.
Integration test:
Submit bài tạo submission và job.
Worker chấm với fake provider rồi ghi result.
Job retry không tạo result trùng.
Permission API.
Migration quan trọng.
Object storage missing file.
E2E test:
Student submit bài và thấy trạng thái.
Teacher xem kết quả.
Teacher regrade một bài.
User không có quyền bị chặn.
Evaluation riêng cho AI:
Bộ bài mẫu.
Rubric mẫu.
So sánh score/feedback với kỳ vọng.
Theo dõi regression sau khi đổi prompt/model.
Monitoring sau deploy:
Queue age.
Provider error rate.
Score distribution.
Needs_review rate.
Cost/token.
User-facing latency.
Đây là một bộ bảo vệ nhiều lớp.
Không lớp nào đủ một mình.
Nhưng ghép lại, team có thể sửa hệ thống với ít sợ hãi hơn.
---
56.29. Bảng chọn nhanh loại test
| Cần kiểm tra | Loại test phù hợp | |---|---| | Công thức tính điểm | Unit test | | Rule retry theo loại lỗi | Unit test | | API ghi database đúng | Integration test | | Worker đọc queue và ghi result | Integration test | | User submit bài qua UI | E2E test | | Permission qua API | Integration test | | Button quan trọng trên UI | E2E hoặc component test | | Prompt/model chấm tốt không | AI evaluation | | Provider thật có đang lỗi không | Monitoring | | Migration dữ liệu cũ lên schema mới | Migration/integration test | | Job chạy hai lần có trùng kết quả không | Integration test |
---
56.30. Checklist test trước khi merge feature quan trọng
Trước khi merge một thay đổi quan trọng, hãy hỏi:
- Logic nghiệp vụ chính đã có unit test chưa?
- Ranh giới database/API/queue đã có integration test chưa?
- Luồng user quan trọng có E2E test tối thiểu chưa?
- Sad path có được test không?
- Retry/idempotency có được test không?
- Permission âm tính có được test không?
- Migration có được thử trên dữ liệu gần thật không?
- Test có gọi service ngoài thật một cách không cần thiết không?
- Test có flaky không?
- Test fail có dễ hiểu không?
- CI có chạy lớp test phù hợp không?
- Monitoring sau deploy có nhìn được hành vi thật không?
Nếu tất cả câu trả lời đều là "có", thay đổi vẫn có thể lỗi.
Nhưng team đã giảm rủi ro rất nhiều.
---
56.31. Kết luận của chương
Testing là cách biến sự hiểu biết thành hàng rào tự động.
Unit test giúp bảo vệ logic nhỏ, rẻ và nhanh.
Integration test giúp kiểm tra các mảnh ghép thật sự nối được với nhau.
End-to-end test giúp bảo vệ luồng người dùng quan trọng, nhưng đắt và dễ flaky nếu lạm dụng.
Coverage hữu ích, nhưng không thay thế việc chọn đúng rủi ro.
Test nên ưu tiên phần nghiệp vụ quan trọng, sad path, permission, retry, idempotency, migration và async workflow.
Với hệ thống dùng AI, test phần deterministic thật chặt, còn chất lượng AI cần evaluation riêng.
Và cuối cùng:
Test không thay thế monitoring.
Test giúp tự tin trước khi deploy.
Monitoring cho biết điều gì xảy ra sau khi deploy.
Thông điệp cần nhớ:
> Test tốt không phải là viết thật nhiều test. Test tốt là đặt hàng rào đúng chỗ, để mỗi lần sửa hệ thống, ta biết những hành vi quan trọng vẫn còn được bảo vệ.
Ở chương tiếp theo, ta sẽ nói về contract testing: vì trong hệ thống nhiều service, một service đổi API có thể làm service khác chết dù test nội bộ của từng service vẫn pass.