Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 13 — Testing Pyramid trong CI

Đã đọc 0/14 mục · còn khoảng 12 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn vừa gia nhập một team phát triển sản phẩm fintech ở TP.HCM. Team có gần 400 bài test tự động, ai cũng tự hào vì "coverage cao". Nhưng mỗi lần merge một pull request, pipeline CI chạy mất 47 phút. Tệ hơn, cứ 3 lần chạy thì có 1 lần đỏ (fail) — không phải vì code sai, mà vì test "chập chờn": lúc pass, lúc fail dù chẳng ai đụng vào gì. Kết quả là developer bắt đầu có thói quen bấm "Re-run" một cách vô thức, và dần dần chẳng ai còn tin vào màu đỏ của pipeline nữa.

Đây là một trong những căn bệnh phổ biến nhất của CI: có nhiều test không đồng nghĩa với có CI tốt. Vấn đề không nằm ở số lượng, mà ở cơ cấu — bạn có bao nhiêu test loại nào, chúng chạy nhanh hay chậm, ổn định hay dễ vỡ, và feedback quay về với developer trong bao lâu.

Testing Pyramid (kim tự tháp kiểm thử) là mô hình tư duy giúp bạn thiết kế cơ cấu test đó một cách có chủ đích. Trong bối cảnh CI/CD — nơi mọi commit đều kích hoạt một chuỗi kiểm thử tự động — kim tự tháp không chỉ là lý thuyết đẹp trên slide, mà là công cụ quyết định pipeline của bạn chạy 5 phút hay 50 phút, tin cậy hay lởm khởm. Bài này tập trung riêng vào cách bố trí các tầng test trong pipeline CI: tầng nào chạy khi nào, tỷ lệ ra sao, và làm sao để feedback loop luôn nhanh và đáng tin.

Khái niệm cốt lõi

Kim tự tháp của Mike Cohn

Mô hình Testing Pyramid được Mike Cohn giới thiệu trong cuốn Succeeding with Agile (2009). Ý tưởng trực quan: hình dung các loại test xếp thành một kim tự tháp.

        /\
       /E2E\          ← ít (chậm, đắt, dễ vỡ)
      /------\
     / Integr.\       ← vừa phải
    /----------\
   /   Unit     \     ← nhiều (nhanh, rẻ, ổn định)
  /--------------\
  • Đáy — Unit tests (kiểm thử đơn vị): kiểm tra một hàm, một class, một đơn vị logic nhỏ nhất một cách cô lập. Không đụng database, không gọi mạng, không đọc file. Chạy trong mili-giây. Đây là tầng chiếm số lượng lớn nhất.
  • Giữa — Integration tests (kiểm thử tích hợp): kiểm tra sự phối hợp giữa các thành phần — ví dụ tầng service gọi database thật, hoặc gọi một API nội bộ. Chậm hơn unit test vì có I/O thật. Số lượng vừa phải.
  • Đỉnh — End-to-End tests (kiểm thử đầu-cuối, E2E): mô phỏng hành vi người dùng thật, chạy qua toàn bộ hệ thống (trình duyệt → frontend → backend → database). Chậm nhất, đắt nhất, dễ vỡ nhất. Số lượng phải ít.
Nguyên tắc cốt lõi: càng lên cao, test càng chậm, càng đắt để viết/bảo trì, và càng dễ "flaky" (chập chờn) — nên càng ít. Càng xuống thấp, test càng nhanh, rẻ, ổn định — nên càng nhiều.

Vì sao hình dạng lại quan trọng với CI?

Trong CI, mỗi tầng test đóng một vai trò về tốc độ feedback:

  • Unit test cho feedback trong vài giây đến vài chục giây. Developer biết mình vừa làm hỏng logic gần như ngay lập tức.
  • Integration test cho feedback trong vài phút. Bắt được lỗi ở "đường nối" giữa các module.
  • E2E test cho feedback trong vài phút đến vài chục phút, nhưng bù lại xác nhận cả luồng nghiệp vụ thực sự hoạt động.
Một quy tắc kinh nghiệm hữu ích để định lượng: tỷ lệ ~70% unit / ~20% integration / ~10% E2E. Con số này không thiêng liêng, nhưng nó nhắc bạn giữ đáy rộng và đỉnh hẹp.

Hai phản mô hình cần tránh: Ice Cream Cone và Hourglass

Khi cơ cấu test bị lộn ngược, người ta gọi đó là Ice Cream Cone (ly kem ốc quế) — nhiều E2E ở trên, ít unit ở dưới:

  \--------------/   ← rất nhiều E2E / manual test
   \  E2E/Manual /
    \----------/
     \ Integr./
      \------/
       \Unit/         ← rất ít unit
        \/

Đây chính là căn bệnh của team fintech ở đầu bài. Hậu quả: pipeline chậm, chập chờn, tốn tiền hạ tầng, và khi test fail thì rất khó biết lỗi nằm ở đâu (vì một E2E test đi qua hàng chục thành phần).

Một phản mô hình khác là Hourglass (đồng hồ cát): nhiều unit, nhiều E2E, nhưng thiếu integration ở giữa. Team này bỏ qua tầng tích hợp, dẫn tới việc unit test "xanh" hết nhưng khi ghép các module lại thì vỡ, và họ phải dựa vào E2E để bắt lỗi tích hợp — vừa chậm vừa khó debug.

Testing Trophy — biến thể hiện đại

Bạn sẽ nghe nhiều đội frontend/JavaScript nhắc tới Testing Trophy (Kent C. Dodds đề xuất), nhấn mạnh tầng integration hơn. Với các ứng dụng web hiện đại, "integration test viết theo góc nhìn người dùng nhưng không chạy trình duyệt thật" cho tỷ lệ chi phí/lợi ích rất tốt. Điểm mấu chốt vẫn không đổi: giữ đỉnh E2E hẹp, đừng để test chậm chi phối pipeline. Kim tự tháp là kim chỉ nam, không phải luật cứng — bạn điều chỉnh tỷ lệ theo loại sản phẩm.

Tình huống thực tế

Tình huống 1 — Sàn TMĐT "ChợViệt" và pipeline 47 phút

Một công ty TMĐT giả định tên ChợViệt ở Hà Nội có 380 test, trong đó ~240 là E2E chạy bằng Selenium mô phỏng người mua đặt hàng. Pipeline CI chạy trung bình 47 phút, tỷ lệ flaky khoảng 30%.

Đội DevOps ngồi lại phân tích: phần lớn E2E test thực chất chỉ để kiểm tra logic tính giá khuyến mãi và validate form — những thứ hoàn toàn có thể test ở tầng unit. Họ tiến hành đảo ngược kim tự tháp về đúng hình dạng: viết lại logic tính giá thành 90 unit test chạy trong 8 giây, chuyển phần kiểm tra tương tác API giỏ hàng xuống ~40 integration test, và chỉ giữ lại 12 E2E cho những luồng doanh thu quan trọng nhất (đăng nhập, thêm giỏ, thanh toán, xác nhận đơn).

Kết quả sau 6 tuần: pipeline giảm còn 9 phút, tỷ lệ flaky xuống dưới 4%. Quan trọng hơn, số lần developer bấm "Re-run" giảm hẳn, và niềm tin vào pipeline quay trở lại.

Bài học: phần lớn thứ mọi người viết bằng E2E có thể được kiểm tra rẻ hơn nhiều ở tầng thấp. Hãy hỏi với mỗi test E2E: "Logic này có bắt buộc phải chạy qua trình duyệt không, hay chỉ cần một unit test là đủ?"

Tình huống 2 — Startup logistics và cái bẫy "coverage 100%"

Một startup logistics ở Singapore đặt KPI cứng: mọi PR phải đạt coverage 100% mới được merge. Nghe rất "chuẩn", nhưng thực tế đội ngũ bắt đầu viết những unit test vô nghĩa chỉ để "phủ dòng code" — test gọi hàm rồi assert những thứ luôn đúng, không kiểm tra hành vi thực. Coverage đạt 100% nhưng một bug tính sai phí ship theo vùng vẫn lọt ra production, gây thất thoát khoảng 12.000 USD trong một tuần trước khi bị phát hiện.

Vấn đề: họ đo số lượng (coverage) thay vì chất lượng (test có thực sự bắt được lỗi không). Đội sau đó bỏ KPI 100%, chuyển sang đặt ngưỡng coverage hợp lý (~75%) nhưng bổ sung integration test cho các quy tắc tính phí — nơi bug hay xảy ra nhất.

Bài học: coverage là chỉ báo, không phải mục tiêu. Kim tự tháp cân đối quan trọng hơn con số coverage tuyệt đối. Một đáy unit rộng nhưng test rỗng thì vô dụng.

Tình huống 3 — Team nội bộ ngân hàng và "test đúng tầng"

Một team nội bộ tại một ngân hàng ở Việt Nam xây API phê duyệt khoản vay. Họ áp dụng kim tự tháp có kỷ luật: quy tắc tính điểm tín dụng (hàm thuần, nhiều nhánh if/else) được phủ bởi hơn 200 unit test chạy dưới 5 giây; phần ghi/đọc hồ sơ vào PostgreSQL được phủ bởi ~35 integration test chạy với database thật trong container; và chỉ có 8 E2E kiểm tra luồng "nộp hồ sơ → phê duyệt → nhận thông báo".

Khi một quy tắc điểm tín dụng bị sửa sai, unit test đỏ ngay trong 5 giây — developer sửa trước cả khi kịp uống ngụm cà phê. So với việc phải đợi E2E 20 phút mới biết, đây là khác biệt sống còn về năng suất.

Bài học: đặt test đúng tầng giúp bạn nhận feedback sớm nhất có thể và khoanh vùng lỗi chính xác. Lỗi logic thì bắt ở unit; lỗi "đường nối" thì bắt ở integration; lỗi luồng nghiệp vụ thì để dành cho E2E.

Hướng dẫn từng bước

Dưới đây là cách bố trí kim tự tháp vào một pipeline CI thực tế.

Bước 1 — Kiểm kê cơ cấu test hiện tại. Đếm xem bạn đang có bao nhiêu test mỗi loại và mỗi tầng chạy mất bao lâu. Vẽ ra "hình dạng" thật của bạn: kim tự tháp, ly kem, hay đồng hồ cát? Đây là điểm xuất phát bắt buộc — bạn không thể sửa thứ mình chưa đo.

Bước 2 — Chia pipeline thành các stage (giai đoạn) theo tầng. Nguyên tắc "fail fast": chạy tầng nhanh trước, tầng chậm sau. Nếu unit test đã đỏ thì không cần tốn tài nguyên chạy E2E.

# Ví dụ minh họa với GitHub Actions
jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:unit   # ~30s, chạy song song, không cần service

  integration:
    needs: unit                  # chỉ chạy khi unit đã pass
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:integration

  e2e:
    needs: integration           # chạy sau cùng
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:e2e     # chỉ vài luồng quan trọng nhất

Bước 3 — Tách test nhanh khỏi test chậm để chạy song song. Unit test không phụ thuộc lẫn nhau nên hãy chạy chúng song song (sharding) để rút ngắn thời gian. Hầu hết framework (Jest, Pytest-xdist, JUnit) đều hỗ trợ chia test ra nhiều worker.

Bước 4 — Đặt "ngân sách thời gian" cho từng stage. Ví dụ: unit ≤ 2 phút, integration ≤ 5 phút, E2E ≤ 10 phút. Khi một stage vượt ngân sách, đó là tín hiệu cần tối ưu hoặc xem lại cơ cấu, không phải chờ đợi thụ động.

Bước 5 — Quyết định tầng nào chạy khi nào. Một chiến lược phổ biến: unit + integration chạy trên mọi commit/PR; E2E đầy đủ chạy khi merge vào nhánh chính hoặc theo lịch nightly. Cách này giữ feedback loop trên PR luôn nhanh mà vẫn đảm bảo luồng nghiệp vụ được kiểm tra kỹ định kỳ.

Bước 6 — Xử lý flaky test như một hạng mục ưu tiên. Đưa các E2E chập chờn vào diện theo dõi (quarantine): tách chúng ra khỏi luồng chặn merge, gắn nhãn, và giao người sửa. Một pipeline "xanh giả" (do bấm re-run đủ nhiều) còn nguy hiểm hơn pipeline đỏ thật.

Bước 7 — Theo dõi và điều chỉnh liên tục. Định kỳ (ví dụ mỗi sprint) nhìn lại tỷ lệ các tầng và thời gian pipeline. Kim tự tháp không phải làm một lần rồi thôi — nó là một trạng thái cân bằng cần được duy trì khi codebase lớn dần.

Lỗi thường gặp & mẹo

Lỗi 1 — Kim tự tháp lộn ngược (Ice Cream Cone). Đây là lỗi số một. Nguyên nhân thường vì "test qua UI cho chắc" hoặc đội thiếu kỹ năng viết unit test. Mẹo: mỗi khi định viết một E2E test, tự hỏi "logic này có test được ở tầng thấp hơn không?". Nếu có, hãy đẩy nó xuống.

Lỗi 2 — Nhầm lẫn unit test với integration test. Nhiều "unit test" thực chất gọi database thật hoặc HTTP thật, nên chậm và dễ vỡ. Unit test đúng nghĩa phải cô lập — dùng mock/stub cho phụ thuộc bên ngoài. Mẹo: nếu một "unit test" cần bật Docker mới chạy được, nó là integration test.

Lỗi 3 — Bỏ hoàn toàn tầng E2E. Kim tự tháp bảo ít E2E, không phải không có. Vài luồng nghiệp vụ quan trọng (đăng nhập, thanh toán) bắt buộc phải có E2E để đảm bảo toàn hệ thống thực sự hoạt động. Đừng cực đoan.

Lỗi 4 — Coi coverage là mục tiêu thay vì chỉ báo. Như tình huống startup logistics: 100% coverage vẫn lọt bug. Mẹo: đo chất lượng test bằng khả năng bắt lỗi (có thể tham khảo mutation testing) hơn là số dòng được phủ.

Lỗi 5 — Test phụ thuộc thứ tự chạy. Test A chỉ pass nếu test B đã chạy trước — đây là nguồn flaky kinh điển. Mẹo: mỗi test phải tự dọn dẹp trạng thái của mình, chạy độc lập được theo bất kỳ thứ tự nào.

Mẹo tối ưu tốc độ: chạy các stage song song khi không có phụ thuộc; dùng cache dependency; và với E2E, cân nhắc chạy trên tập con "smoke" cho mỗi PR, để lại tập đầy đủ cho nightly.

Bài tập thực hành

  • Đo hình dạng của bạn. Lấy một dự án bạn đang làm (hoặc một repo mã nguồn mở). Đếm số lượng unit / integration / E2E test và ghi lại thời gian chạy mỗi loại. Vẽ ra hình dạng thực tế. Nó là kim tự tháp, ly kem, hay đồng hồ cát?
  • Phân loại lại 5 test. Chọn 5 test bất kỳ trong dự án. Với mỗi test, xác định nó đang ở tầng nào và nên ở tầng nào. Có test nào đang được viết ở tầng E2E nhưng thực ra chỉ cần unit test không?
  • Thiết kế pipeline phân tầng. Viết một file cấu hình CI (GitHub Actions hoặc GitLab CI tùy bạn) chia thành 3 stage unit → integration → e2e với nguyên tắc fail-fast, trong đó integration cần một service database. Đặt ngân sách thời gian cho mỗi stage.
  • Săn flaky test. Nếu có quyền truy cập lịch sử CI, tìm 1 test hay fail rồi pass mà không sửa gì. Phân tích nguyên nhân (phụ thuộc thứ tự? timing? tài nguyên chung?) và đề xuất cách khắc phục hoặc chuyển nó xuống tầng thấp hơn.

Tóm tắt

  • Testing Pyramid là mô hình bố trí cơ cấu test: đáy rộng nhiều unit test (nhanh, rẻ, ổn định), giữa là integration test (vừa phải), đỉnh hẹp là E2E test (chậm, đắt, dễ vỡ). Tỷ lệ tham khảo ~70/20/10.
  • Trong CI, hình dạng kim tự tháp quyết định trực tiếp tốc độ và độ tin cậy của feedback loop. Kim tự tháp lộn ngược (Ice Cream Cone) khiến pipeline chậm, chập chờn và tốn kém.
  • Bố trí test đúng tầng giúp bắt lỗi sớm nhất và khoanh vùng chính xác: logic → unit, đường nối → integration, luồng nghiệp vụ → E2E.
  • Trong pipeline, hãy chia stage theo tầng, chạy nhanh trước (fail fast), đặt ngân sách thời gian, và xử lý flaky test như hạng mục ưu tiên.
  • Coverage là chỉ báo, không phải mục tiêu. Cơ cấu test cân đối và bắt được lỗi thật quan trọng hơn con số phần trăm.
Nắm vững kim tự tháp không phải để tuân thủ máy móc một tỷ lệ, mà để bạn luôn tự hỏi đúng câu: "Test này có đang ở đúng tầng để cho tôi feedback nhanh nhất và đáng tin nhất không?" Đó là tư duy nền tảng cho mọi pipeline CI/CD khỏe mạnh mà bạn sẽ tiếp tục vận dụng ở các bài sau về code quality gate và tối ưu hiệu năng pipeline.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi