Coverage 100% mà vẫn không chạy
Một tính năng chết hẳn trong sản phẩm, trong khi 178 test xanh và coverage vẫn 100%. Coverage đo dòng đã chạy, chứ không đo đường mà sản phẩm thật sự đi.
Chuyện có thật, và nó có số
Tôi để dành câu chuyện này tới cuối series, vì nó là thứ tôi muốn bạn nhớ nhất.
Một harness tôi đọc có thư mục hậu kiểm. Bài đầu tiên kể chuyện: server ACP crash ngay khi một editor thật kết nối vào. Không phải hỏng một phần — hỏng hẳn. Hai RPC đầu tiên mà editor gọi đều trả lỗi.
Nguyên văn:
The bridge was completely non-functional in production despite 178 green unit tests and 100% line coverage.— dsh, docs/postmortem/0001-acp-default-export-drops-inject.md:13
178 test xanh. Coverage 100%. Tính năng không chạy một giây nào.
Đọc câu đó lần đầu, tôi nghĩ chắc có gì gian lận — test giả, hoặc coverage đo sai. Không phải. Mọi thứ đều thật. Test thật, chạy thật, xanh thật.
Vấn đề nằm ở chỗ khác, và tài liệu nói thẳng:
Every test mounted the plugin through a path that did not exercise how it actually loads or how its services actually resolve.— dsh, docs/postmortem/0001-acp-default-export-drops-inject.md
Mọi test đều gắn plugin bằng một đường khác với đường nó thực sự được nạp.
Hai đường, một cái tên
Đây là cơ chế, và nó đáng đọc chậm.
Ứng dụng nạp plugin qua một bộ nạp thật. Bộ nạp đó gọi một hàm xử lý export trước khi dùng. Bộ test trong bộ nhớ thì không. Nó tự dựng đối tượng plugin bằng tay, và tự điền sẵn trường mà bộ nạp thật sẽ điền.
ctx.plugin({ name, inject, apply })// `inject` được điền tay ở đây.// Hàm xử lý export chỉ được bộ nạp gọi, không bao giờ được ctx.plugin gọi.Lỗi trong sản phẩm nằm ở chỗ bộ nạp xử lý export. Bộ test đi vòng qua đúng chỗ đó. Nên nó không bao giờ chạm tới lỗi — dù nó chạy qua mọi dòng code xung quanh.
Tài liệu còn nói thêm một chi tiết làm tôi dừng lại: kể cả gọi ctx.plugin với namespace import cũng không bắt được lỗi. Vì đường đi vẫn khác.
Và lỗi thứ hai cũng cùng một kiểu. Một lần tra cứu service đi qua một ranh giới bóng. Bộ test gắn mọi thứ phẳng trên một context gốc, nên không bao giờ đi qua ranh giới đó.
Hai lỗi độc lập. Cùng một lý do lọt.
Bài e2e duy nhất, và cái khoá
Đến đây bạn sẽ hỏi: thế không có bài test đầu-cuối nào à?
Có. Hai bài, và cả hai đều không cứu được.
Bài thứ nhất gửi một lệnh khởi tạo rồi kiểm tra stdout sạch. Nghe hợp lý. Nhưng lệnh đó không bao giờ chạm tới nhà máy nơi lỗi nằm. Nó đi qua một nhánh khác.
Bài thứ hai mới là bài chạm đúng chỗ. Và nó bị khoá sau API key — nên CI, vốn không có key, bỏ qua nó.
Còn khi chạy ở máy local thì nó “xanh”. Tài liệu giải thích vì sao, và đây là chi tiết tôi thấy rùng mình:
The only test that drove session/new/session/load was key-gated, so CI (no key) skipped it — and locally it “passed” only because a stale built lib/ (with the old code) happened to satisfy module resolution.— dsh, docs/postmortem/0001-acp-default-export-drops-inject.md
Một thư mục lib/ cũ còn sót lại. Nó thoả mãn việc phân giải module. Nên bài test chạy trên code cũ, và code cũ thì không có lỗi.
Bạn thấy cái bẫy chưa: test xanh vì nó đang test một phiên bản khác với phiên bản bạn sắp ship.
Câu chốt
Rồi tài liệu nói câu mà tôi muốn đóng khung:
100% line coverage was satisfied the whole time. Coverage proves lines ran; it says nothing about whether the feature works the way it ships.— dsh, docs/postmortem/0001-acp-default-export-drops-inject.md
Coverage chứng minh các dòng đã chạy. Nó không nói gì về việc tính năng có hoạt động theo đúng cách nó được ship hay không.
Suốt thời gian đó, con số 100% vẫn đúng. Không có gì gian lận. Chỉ là con số đó trả lời một câu hỏi khác với câu hỏi bạn tưởng nó trả lời.
Xem dữ liệu diagram
type: architecture
title: Hai đường nạp plugin song song
desc: Đường test tự điền trường tạo ảo tưởng xanh; đường ship thật qua bộ nạp dẫn tới lỗi runtime
nodes:
- id: testpath
label: Đường test (ctx.plugin)
sub: Tự điền inject bằng tay
- id: shippath
label: Đường ship thật
sub: Bộ nạp xử lý export mặc định
kind: security
focal: true
- id: green
label: 178 test xanh (100%)
sub: Ảo tưởng an toàn
- id: dead
label: Crash trong sản phẩm
sub: Không tìm thấy inject lúc chạy
focal: true
edges:
- [testpath, green]
- [shippath, dead]Họ sửa bằng gì
Phần này mới là phần đáng bắt chước, không phải phần kể lỗi.
Họ không viết thêm test cho hàm bị lỗi. Họ sửa đường đi của bài test:
- Một bài e2e mới chạy
session/newqua stdio thật, khởi động cả profile như một tiến trình con qua bộ nạp thật. - Bài đó không cần API key, nên CI chạy nó thay vì bỏ qua.
- Và họ chỉ cho tiến trình con tới đúng file cấu hình ở gốc repo, để việc phân giải module không phụ thuộc thư mục làm việc — nhờ đó bài test chạy trên mã nguồn, không phải trên một bản build có thể đã cũ.
Rồi bước cuối, và đây là bước tôi muốn bạn nhớ:
This fails loudly on Bug #1 with no API key. Verified it fails when export default apply is restored.— dsh, docs/postmortem/0001-acp-default-export-drops-inject.md
Họ gieo lại lỗi cũ và xác nhận bài test mới đỏ lên.
Một test chưa từng đỏ là một test chưa chứng minh được gì. Nó chỉ là một dòng code xanh.
Cái giá của kỷ luật
Harness đó giữ kỷ luật ở mức tôi chưa thấy ở đâu khác.
Cổng coverage là 100% mỗi file — không phải 100% toàn repo. Nghĩa là một file lớn được test kỹ không bù được cho một file nhỏ không ai test. Comment trong file cấu hình nói gọn: “100% or it doesn’t merge.”
Và họ giữ lại cả những quyết định đã bị loại. Đếm ở đúng commit tôi đọc:
| Loại ghi chép | Số lượng |
|---|---|
| Đã triển khai | 522 |
| Đang đề xuất | 40 |
| Bị từ chối | 14 |
| Đã lưu trữ | 642 |
| Tổng | 1.218 |
Mười bốn quyết định bị từ chối vẫn được viết ra và lưu lại. Cộng thêm 4 bài hậu kiểm và 69 script kiểm tra.
Đó là một mức kỷ luật mà repo nhỏ không kham nổi. Và đây là chỗ tôi phải nói thẳng: bạn không nên bắt chước nó.
Một mình bạn viết blog và dựng một harness nhỏ thì 100% mỗi file là bất khả thi. Thứ đáng bắt chước không phải con số, mà là hướng của cổng: cổng phải chạy đúng đường mà sản phẩm chạy.
Nối lại cả series
Mười bài trước đều là về cơ chế. Bài này là về chỗ cơ chế đó có thể đúng mà vẫn vô dụng.
Nhớ lại bài 1: model là một hàm không nhớ gì. Mọi thứ nó biết đến từ ngữ cảnh bạn dựng. Nếu đường dựng ngữ cảnh có lỗi mà test không chạm tới, model sẽ trả lời sai một cách tự tin.
Nhớ lại bài 4: vòng lặp chạy được cho tới lúc không. Bốn nhánh vỡ. Một cổng test chỉ đi qua nhánh thành công sẽ không bao giờ thấy ba nhánh còn lại.
Và đây là điều nối chúng lại. Mọi mảnh trong series này đều có một đường sản phẩm và một đường test. Sổ ghi, đường ống tool, cắt ngữ cảnh, quyền — mảnh nào cũng vậy.
Hai đường đó giống nhau tới mức bạn tưởng chúng là một.
Chúng không phải một. Và khoảng cách giữa chúng là chỗ lỗi sống.
Làm được ngay
- Hỏi mỗi test đang đi đường nàoVới từng bài test, hỏi: nó dựng đối tượng bằng tay hay để hệ thống thật dựng? Nếu là tay, nó không test đường nạp.
- Đừng để bài e2e quan trọng nhất bị khoá sau bí mậtMột bài test cần API key là một bài CI sẽ bỏ qua. Nếu nó là bài duy nhất chạm đường thật, bạn đang không có cổng nào.
- Ghim việc phân giải module vào gốc repoĐể nó không phụ thuộc thư mục làm việc. Nếu không, bài test có thể chạy trên một bản build cũ và xanh vì lý do sai.
- Gieo lại lỗi để chứng minh test bắt được nóSửa xong, khôi phục lỗi cũ và xem test có đỏ. Một test chưa từng đỏ chưa chứng minh được gì.
Nguyên tắc
Cả series này nói về việc dựng đúng cơ chế. Bài cuối nói về việc chứng minh cơ chế đó chạy — và đó là việc khó hơn.
Test xanh là điều kiện cần. Nó chưa bao giờ là điều kiện đủ. Một test chưa từng đỏ là một test bạn chưa kiểm chứng được gì.— tusgino
- 178 test xanh và coverage 100% vẫn không cứu được một tính năng chết hẳn trong sản phẩm.
- Mọi test gắn plugin bằng tay, đi vòng qua đúng chỗ bộ nạp thật xử lý — nên không bao giờ chạm tới lỗi.
- Bài e2e duy nhất chạm đường thật bị khoá sau API key; CI bỏ qua, còn local “xanh” nhờ một thư mục build cũ.
- Cách sửa đúng là sửa đường đi của bài test, rồi gieo lại lỗi để chứng minh nó đỏ lên.
- Cổng 100% mỗi file cùng 1.218 ghi chép quyết định là kỷ luật đáng nể — và không bắt chước được nếu bạn làm một mình.