Product Management
Đăng nhập
ESC

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

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

Parallel Test Execution

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

Hãy tưởng tượng bạn là QA của một sàn thương mại điện tử tại TP.HCM. Sau hai năm, đội của bạn đã xây được 1.200 test tự động cho web. Nghe thì oai, nhưng mỗi lần chạy toàn bộ suite, cả team phải chờ… gần 8 tiếng. Kết quả: không ai dám chạy full suite trước khi merge. Người ta chỉ chạy "vài test liên quan", rồi cầu nguyện. Và tất nhiên, bug production vẫn lọt.

Vấn đề ở đây không phải là test viết dở. Vấn đề là bạn đang chạy chúng tuần tự (sequential) — hết test này mới tới test kia, như một hàng người xếp hàng mua bánh mì. 1.200 test × 30 giây/test = 36.000 giây ≈ 10 tiếng nếu tính cả overhead. Con số này giết chết mọi lợi ích mà automation hứa hẹn: feedback nhanh.

Đây chính là lý do Parallel Test Execution (thực thi test song song) tồn tại. Nếu bạn chạy 1.200 test đó trên 10 "làn" (worker) cùng lúc, thời gian lý thuyết giảm còn khoảng 1 tiếng. Chạy trên 30 worker, còn khoảng 20 phút. Từ "không ai dám chạy" thành "chạy trong mỗi pull request" — đó là sự khác biệt giữa một automation suite chết và một automation suite thực sự tạo giá trị.

Trong bài này, chúng ta sẽ đi sâu vào cách chạy test song song một cách đúng đắn: từ cơ chế bên dưới, Selenium Grid, phân chia test (sharding), cho tới những cái bẫy khiến parallel execution biến thành cơn ác mộng flaky test. Đây là kỹ năng bắt buộc cho bất kỳ ai muốn scale automation lên quy mô thật.

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

Song song (parallel) khác gì tuần tự (sequential)?

Chạy tuần tự: một tiến trình chạy test 1, xong test 2, xong test 3… Tổng thời gian = tổng thời gian từng test.

Chạy song song: bạn có N "worker" (tiến trình, thread, hoặc container), mỗi worker nhận một phần test và chạy đồng thời. Tổng thời gian ≈ (thời gian test lâu nhất trong một worker), lý tưởng là tổng / N.

Công thức ước lượng đơn giản (bỏ qua overhead):

Thời gian parallel ≈ Thời gian sequential / Số worker + Overhead khởi động

Chữ "Overhead" ở cuối rất quan trọng — ta sẽ nói kỹ ở phần lỗi thường gặp. Việc tăng worker không giảm thời gian tuyến tính mãi mãi; đến một điểm, chi phí khởi động browser, phân phối test, và tài nguyên máy sẽ ăn hết lợi ích.

Các cấp độ song song

Có ba cấp độ bạn cần phân biệt rõ:

  • Song song ở cấp test/method — nhiều test chạy cùng lúc trong cùng một máy, mỗi test một thread. Nhanh nhưng dễ đụng nhau về state.
  • Song song ở cấp file/class — mỗi file test chạy trên một worker riêng, nhưng các test bên trong một file vẫn tuần tự. Đây là mặc định của nhiều framework hiện đại (pytest-xdist, Jest, Playwright) vì an toàn hơn.
  • Song song ở cấp máy/node (distributed) — test được chia ra nhiều máy vật lý hoặc container khác nhau. Đây là lúc Selenium Grid hoặc cloud grid vào cuộc.

Điều kiện tiên quyết: test phải ĐỘC LẬP

Đây là nguyên tắc vàng, tôi nhấn mạnh vì 90% sự cố parallel đến từ đây: mỗi test phải chạy được độc lập, không phụ thuộc thứ tự, không dùng chung state với test khác.

Nếu test A tạo user "admin@test.com" rồi test B giả định user đó đã tồn tại, khi chạy song song, B có thể chạy trước A và fail. Nếu hai test cùng sửa một record trong database, chúng sẽ giẫm chân nhau. Test độc lập nghĩa là: mỗi test tự chuẩn bị dữ liệu của mình (thường qua unique data — email có timestamp, order ID ngẫu nhiên), tự dọn dẹp, và không giả định gì về môi trường ngoài fixture của chính nó.

Selenium Grid — song song cho UI test

Với UI automation, mỗi test cần một browser thật. Bạn không thể mở 30 Chrome trên một laptop mà không sập máy. Selenium Grid giải quyết bằng kiến trúc Hub–Node:

  • Hub (Selenium 4 gọi là Router/Distributor): điểm điều phối, nhận request "tôi cần một Chrome" và phân bổ.
  • Node: máy thực thi, đăng ký với Hub và báo "tôi có 5 slot Chrome, 3 slot Firefox".
Test của bạn không nói chuyện trực tiếp với browser local nữa, mà kết nối tới Hub qua RemoteWebDriver:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options

options = Options() options.add_argument("--headless=new")

driver = webdriver.Remote( command_executor="http://grid-hub.mycompany.local:4444/wd/hub", options=options ) driver.get("https://shop.example.vn")

... test logic ...

driver.quit()

Điểm hay: code test gần như không đổi, chỉ thay webdriver.Chrome() bằng webdriver.Remote(...). Grid lo phần phân phối. Bạn chạy 30 test song song, Grid tự tìm 30 slot trống trên các node, thực thi, trả kết quả.

Sharding — cắt suite thành nhiều mảnh

Ở tầng CI, khái niệm quan trọng là sharding: chia toàn bộ test suite thành các mảnh (shard) và giao mỗi mảnh cho một CI runner/agent khác nhau chạy đồng thời. Ví dụ, Playwright hỗ trợ sẵn:

Máy CI 1

npx playwright test --shard=1/4

Máy CI 2

npx playwright test --shard=2/4

... và cứ thế tới 4/4

Bốn runner chạy đồng thời, mỗi runner một phần tư số test. Đây là song song ở cấp hạ tầng CI, kết hợp với song song bên trong mỗi runner (mỗi runner lại chạy nhiều worker). Hai tầng song song nhân với nhau cho tốc độ khủng khiếp.

Tình huống thực tế

Tình huống 1: Sàn TMĐT giảm regression từ 8 tiếng xuống 22 phút

Một đội QA tại một sàn TMĐT ở Hà Nội (giả định gọi là "ShopViet") có 1.400 UI test Selenium. Chạy tuần tự trên một agent Jenkins: 8 giờ 10 phút. Hệ quả là họ chỉ chạy regression một lần mỗi đêm, và sáng hôm sau mới biết build hỏng — quá trễ.

Họ triển khai theo hai bước. Đầu tiên, dựng Selenium Grid trên 4 con máy ảo, mỗi máy chạy Docker với 6 slot Chrome headless — tổng 24 slot song song. Thứ hai, ở tầng Jenkins, họ dùng pytest-xdist với -n 6 trên mỗi trong 4 agent, và shard suite ra 4 phần.

Kết quả: 24 test chạy đồng thời liên tục. Regression từ 8 giờ 10 phút xuống còn 22 phút. Điều quan trọng hơn cả tốc độ: giờ họ chạy full regression trong mỗi pull request thay vì mỗi đêm. Bug bị bắt trước khi merge, không phải sau khi lên production.

Bài học: Tốc độ không chỉ là "chờ ít hơn" — nó thay đổi hành vi của cả team. Feedback dưới 30 phút biến regression từ "việc chạy hằng đêm" thành "việc chạy mỗi commit".

Tình huống 2: Fintech và cơn ác mộng shared database

Một công ty fintech ở Singapore mở rộng test API cho luồng ví điện tử. Họ bật pytest-xdist -n 8, kỳ vọng nhanh gấp 8. Thực tế: test pass/fail ngẫu nhiên, tỷ lệ flaky lên tới 30%. Chạy lại thì có khi pass, có khi fail — kinh điển của flaky.

Điều tra ra, nhiều test cùng thao tác trên một tài khoản test cố định wallet_test_001. Test A nạp 100.000đ rồi assert số dư = 100.000đ. Nhưng test B (chạy song song) cũng vừa trừ 50.000đ từ cùng tài khoản đó. Assert của A fail vì số dư thực tế là 50.000đ. Chúng giẫm chân nhau trên shared state.

Cách sửa: mỗi test tạo một ví riêng với ID ngẫu nhiên (wallet_{uuid4()}) trong fixture setup, và teardown xóa đi. Không còn dữ liệu dùng chung. Tỷ lệ flaky rơi về gần 0%.

Bài học: Parallel execution không tạo ra bug — nó phơi bày những phụ thuộc ẩn mà chạy tuần tự đã che giấu. Nếu bật parallel mà flaky tăng vọt, đừng đổ lỗi cho parallel; hãy đi tìm shared state.

Tình huống 3: Giới hạn hạ tầng — thêm worker không phải lúc nào cũng nhanh hơn

Một startup edtech ở TP.HCM chạy test trên GitHub Actions runner tiêu chuẩn (2 vCPU, 7 GB RAM). Kỹ sư QA hào hứng đẩy -n 16 nghĩ rằng nhanh gấp 16. Kết quả ngược đời: suite chậm hơn so với -n 4, và một số test timeout.

Lý do: máy chỉ có 2 nhân CPU. 16 worker giành nhau CPU và RAM, mỗi worker lại mở một Chrome headless ngốn ~300 MB. Máy swap liên tục, mọi thứ chậm rề. Đây là hiện tượng "oversubscription" — bạn tạo nhiều worker hơn tài nguyên máy chịu nổi.

Họ chỉnh về -n 4 (đúng bằng số vCPU × 2), thời gian ổn định và nhanh hơn hẳn. Khi cần nhanh hơn nữa, họ chuyển sang shard trên nhiều runner GitHub Actions thay vì nhồi worker vào một máy.

Bài học: Song song bị chặn bởi tài nguyên vật lý. Số worker tối ưu thường xoay quanh số nhân CPU (với test nặng CPU) hoặc cao hơn chút (với test I/O-bound như API). Đừng đoán — hãy đo.

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

Đây là lộ trình thực dụng để đưa parallel execution vào suite của bạn.

Bước 1 — Kiểm tra tính độc lập của test. Trước khi song song hóa bất cứ thứ gì, chạy suite của bạn theo thứ tự ngẫu nhiên ở chế độ tuần tự. Với pytest, dùng pytest-randomly. Nếu suite fail khi đảo thứ tự, bạn có test phụ thuộc thứ tự — phải sửa trước, nếu không parallel sẽ hỗn loạn.

Bước 2 — Đảm bảo dữ liệu độc lập. Rà soát mọi email, username, order ID, tài khoản test cố định. Thay bằng dữ liệu duy nhất: user_{uuid4()}@test.vn, order ID có timestamp. Mỗi test tự tạo và tự dọn dữ liệu của mình trong fixture.

Bước 3 — Bật song song trong máy (in-process). Bắt đầu nhỏ với framework bạn đang dùng:

Python

pytest -n 4 # pytest-xdist, 4 worker

JavaScript

npx playwright test --workers=4

Chạy -n 2 trước, xác nhận vẫn xanh, rồi tăng dần. Quan sát tỷ lệ flaky tăng hay không sau mỗi lần tăng worker.

Bước 4 — Dựng Selenium Grid (nếu là UI test số lượng lớn). Cách nhanh nhất là Docker Compose:

services:
  selenium-hub:
    image: selenium/hub:4.18
    ports: ["4444:4444"]
  chrome:
    image: selenium/node-chrome:4.18
    depends_on: [selenium-hub]
    environment:
      - SE_EVENT_BUS_HOST=selenium-hub
      - SE_NODE_MAX_SESSIONS=5
    deploy:
      replicas: 4          # 4 node × 5 session = 20 slot song song

Trỏ webdriver.Remote tới http://localhost:4444/wd/hub và test của bạn tự động phân phối lên các node.

Bước 5 — Sharding ở tầng CI. Chia suite ra nhiều runner. Với GitHub Actions, dùng matrix:

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npx playwright test --shard=${{ matrix.shard }}/4

Bốn job chạy đồng thời, mỗi job một phần tư test.

Bước 6 — Gộp báo cáo. Khi test chạy trên nhiều shard, bạn có nhiều mảnh kết quả rời rạc. Cần merge chúng lại thành một report tổng (chi tiết về công cụ report như Allure sẽ ở bài riêng). Ở đây chỉ cần nhớ: mỗi shard xuất kết quả ra một artifact, một bước cuối gom lại.

Bước 7 — Đo và điều chỉnh. Ghi lại thời gian tổng ở mỗi mức worker/shard. Vẽ ra bạn sẽ thấy đường cong: giảm mạnh lúc đầu, rồi phẳng dần. Chọn điểm "đủ nhanh mà chưa lãng phí tài nguyên".

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

Lỗi 1 — Shared state ngầm. Đã nói ở tình huống 2, nhưng đáng nhắc lại vì đây là nguyên nhân số một. Các dạng shared state hay bị bỏ sót: file tạm cùng tên (/tmp/report.json), biến toàn cục trong code test, singleton connection, và đặc biệt là dữ liệu database dùng chung. Mẹo: giả định mọi test đều chạy đồng thời với mọi test khác, rồi tự hỏi "nếu hai bản của test này chạy cùng lúc, chúng có đụng nhau không?".

Lỗi 2 — Oversubscription. Nhồi worker vượt xa số nhân CPU. Mẹo khởi điểm: với UI/CPU-bound, đặt worker ≈ số nhân CPU. Với API/I/O-bound, có thể worker = 2–4× số nhân vì test dành phần lớn thời gian chờ mạng chứ không đốt CPU.

Lỗi 3 — Test dùng port/tài nguyên cứng. Nếu test khởi động một server local ở cổng cố định 8080, hai worker sẽ tranh cổng. Dùng cổng động (port = 0 để OS tự cấp) hoặc gán cổng theo worker ID (pytest-xdist cung cấp biến PYTEST_XDIST_WORKER).

Lỗi 4 — Không cách ly browser session. Trên Grid, đảm bảo mỗi test driver.quit() sạch sẽ để trả slot về. Session rò rỉ (leak) sẽ dần chiếm hết slot, khiến các test sau xếp hàng chờ và timeout.

Lỗi 5 — Log/screenshot ghi đè nhau. Khi fail, nhiều worker cùng ghi screenshot.png sẽ đè lên nhau, mất bằng chứng. Đặt tên file có test ID hoặc worker ID.

Mẹo phân bổ test thông minh: Đừng chia test theo kiểu "4 file đầu cho shard 1". Nếu shard 1 vô tình chứa toàn test chậm, nó thành nút cổ chai trong khi các shard khác đã xong từ lâu. Nhiều công cụ hỗ trợ chia theo thời gian chạy lịch sử (timing-based sharding) để cân bằng tải giữa các shard — nên bật nếu có.

Mẹo debug flaky do parallel: Khi một test fail lúc chạy song song nhưng pass khi chạy một mình, gần như chắc chắn là vấn đề cách ly. Chạy lại đúng test đó với -n 1 để xác nhận, rồi soi phần setup/teardown và dữ liệu nó dùng.

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

  • Đo baseline. Lấy một suite (hoặc tạo 20 test giả có time.sleep(2) mỗi test). Chạy tuần tự, ghi lại tổng thời gian. Đây là mốc so sánh.
  • Song song trong máy. Chạy lại suite đó với pytest -n 2, -n 4, -n 8 (hoặc Playwright --workers). Lập bảng: worker vs thời gian. Tìm điểm mà tăng worker không còn giảm thời gian đáng kể — đó là giới hạn tài nguyên máy bạn.
  • Tạo lỗi shared state có chủ đích. Viết hai test cùng ghi/đọc một biến toàn cục hoặc một record database cố định. Chạy song song và quan sát flaky xuất hiện. Sau đó sửa bằng cách cho mỗi test dùng dữ liệu duy nhất (uuid). Xác nhận flaky biến mất. Bài này giúp bạn cảm nhận được bản chất của vấn đề độc lập.
  • Dựng Selenium Grid mini. Dùng Docker Compose ở Bước 4 để chạy Hub + 2 node Chrome. Sửa một test UI dùng webdriver.Remote trỏ tới Grid. Chạy 4 test song song và mở http://localhost:4444 xem Grid phân phối session real-time.
  • Sharding trên CI (nâng cao). Nếu có repo trên GitHub, cấu hình matrix để chia suite thành 2 shard chạy đồng thời. So sánh thời gian tổng của pipeline trước và sau khi shard.

Tóm tắt

Parallel Test Execution là cầu nối giữa một automation suite "chạy được" và một automation suite "thực sự hữu ích ở quy mô thật". Điểm cốt lõi cần khắc ghi:

  • Bài toán: thời gian tuần tự = tổng thời gian mọi test; parallel kéo nó xuống ≈ tổng / số worker, biến regression 8 tiếng thành 20–30 phút.
  • Điều kiện sống còn: test phải độc lập — dữ liệu riêng, không shared state, không phụ thuộc thứ tự. Parallel không sinh ra bug mà phơi bày những phụ thuộc ẩn.
  • Ba tầng song song: trong-máy (pytest-xdist, workers), qua Grid (Hub–Node cho UI), và sharding ở CI. Chúng nhân với nhau cho tốc độ tối đa.
  • Giới hạn thực tế: tài nguyên vật lý (CPU/RAM) chặn số worker hiệu quả; đừng nhồi worker mù quáng. Hãy đo, đừng đoán.
  • Bẫy hay gặp: shared state ngầm, oversubscription, tài nguyên cứng (port/file), session leak, và log ghi đè.
Khi bạn đã có suite độc lập và bật song song đúng cách, mỗi commit sẽ nhận feedback trong vài phút thay vì vài giờ. Đó là lúc automation thật sự trả lại giá trị mà nó đã hứa.

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