Chương 58. Testing event-driven systems
Ở chương trước, ta nói về contract testing.
API, event, webhook đều là lời hứa giữa producer và consumer.
Chương này đi sâu vào event-driven systems.
Đây là kiểu hệ thống rất mạnh, nhưng cũng dễ làm ta tự tin sai.
Trong request/response thông thường, luồng khá dễ nhìn:
Client gọi API.
API xử lý.
API trả response.
Còn trong event-driven system:
Service A phát event.
Broker lưu/chuyển event.
Service B xử lý event.
Service C cũng xử lý event.
Một số xử lý thành công.
Một số xử lý chậm.
Một số retry.
Một số event đến muộn.
Một số event có thể đến trùng.
Nếu test như hệ thống đồng bộ, ta sẽ bỏ sót nhiều lỗi.
Thông điệp chính của chương:
> Event-driven system phải được test với giả định production thật: event có thể đến trùng, đến muộn, lệch thứ tự, retry nhiều lần, và trạng thái giữa các service chỉ nhất quán sau một khoảng thời gian.
---
58.1. Một tình huống: bài đã nộp nhưng chưa thấy điểm
Hãy quay lại AI Judge.
Khi học sinh nộp bài, hệ thống không chấm ngay trong request.
Luồng có thể là:
Submission Service lưu bài.
Submission Service phát event submission.created.
Grading Worker nhận event.
Grading Worker gọi AI.
Grading Worker lưu kết quả.
Grading Worker phát event grading.completed.
Notification Service nhận event và gửi thông báo.
Teacher Dashboard nhận event và cập nhật màn hình.
Analytics Service nhận event và cập nhật thống kê.
Luồng này rất hợp lý.
API nộp bài trả nhanh.
Chấm bài chạy nền.
Notification, dashboard, analytics không làm chậm submit.
Nhưng khi test, ta phải nhớ:
Event không phải cuộc gọi hàm.
Gọi hàm thường là:
Gọi xong có kết quả ngay.
Event thường là:
Phát ra rồi có ai xử lý, xử lý lúc nào, xử lý mấy lần, có retry không, là chuyện khác.
Nếu không test đúng bản chất này, hệ thống sẽ đẹp trong demo và đau trong production.
---
58.2. Event-driven system khác request/response thế nào?
Request/response giống một cuộc nói chuyện trực tiếp:
Tôi hỏi bạn.
Bạn trả lời tôi.
Tôi biết ngay kết quả.
Event-driven giống gửi một thông báo:
Tôi thông báo chuyện đã xảy ra.
Ai quan tâm thì xử lý.
Tôi không nhất thiết chờ họ xử lý xong.
Ví dụ:
submission.created
nghĩa là:
Một bài nộp đã được tạo.
Nó không nên nghĩa là:
Bài đã được chấm.
Notification đã gửi.
Dashboard đã cập nhật.
Analytics đã xong.
Đây là điểm quan trọng.
Event nói về một sự kiện đã xảy ra.
Các consumer xử lý sau.
Do đó trạng thái hệ thống có thể tạm thời chưa đồng bộ.
Vừa nộp bài xong, dashboard có thể chưa cập nhật trong vài giây.
Điều này không nhất thiết là lỗi.
Nó là eventual consistency.
---
58.3. Eventual consistency là gì?
Eventual consistency nghĩa là các phần của hệ thống không nhất quán ngay lập tức, nhưng sẽ nhất quán sau một thời gian nếu mọi thứ hoạt động bình thường.
Ví dụ:
API nộp bài trả success lúc 10:00:00.
Submission đã có trong database chính.
Dashboard giáo viên cập nhật lúc 10:00:05.
Notification gửi lúc 10:00:08.
Analytics cập nhật lúc 10:01:00.
Trong 5 giây đầu, giáo viên có thể chưa thấy bài trên dashboard.
Trong 1 phút đầu, analytics có thể chưa tính bài đó.
Đó không nhất thiết là bug.
Nhưng hệ thống phải nói rõ và test rõ.
Nếu product yêu cầu:
Sau khi học sinh nộp, giáo viên phải thấy ngay trong dưới 2 giây.
thì đó là SLO của luồng event.
Nếu không có yêu cầu này, ta không nên giả định mọi thứ realtime.
Testing eventual consistency cần kiểm tra:
Trạng thái cuối cùng đúng.
Trạng thái trung gian không gây hiểu nhầm.
Hệ thống không báo sai rằng dữ liệu mất.
UI biết hiển thị đang cập nhật.
Timeout chờ hợp lý.
---
58.4. Test event-driven system khó ở đâu?
Khó vì nhiều thứ không xảy ra ngay.
Khó vì cùng một event có thể được nhiều consumer xử lý.
Khó vì consumer có thể fail độc lập.
Khó vì broker có rule riêng.
Khó vì retry có thể làm event được xử lý lại.
Khó vì thứ tự event không phải lúc nào cũng như ta tưởng.
Ví dụ:
submission.created đến trước.
submission.updated đến sau.
Nghe hợp lý.
Nhưng trong production, có thể consumer thấy:
submission.updated trước.
submission.created sau.
Nếu dùng partition/key sai, hoặc nhiều queue khác nhau, hoặc retry làm chậm một event, thứ tự có thể lệch.
Nếu consumer được viết với giả định cứng:
created luôn đến trước updated.
nó có thể hỏng.
Testing phải đưa các trường hợp khó chịu này vào.
Không phải vì ta bi quan.
Mà vì production rất thích kiểm tra những giả định thầm lặng.
---
58.5. Event có thể đến trùng
Một nguyên tắc rất quan trọng:
Consumer nên chịu được duplicate event.
Duplicate event nghĩa là cùng một sự kiện được consumer nhận hơn một lần.
Ví dụ:
grading.completed event_id = evt_123
Notification Service nhận event này.
Nó gửi email.
Nhưng trước khi ack message, service crash.
Broker không biết event đã xử lý xong hay chưa.
Nó gửi lại event.
Notification Service nhận lại evt_123.
Nếu không cẩn thận, user nhận hai email.
Với chấm bài, duplicate còn nguy hiểm hơn:
Cùng một grading.completed được xử lý hai lần.
Dashboard cộng số bài đã chấm thêm hai lần.
Analytics đếm double.
Notification gửi hai lần.
Vì vậy test duplicate event là bắt buộc với nhiều consumer quan trọng.
---
58.6. Test duplicate event như thế nào?
Ta không cần test bằng cách làm broker crash thật.
Ta có thể test consumer trực tiếp.
Ví dụ với Notification Consumer:
Given event grading.completed với event_id = evt_123
When consumer xử lý event này hai lần
Then chỉ gửi một notification quan trọng
And lưu event_id đã xử lý
Với Analytics Consumer:
Given cùng một submission_id và grading_result_id
When nhận grading.completed hai lần
Then số bài đã chấm chỉ tăng một lần
Với Grading Consumer:
Given submission.created event_id = evt_abc
When nhận event hai lần
Then chỉ tạo một grading job hợp lệ
Điểm cần test là kết quả cuối.
Không phải chỉ test "consumer không crash".
Không crash là chưa đủ.
Consumer phải không tạo side effect trùng.
Đây chính là idempotency.
---
58.7. Idempotency trong event consumer
Idempotency nghĩa là xử lý lại cùng một input không làm thay đổi kết quả thêm lần nữa.
Trong event consumer, idempotency thường dựa vào một định danh ổn định:
event_id.
submission_id.
grading_result_id.
idempotency_key.
Ví dụ:
grading.completed có event_id = evt_123
Consumer lưu:
evt_123 đã xử lý.
Lần sau nhận lại, consumer bỏ qua hoặc trả success mà không làm lại side effect.
Nhưng cần cẩn thận.
Không phải lúc nào event_id là đủ.
Ví dụ một sự kiện được publish lại với event_id mới nhưng cùng business meaning:
grading_result_id = gr_789
Nếu notification chỉ nên gửi một lần cho một grading result, key idempotency tốt hơn có thể là:
notification_type + grading_result_id
Thiết kế idempotency key là quyết định nghiệp vụ.
Test phải phản ánh quyết định đó.
---
58.8. Event có thể đến muộn
Late event là event đến sau khi hệ thống đã đi tiếp.
Ví dụ:
10:00 student submit version 1.
10:01 student submit version 2.
10:02 event submission.created for version 1 đến consumer.
10:03 event submission.created for version 2 đến consumer.
Hoặc tệ hơn:
Event version 2 đến trước.
Event version 1 đến sau.
Nếu consumer không kiểm tra version, event cũ có thể ghi đè trạng thái mới.
Ví dụ:
Worker đã chấm bài version 2.
Event cũ của version 1 đến muộn.
Worker tạo lại job cho version 1.
Kết quả cũ ghi đè kết quả mới.
Đây là lỗi rất thật.
Test late event nên kiểm tra:
Event cũ không ghi đè dữ liệu mới.
Consumer so sánh version hoặc timestamp.
Consumer bỏ qua event stale.
Side effect không được tạo từ event đã lỗi thời.
Late event không phải ngoại lệ hiếm.
Nó là một phần của đời sống bất đồng bộ.
---
58.9. Test late event như thế nào?
Ví dụ với AI Judge:
Given submission sub_123 đã có attempt = 2
And grading result hiện tại thuộc attempt = 2
When consumer nhận event submission.created của attempt = 1
Then consumer không tạo grading job mới cho attempt = 1
And không ghi đè result hiện tại
Hoặc:
Given assignment rubric_version = 3
When grading.completed event của rubric_version = 2 đến muộn
Then result không được tự động trở thành official score
And submission được đánh dấu needs_review nếu cần
Điểm mấu chốt:
Event phải mang đủ thông tin để consumer biết nó còn hợp lệ không.
Nếu event chỉ có:
submission_id
consumer phải query thêm database để biết version hiện tại.
Nếu event có:
submission_id, attempt_number, rubric_version, occurred_at
consumer có nhiều cơ sở hơn để quyết định.
Testing late event đôi khi làm lộ ra rằng event schema đang thiếu dữ liệu.
Đó là một phát hiện tốt.
---
58.10. Event có thể lệch thứ tự
Out-of-order event là event đến không theo thứ tự ta kỳ vọng.
Ví dụ producer phát:
submission.created
submission.graded
Nhưng consumer nhận:
submission.graded
submission.created
Hoặc:
grading.started
grading.completed
grading.failed
đến sai thứ tự vì retry hoặc nhiều partition.
Không phải hệ thống nào cũng cho phép lệch thứ tự.
Một số broker có thể giữ thứ tự trong cùng partition/key.
Nhưng ta phải hiểu giới hạn của broker.
Ví dụ:
Kafka giữ thứ tự trong cùng partition, không giữ thứ tự toàn cục.
Nếu key sai, event cùng một submission có thể rơi vào partition khác nhau.
Thứ tự không còn được đảm bảo.
Test out-of-order giúp phát hiện consumer có giả định quá mạnh không.
---
58.11. Test out-of-order event như thế nào?
Ví dụ:
Given consumer chưa từng thấy submission.created
When nhận grading.completed cho sub_123
Then consumer không crash
And có thể tạo placeholder/read model tối thiểu
Or đưa event vào pending để xử lý sau
Hoặc:
Given submission đang status = graded
When nhận grading.started đến muộn
Then status không bị lùi về grading
Đây là test rất quan trọng.
Vì nhiều bug event-driven là bug "đi lùi trạng thái".
Ví dụ:
queued -> grading -> graded
Nếu event cũ đến muộn làm:
graded -> grading
UI sẽ rất rối.
Một cách chống là state machine có rule:
Không cho transition đi lùi nếu event cũ hơn trạng thái hiện tại.
Test state transition nên bao gồm thứ tự sai.
---
58.12. Event có thể mất không?
Câu trả lời thực tế:
Có, nếu thiết kế sai hoặc dùng broker sai cách.
Một số hệ thống message có độ bền cao nếu cấu hình đúng.
Nhưng event vẫn có thể mất vì:
Publish event sau khi commit database nhưng publish fail.
Publish event trước khi commit database rồi transaction rollback.
Broker không persist message.
Consumer ack quá sớm rồi crash.
Retention quá ngắn.
Dead-letter queue không được xử lý.
Sai cấu hình topic/queue.
Deploy xóa nhầm subscription.
Không nên nói chung chung:
Chúng tôi dùng queue nên không mất.
Phải hỏi:
Publish và database commit có atomic không?
Consumer ack lúc nào?
Broker durability ra sao?
DLQ có ai đọc không?
Nếu event mất, có cách reconcile không?
Testing không thể chứng minh event không bao giờ mất.
Nhưng có thể test cơ chế giảm thiệt hại.
---
58.13. Transactional outbox
Một pattern rất phổ biến để tránh mất event là transactional outbox.
Vấn đề ban đầu:
Service vừa ghi database vừa publish event.
Nếu ghi database thành công nhưng publish event fail, dữ liệu đã đổi nhưng event không ra ngoài.
Ví dụ:
Submission đã được lưu.
Nhưng submission.created không được publish.
Grading Worker không biết để chấm.
Transactional outbox giải quyết bằng cách:
Trong cùng database transaction:
- Lưu submission.
- Lưu một record outbox: event cần publish.
Sau đó một outbox publisher đọc outbox và publish event ra broker.
Publish thành công thì đánh dấu sent.
Như vậy database change và ý định publish event đi cùng nhau.
Nếu publisher chết, outbox record vẫn còn.
Nó có thể publish lại sau.
Nhưng vì publish lại có thể tạo duplicate, consumer vẫn phải idempotent.
Outbox giảm mất event.
Nó không xóa nhu cầu idempotency.
---
58.14. Test transactional outbox
Nếu dùng outbox, cần test vài điều.
Thứ nhất:
Khi tạo submission thành công, outbox record cũng được tạo.
Thứ hai:
Nếu transaction rollback, không có submission và không có outbox event.
Thứ ba:
Outbox publisher publish event thành công thì đánh dấu sent.
Thứ tư:
Nếu publish fail, event vẫn còn pending để retry.
Thứ năm:
Nếu publisher chạy lại, event không tạo side effect nguy hiểm ở consumer.
Một test quan trọng:
Given outbox có event pending
When publisher publish được nhưng crash trước khi mark sent
Then lần sau event có thể publish lại
And consumer xử lý duplicate an toàn
Đây là kiểu test bắt được sự thật production:
Crash có thể xảy ra ở giữa hai bước.
Nếu test chỉ happy path, ta bỏ sót phần nguy hiểm nhất.
---
58.15. Consumer ack lúc nào?
Trong message queue, consumer thường phải ack để nói:
Tôi đã xử lý xong message này.
Nếu ack quá sớm, có thể mất message.
Ví dụ:
Consumer nhận event.
Ack ngay.
Sau đó ghi database.
Database fail.
Message đã bị broker xem là xử lý xong.
Event mất.
Nếu ack sau khi xử lý xong, an toàn hơn:
Consumer nhận event.
Ghi database thành công.
Gửi side effect cần thiết.
Ack message.
Nhưng nếu consumer crash sau khi ghi database, trước khi ack, broker sẽ gửi lại message.
Do đó duplicate xảy ra.
Đây là trade-off quen thuộc:
Ack sớm: ít duplicate hơn nhưng dễ mất.
Ack muộn: ít mất hơn nhưng phải chịu duplicate.
Trong nhiều hệ thống nghiệp vụ, ta thích ack muộn và thiết kế idempotent.
Vì duplicate có thể xử lý.
Mất event quan trọng thì đau hơn.
---
58.16. Test retry của consumer
Consumer retry khi xử lý event fail tạm thời.
Ví dụ:
Notification Service gọi email provider bị timeout.
Retry có ích.
Nhưng như chương failure modes đã nói, retry sai có thể tạo retry storm.
Test retry consumer nên kiểm tra:
Lỗi tạm thời được retry.
Lỗi vĩnh viễn không retry vô hạn.
Retry có giới hạn.
Retry có backoff.
Sau quá số lần, message vào dead-letter queue hoặc trạng thái failed rõ.
Side effect không bị nhân đôi.
Ví dụ:
Provider email trả 500 hai lần rồi success.
Consumer retry và cuối cùng gửi một notification.
Hoặc:
Event thiếu required field.
Consumer không retry vô hạn.
Message được đưa vào invalid/dead-letter.
Alert hoặc metric tăng.
Retry test nên phân biệt lỗi tạm thời và lỗi vĩnh viễn.
Nếu không, hệ thống sẽ retry cả những event không bao giờ xử lý được.
---
58.17. Dead-letter queue không phải thùng rác
Dead-letter queue, thường gọi là DLQ, là nơi message được đưa vào sau khi xử lý thất bại quá số lần.
DLQ không phải nơi để quên lỗi.
Nó là nơi để điều tra và phục hồi.
Ví dụ:
grading.completed event bị thiếu submission_id.
Consumer retry 3 lần vẫn fail.
Message vào DLQ.
Team cần biết:
DLQ có message không?
Bao nhiêu message?
Message thuộc loại nào?
Có alert không?
Ai xử lý?
Có tool replay không?
Replay có idempotent không?
Test DLQ nên kiểm tra:
Message invalid không retry mãi.
Message vào đúng DLQ.
Metric/alert tăng.
Replay từ DLQ không tạo side effect trùng.
Một hệ thống có DLQ nhưng không ai nhìn cũng giống có báo cháy nhưng không ai nghe.
---
58.18. Test consumer độc lập
Một sai lầm phổ biến là chỉ test event-driven system bằng end-to-end.
Ví dụ:
Submit bài qua UI.
Chờ mọi service xử lý.
Kiểm tra dashboard và notification.
Test này hữu ích, nhưng rất đắt và dễ flaky.
Consumer nên được test độc lập.
Ví dụ:
Đưa event grading.completed vào Notification Consumer.
Kiểm tra notification được tạo đúng.
Không cần chạy toàn bộ Submit flow.
Consumer độc lập cần test:
Happy path.
Duplicate event.
Late event.
Out-of-order event nếu liên quan.
Invalid event.
Retryable error.
Non-retryable error.
Idempotency.
DLQ.
Khi consumer được test độc lập, ta phát hiện lỗi nhanh hơn.
E2E vẫn cần cho một số luồng chính.
Nhưng không nên bắt E2E gánh toàn bộ logic event.
---
58.19. Contract test cho event
Event consumer độc lập vẫn cần biết event schema đúng.
Đây là nơi contract testing từ chương trước quay lại.
Producer phải giữ event contract.
Consumer phải khai báo nó cần gì.
Ví dụ event:
{
"event_type": "grading.completed",
"event_id": "evt_123",
"submission_id": "sub_123",
"grading_result_id": "gr_456",
"score": 8.5,
"rubric_version": 3,
"occurred_at": "2026-05-12T10:00:00Z"
}
Notification Consumer có thể cần:
event_id.
student_id hoặc submission_id để tìm student.
grading_result_id.
occurred_at.
Analytics Consumer có thể cần:
score.
assignment_id.
rubric_version.
Nếu producer bỏ rubric_version, Analytics có thể sai.
Contract test giúp bắt chuyện này trước production.
Event schema cũng cần backward compatibility giống API.
---
58.20. Test schema evolution
Event sống lâu hơn ta tưởng.
Một event cũ có thể vẫn nằm trong queue, DLQ, log, hoặc replay storage.
Consumer mới có thể phải xử lý event version cũ.
Producer mới có thể phát event version mới trong khi consumer cũ vẫn chạy.
Vì vậy cần test schema evolution.
Ví dụ:
Event v1 không có rubric_version.
Event v2 có rubric_version.
Consumer v2 xử lý được cả v1 và v2.
Hoặc:
Producer thêm field optional.
Consumer cũ không crash.
Hoặc:
Producer thêm event_type mới.
Consumer không biết event_type đó thì bỏ qua an toàn.
Breaking change trong event còn khó hơn API.
Vì event đã phát ra có thể được lưu và replay.
Khi đổi schema event, cần nghĩ:
Event cũ trong lịch sử còn xử lý được không?
Replay có chạy được không?
Consumer cũ gặp event mới thì sao?
---
58.21. Test replay
Replay là xử lý lại event cũ.
Replay dùng khi:
Rebuild read model.
Sửa bug consumer.
Khôi phục dữ liệu.
Tính lại analytics.
Chạy consumer mới trên lịch sử cũ.
Replay rất mạnh.
Nhưng nguy hiểm nếu consumer không idempotent.
Ví dụ:
Replay grading.completed của 6 tháng.
Notification Consumer gửi lại email cho tất cả học sinh.
Thảm họa.
Vì vậy consumer cần phân biệt:
Chạy realtime.
Chạy replay.
Side effect nào được phép trong replay.
Side effect nào phải tắt.
Test replay nên kiểm tra:
Rebuild read model đúng.
Không gửi notification thật trong replay.
Không gọi provider ngoài nếu không cần.
Không tạo dữ liệu trùng.
Có thể chạy lại replay nhiều lần.
Replay là lý do nữa để tách logic thuần khỏi side effect.
---
58.22. Test read model
Trong event-driven system, read model là dữ liệu được dựng lên từ event để đọc nhanh hơn.
Ví dụ:
Teacher Dashboard có bảng/subsystem riêng:
- assignment_id
- total_submissions
- graded_count
- average_score
Bảng này có thể được cập nhật từ event:
submission.created
grading.completed
grading.failed
Read model không phải source of truth gốc.
Nó là bản chiếu phục vụ đọc.
Test read model cần kiểm tra:
Nhận submission.created thì total_submissions tăng.
Nhận grading.completed thì graded_count tăng.
Duplicate grading.completed không tăng hai lần.
Out-of-order event không làm số liệu âm/sai.
Replay toàn bộ event tạo ra cùng kết quả.
Read model rất dễ sai âm thầm.
UI vẫn chạy.
Không có error.
Chỉ là số liệu sai.
Vì vậy test read model theo event sequence rất đáng giá.
---
58.23. Test bằng chuỗi event
Một cách tốt để test event-driven logic là dùng chuỗi event.
Ví dụ:
Given no dashboard record
When events arrive:
1. submission.created sub_1
2. submission.created sub_2
3. grading.completed sub_1
Then dashboard shows:
total_submissions = 2
graded_count = 1
Sau đó test duplicate:
When grading.completed sub_1 arrives again
Then graded_count vẫn = 1
Test out-of-order:
When grading.completed sub_3 arrives before submission.created sub_3
Then consumer handles safely
And final dashboard correct after submission.created arrives
Test dạng này rất dễ đọc.
Nó gần với cách hệ thống thật hoạt động.
Và nó giúp ta thấy rõ state thay đổi qua event.
---
58.24. Test state machine
Nhiều consumer thực chất là state machine.
Ví dụ trạng thái grading:
queued -> grading -> graded
queued -> grading -> failed
failed -> retrying -> grading -> graded
graded -> needs_review
Một số transition không nên cho phép:
graded -> queued
graded -> grading vì event cũ đến muộn
failed -> graded nếu result thuộc attempt cũ
Test state machine nên rõ:
Transition nào hợp lệ.
Transition nào bị bỏ qua.
Transition nào tạo alert.
Transition nào cần human review.
Nếu state machine chỉ nằm rải rác trong if/else, rất khó test.
Đây là lý do với workflow quan trọng, ta nên làm state transition explicit.
Không cần framework phức tạp.
Chỉ cần code đủ rõ để test:
current_state + event -> next_state / action
---
58.25. Test timeout trong workflow bất đồng bộ
Event-driven system không chỉ xử lý event đến.
Nó còn phải xử lý event không đến.
Ví dụ:
Submission đã queued 30 phút nhưng không có grading.started.
Hoặc:
grading.started rồi 20 phút không có grading.completed hoặc grading.failed.
Đây là timeout ở cấp workflow.
Không phải HTTP timeout.
Test workflow timeout nên kiểm tra:
Job quá lâu được phát hiện.
Status chuyển sang stuck hoặc needs_attention.
Alert/metric tăng.
Không tạo vô hạn job trùng.
User thấy trạng thái rõ.
Nhiều team chỉ test event khi nó đến.
Nhưng production cũng có lỗi vì event không bao giờ đến.
Ví dụ outbox publisher chết.
Hoặc consumer bị pause.
Hoặc provider gọi AI treo.
Workflow timeout là cách phát hiện "im lặng bất thường".
---
58.26. Test reconciliation
Reconciliation là quá trình đối soát và sửa lệch.
Trong event-driven system, dù cẩn thận, vẫn có thể có lệch tạm thời:
Submission status = queued.
Nhưng không có job trong queue.
Hoặc:
Grading result đã tồn tại.
Nhưng dashboard read model chưa cập nhật.
Một reconciliation job có thể chạy định kỳ:
Tìm submission queued quá lâu mà không có job.
Tạo lại job nếu cần.
Tìm result đã graded nhưng read model thiếu.
Cập nhật lại dashboard.
Test reconciliation rất quan trọng.
Vì nó là lưới cứu khi event bị mất, consumer fail, hoặc replay thiếu.
Test nên kiểm tra:
Phát hiện lệch đúng.
Sửa lệch an toàn.
Không tạo trùng job/result.
Không ghi đè dữ liệu mới hơn.
Có metric/log cho số item được sửa.
Event-driven system trưởng thành thường có reconciliation.
Vì không ai muốn đặt toàn bộ niềm tin vào việc mọi event luôn hoàn hảo.
---
58.27. Test broker thật hay fake broker?
Có hai cách phổ biến.
Test với fake broker:
Nhanh.
Dễ setup.
Tốt để test logic consumer.
Nhưng fake broker có thể không giống broker thật.
Ví dụ fake không mô phỏng:
Ack.
Redelivery.
Partition.
Ordering.
DLQ.
Visibility timeout.
Retention.
Test với broker thật:
Bắt lỗi tích hợp tốt hơn.
Gần production hơn.
Nhưng chậm và phức tạp hơn.
Cách thực dụng:
Consumer logic test bằng fake/direct invocation.
Một số integration test chạy với broker thật hoặc container broker.
E2E rất ít cho luồng sống còn.
Không nên bắt mọi test dựng broker thật.
Nhưng cũng không nên chỉ tin fake.
Vì broker behavior là một phần của hệ thống.
---
58.28. Test consumer bằng direct invocation
Consumer thường nên có phần logic có thể gọi trực tiếp.
Ví dụ:
handle_grading_completed(event)
Test có thể gọi hàm này với event mẫu.
Không cần broker.
Điều này giúp test:
Duplicate.
Late.
Invalid.
State transition.
Idempotency.
Read model update.
Broker adapter chỉ làm việc:
Nhận message.
Parse.
Gọi handler.
Ack/nack theo kết quả.
Phần adapter cũng cần vài integration test.
Nhưng phần lớn business behavior nên nằm trong handler dễ test.
Nếu toàn bộ logic bị nhét vào callback của broker, test sẽ khó và chậm.
Thiết kế dễ test thường cũng là thiết kế dễ hiểu.
---
58.29. Test side effects
Consumer thường tạo side effect:
Ghi database.
Gửi email.
Gọi API khác.
Cập nhật search index.
Gửi webhook.
Test side effect cần rõ:
Side effect nào bắt buộc?
Side effect nào chỉ nên xảy ra một lần?
Side effect nào được phép retry?
Side effect nào phải tắt khi replay?
Ví dụ Notification Consumer:
grading.completed -> tạo notification record.
Ta có thể test:
Nhận event lần đầu -> tạo notification.
Nhận duplicate -> không tạo notification thứ hai.
Replay mode -> không gửi email thật.
Provider email fail -> retry hoặc mark pending.
Không nên chỉ mock quá sâu kiểu:
Hàm send_email được gọi.
Nếu hành vi quan trọng là "không gửi hai lần", test nên kiểm tra kết quả quan sát được:
Chỉ có một notification/email job trong database.
---
58.30. Test observability của consumer
Consumer chạy nền.
Nếu nó fail âm thầm, user có thể không biết ngay.
Vì vậy test không chỉ hành vi nghiệp vụ.
Nên kiểm tra observability quan trọng:
Invalid event tăng metric.
Retry tăng metric.
DLQ tăng metric.
Processing latency được đo.
Consumer lag được đo.
Log có event_id/correlation_id.
Ví dụ:
Given event thiếu submission_id
When consumer xử lý
Then metric consumer_invalid_event_total tăng
And log có event_id
And message vào DLQ
Không phải mọi log/metric đều cần unit test.
Nhưng với failure path quan trọng, test observability giúp tránh tình trạng:
Hệ thống lỗi nhưng không ai thấy.
---
58.31. Test consumer lag và backlog
Consumer lag là độ trễ giữa event được publish và event được consumer xử lý.
Nếu lag tăng, hệ thống có thể đang chậm.
Ví dụ:
submission.created publish lúc 10:00.
Grading Consumer xử lý lúc 10:20.
Lag = 20 phút.
Với AI Judge, lag lớn nghĩa là bài chờ lâu.
Test tự động có thể không đo lag production.
Nhưng ta có thể test hệ thống có ghi timestamp đủ để đo lag:
Event có occurred_at.
Consumer ghi processed_at.
Metric processing lag được emit.
Dashboard/alert dùng được.
Nếu event không có timestamp, rất khó đo lag chính xác.
Vì vậy testing đôi khi dẫn đến cải thiện schema.
Ta phát hiện:
Muốn test/monitor đúng thì event thiếu trường cần thiết.
---
58.32. Test event không hợp lệ
Invalid event là event sai schema hoặc sai nghiệp vụ.
Ví dụ:
Thiếu submission_id.
score là string thay vì number.
status không nằm trong enum.
occurred_at sai format.
grading_result_id không tồn tại.
Consumer không nên crash toàn bộ vì một message xấu.
Nó nên:
Reject message rõ ràng.
Đưa vào DLQ nếu cần.
Log đủ context.
Tăng metric.
Không retry vô hạn nếu lỗi không thể tự hết.
Test invalid event rất quan trọng.
Vì event xấu thường gây poison message.
Poison message là message mà consumer cứ xử lý là fail.
Nếu broker cứ gửi lại mãi, consumer bị kẹt ở message đó.
Một message xấu làm cả queue chậm.
Test giúp đảm bảo poison message được cô lập.
---
58.33. Test event version cũ
Khi hệ thống sống lâu, event version cũ là chuyện bình thường.
Ví dụ:
grading.completed v1:
- submission_id
- score
grading.completed v2:
- submission_id
- grading_result_id
- score
- rubric_version
Consumer mới có thể cần xử lý v1 để replay lịch sử.
Nếu không, replay từ 6 tháng trước fail.
Test nên có fixture cho event version cũ:
consumer_handles_grading_completed_v1
consumer_handles_grading_completed_v2
Nếu v1 không còn hỗ trợ, điều đó cũng phải là quyết định rõ:
Không replay trước ngày X.
Hoặc migration event history trước.
Không nên để việc bỏ hỗ trợ event cũ xảy ra tình cờ.
---
58.34. Test không nên phụ thuộc thời gian thật quá nhiều
Event-driven test hay dính thời gian.
Ví dụ:
Chờ worker xử lý.
Chờ retry.
Chờ timeout.
Nếu test dùng sleep cứng, nó dễ chậm và flaky.
Ví dụ:
sleep 10 seconds
Tốt hơn:
Chờ đến khi điều kiện đúng, tối đa 10 giây.
Hoặc trong unit test, dùng fake clock:
Đẩy thời gian lên 30 phút để test workflow timeout.
Thời gian thật làm test khó kiểm soát.
Nếu có thể, tách logic thời gian để test được bằng clock giả.
Đặc biệt với retry/backoff, fake clock giúp test nhanh hơn rất nhiều.
---
58.35. Một chiến lược test thực dụng cho AI Judge
Với AI Judge event-driven, ta có thể chia test thành vài lớp.
Consumer unit/integration nhẹ:
Grading Consumer xử lý submission.created.
Notification Consumer xử lý grading.completed.
Dashboard Consumer cập nhật read model.
Analytics Consumer đếm score distribution.
Mỗi consumer test:
Happy path.
Duplicate.
Late/out-of-order nếu liên quan.
Invalid event.
Retryable error.
Non-retryable error.
Idempotency.
Broker integration test:
Publish event thật vào broker test.
Consumer nhận và ack đúng.
Retry/DLQ hoạt động.
Ordering theo key nếu hệ thống dựa vào ordering.
Outbox test:
Database change tạo outbox event.
Publisher retry khi publish fail.
Duplicate publish không làm consumer sai.
Workflow test:
Submit bài -> queued -> grading -> graded.
Provider timeout -> retry -> failed/needs_review.
Submission queued quá lâu -> alert/repair.
E2E tối thiểu:
Học sinh nộp bài.
Sau một khoảng hợp lý, giáo viên thấy trạng thái/kết quả.
Monitoring:
Consumer lag.
DLQ count.
Retry rate.
Invalid event count.
Oldest queued submission age.
Không có lớp nào đủ một mình.
Nhưng ghép lại, ta test đúng bản chất bất đồng bộ của hệ thống.
---
58.36. Những sai lầm phổ biến
Sai lầm thứ nhất:
Giả định event chỉ đến đúng một lần.
Thực tế, duplicate là chuyện nên chuẩn bị.
Sai lầm thứ hai:
Giả định event luôn đúng thứ tự.
Nếu hệ thống phụ thuộc thứ tự, phải hiểu broker đảm bảo ở mức nào và test điều đó.
Sai lầm thứ ba:
Ack quá sớm.
Điều này có thể làm mất event khi consumer crash sau ack.
Sai lầm thứ tư:
Không có idempotency.
Retry và duplicate sẽ biến thành side effect trùng.
Sai lầm thứ năm:
DLQ không ai xem.
Message lỗi bị chôn thay vì được xử lý.
Sai lầm thứ sáu:
Chỉ test E2E.
Chậm, flaky, và khó biết consumer nào sai.
Sai lầm thứ bảy:
Không test replay.
Đến lúc cần rebuild dữ liệu mới phát hiện consumer gửi lại notification hoặc xử lý sai event cũ.
Sai lầm thứ tám:
Không có reconciliation.
Khi event mất hoặc consumer fail âm thầm, dữ liệu lệch mãi.
---
58.37. Checklist test event-driven system
Trước khi tin một workflow event-driven đã ổn, hãy hỏi:
- Consumer xử lý duplicate event thế nào?
- Idempotency key là gì?
- Event cũ đến muộn có bị bỏ qua đúng không?
- Event lệch thứ tự có làm trạng thái đi lùi không?
- Consumer ack trước hay sau side effect?
- Lỗi tạm thời có retry không?
- Lỗi vĩnh viễn có bị retry vô hạn không?
- DLQ có hoạt động không?
- DLQ có alert/owner không?
- Event schema có contract test không?
- Consumer xử lý được event version cũ không?
- Replay có an toàn không?
- Side effect có bị tắt hoặc kiểm soát khi replay không?
- Read model có test bằng chuỗi event không?
- Workflow timeout có được phát hiện không?
- Có reconciliation job không?
- Broker behavior quan trọng có integration test không?
- Test có dùng sleep cứng quá nhiều không?
- Consumer lag có đo được không?
- Invalid event có bị cô lập không?
Nếu câu trả lời chủ yếu là "chưa biết", hệ thống event-driven vẫn còn nhiều giả định nguy hiểm.
---
58.38. Bảng nhìn nhanh
| Rủi ro | Test nên có | |---|---| | Event trùng | Xử lý cùng event hai lần, side effect chỉ một lần | | Event đến muộn | Event cũ không ghi đè trạng thái mới | | Event lệch thứ tự | State không đi lùi, consumer không crash | | Event mất | Outbox/reconciliation/workflow timeout | | Retry | Retry lỗi tạm thời, không retry lỗi vĩnh viễn | | Poison message | Invalid event vào DLQ, không kẹt queue | | Replay | Không gửi side effect thật, không tạo dữ liệu trùng | | Schema đổi | Contract test và test event version cũ | | Read model sai | Test bằng chuỗi event và replay | | Consumer chậm | Đo consumer lag, queue age, backlog |
---
58.39. Kết luận của chương
Event-driven systems giúp hệ thống mở rộng, tách rời và xử lý bất đồng bộ tốt hơn.
Nhưng đổi lại, testing phải trưởng thành hơn.
Ta không thể giả định event luôn đến đúng một lần.
Ta không thể giả định event luôn đúng thứ tự.
Ta không thể giả định event luôn đến ngay.
Ta không thể giả định mọi consumer luôn chạy khỏe.
Vì vậy test event-driven system cần tập trung vào:
Duplicate.
Late event.
Out-of-order event.
Retry.
Idempotency.
DLQ.
Replay.
Schema evolution.
Eventual consistency.
Reconciliation.
Consumer nên được test độc lập.
Broker behavior quan trọng nên có integration test.
E2E chỉ nên giữ cho các luồng sống còn.
Monitoring vẫn cần để nhìn production thật.
Thông điệp cần nhớ:
> Event-driven system không hỏng vì event tồn tại. Nó hỏng vì ta giả định event cư xử như lời gọi hàm đồng bộ. Hãy test consumer như thể production sẽ gửi event trùng, muộn, lệch thứ tự, và retry bất cứ lúc nào.
Ở chương tiếp theo, ta sẽ nói về deploy an toàn: feature flag, canary, shadow traffic, parallel run, rollback, migration có quan sát, và vì sao không nên deploy thay đổi lớn khi không có đường quay lại.