Product Management
Đăng nhập
ESC

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

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

Headless Browser Testing

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

Hãy tưởng tượng bạn là QA engineer tại một công ty fintech ở TP.HCM. Bộ test automation UI của bạn có 400 test case, chạy toàn bộ mất 55 phút mỗi lần. Mỗi ngày team push code khoảng 20 lần, nghĩa là hàng đợi CI luôn kẹt cứng, developer chờ kết quả test mà sốt ruột, còn con Mac mini làm CI runner thì nóng rực vì phải mở 400 lần cửa sổ Chrome nhấp nháy trên màn hình ảo. Đây là bối cảnh mà headless browser testing ra đời để giải quyết.

Headless nghĩa là chạy trình duyệt thật (Chrome, Firefox, Edge) nhưng không render giao diện đồ họa (GUI) ra màn hình. Trình duyệt vẫn tải trang, chạy JavaScript, dựng DOM, áp dụng CSS, thực thi network request — mọi thứ y hệt như bình thường — chỉ bỏ đi phần vẽ pixel lên cửa sổ. Kết quả: nhanh hơn, nhẹ tài nguyên hơn, và đặc biệt là chạy được trên máy chủ không có màn hình (như server Linux trong CI/CD).

Với một QA muốn đưa test lên pipeline, hiểu headless không phải là "tùy chọn nâng cao" mà là kỹ năng nền tảng. Gần như mọi lần test UI của bạn chạy trên GitHub Actions, Jenkins hay GitLab CI, nó đều chạy ở chế độ headless. Nếu không nắm vững, bạn sẽ liên tục gặp cảnh "test pass trên máy tôi nhưng fail trên CI" mà không hiểu vì sao. Bài này sẽ giúp bạn làm chủ headless từ khái niệm đến thực hành, và tránh những cạm bẫy kinh điển.

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

Headed vs Headless — khác nhau ở đâu?

Ở chế độ headed (có đầu, tức có GUI), trình duyệt mở một cửa sổ bạn nhìn thấy được: có thanh địa chỉ, tab, con trỏ chuột di chuyển. Đây là chế độ tiện để debug vì bạn quan sát trực tiếp test đang làm gì.

Ở chế độ headless (không đầu), toàn bộ phần hiển thị bị lược bỏ. Trình duyệt chạy như một tiến trình ngầm. Bạn không thấy gì trên màn hình, nhưng test vẫn tương tác đầy đủ với trang web thông qua giao thức điều khiển (WebDriver protocol hoặc Chrome DevTools Protocol).

Điểm mấu chốt cần nhớ: headless không phải là một trình duyệt khác. Nó vẫn là Chrome đó, Firefox đó, chỉ khác một cờ (flag) khi khởi động. Vì vậy về mặt lý thuyết, hành vi render phải giống hệt headed — dù trong thực tế đôi khi có khác biệt nhỏ mà ta sẽ bàn ở phần lỗi thường gặp.

Vì sao headless nhanh và nhẹ hơn?

Có ba lý do chính:

  • Không render pixel: Việc vẽ giao diện lên màn hình (layout, paint, composite) tốn CPU và GPU. Bỏ bước này giúp mỗi thao tác nhẹ đi.
  • Không cần môi trường đồ họa: Trên server Linux, để chạy headed browser bạn phải cài cả một X server ảo (như Xvfb — X Virtual Frame Buffer). Headless bỏ được lớp phụ thuộc này, giảm cấu hình và giảm điểm hỏng hóc.
  • Ít tiêu tốn RAM: Không giữ buffer hình ảnh của cửa sổ nên bộ nhớ dùng ít hơn, cho phép chạy nhiều instance song song trên cùng một máy.
Trong thực nghiệm phổ biến, headless thường nhanh hơn headed khoảng 15–30% cho cùng một bộ test, và mức tiết kiệm còn lớn hơn khi chạy song song vì bạn nhồi được nhiều trình duyệt hơn trên cùng phần cứng.

Chrome Headless — hai thế hệ

Chrome headless có một chi tiết lịch sử quan trọng. Từ Chrome 59 (2017) đến khoảng Chrome 111, tồn tại "old headless" — thực chất là một bản triển khai riêng biệt, không dùng chung code đường render với Chrome thật. Điều này gây ra khác biệt hành vi khó chịu.

Từ Chrome 112 trở đi, Google giới thiệu "new headless" (--headless=new), dùng đúng cùng một engine với Chrome headed. Đây là lý do bạn nên ưu tiên --headless=new để đảm bảo test headless phản ánh trung thực Chrome thật mà người dùng thấy.

Công cụ nào hỗ trợ headless?

Gần như tất cả framework automation hiện đại đều hỗ trợ headless: Selenium (qua ChromeOptions/FirefoxOptions), Playwright (mặc định chạy headless), Cypress (cờ --headless), Puppeteer (mặc định headless). Trong bài này ta tập trung vào Selenium vì đó là nền tảng bạn đang học ở các bài trước, còn Cypress và Playwright có bài riêng.

Tình huống thực tế

Ví dụ 1 — Fintech Sài Gòn: cắt thời gian CI từ 55 phút xuống 22 phút

Quay lại team fintech ở đầu bài. Khi họ mới lên CI, họ chạy Selenium ở chế độ headed trên server Ubuntu thông qua Xvfb. Bộ 400 test chạy tuần tự mất 55 phút. Server thi thoảng crash vì Xvfb rò rỉ bộ nhớ sau vài giờ.

Team quyết định làm hai việc: chuyển sang --headless=new và tận dụng phần RAM tiết kiệm được để chạy 4 luồng song song. Kết quả sau hai tuần:

  • Thời gian chạy toàn bộ suite: từ 55 phút xuống 22 phút (kết hợp headless + song song 4 luồng).
  • Bỏ hoàn toàn Xvfb, giảm số lần server treo từ vài lần/tuần xuống gần như không.
  • RAM đỉnh mỗi runner giảm từ ~3.2GB xuống ~2.1GB dù chạy 4 trình duyệt cùng lúc.
Bài học rút ra: headless không chỉ nhanh mà còn đơn giản hóa hạ tầng. Việc bỏ được Xvfb loại bỏ nguyên một lớp phụ thuộc dễ hỏng, và phần tài nguyên tiết kiệm cho phép bạn tái đầu tư vào chạy song song để nhân đôi hiệu quả.

Ví dụ 2 — Sàn TMĐT: bug chỉ xuất hiện ở headless

Một sàn thương mại điện tử tại Hà Nội gặp tình huống lạ. Có 12 test kiểm tra bố cục trang chi tiết sản phẩm trên mobile luôn pass khi chạy local (headed), nhưng fail liên tục trên CI (headless). Log báo nút "Mua ngay" không click được vì bị element khác che.

Điều tra ra nguyên nhân: khi chạy headless, kích thước cửa sổ mặc định của Chrome là 800x600, còn máy dev thì chạy ở 1920x1080. Ở độ phân giải nhỏ 800x600, responsive layout xếp lại, một banner khuyến mãi trồi lên che mất nút mua. Test không sai — layout thật sự vỡ ở màn hình nhỏ, nhưng không ai chủ ý test ở kích thước đó.

Giải pháp là thêm cờ --window-size=1920,1080 để chuẩn hóa viewport giữa local và CI. Sau đó team còn thêm một bộ test riêng cố ý chạy ở 375x667 (kích thước iPhone) để bắt lỗi responsive thật sự.

Bài học rút ra: headless có mặc định khác headed (đặc biệt là viewport). Luôn set kích thước cửa sổ tường minh, đừng phó mặc cho mặc định. "Test fail trên CI nhưng pass ở local" thường bắt nguồn từ khác biệt cấu hình chứ không phải bug của test.

Ví dụ 3 — Startup SaaS: chụp screenshot để debug test không nhìn thấy được

Một startup SaaS ở Đà Nẵng chuyển toàn bộ test lên headless trên GitHub Actions. Mọi thứ nhanh hơn, nhưng khi test fail, QA không biết chuyện gì xảy ra vì... không có màn hình để nhìn. Trước đây họ chỉ cần quan sát cửa sổ Chrome là hiểu ngay.

Giải pháp họ áp dụng: chụp screenshot tự động mỗi khi test fail và lưu làm CI artifact. Họ thêm một hook trong teardown: nếu test thất bại thì gọi driver.save_screenshot(), kèm cả driver.page_source để lưu HTML lúc đó. Từ đó, mỗi lần build đỏ, QA tải ảnh và HTML về xem đúng trạng thái trang tại thời điểm lỗi.

Thời gian trung bình để chẩn đoán một test fail giảm từ khoảng 25 phút (phải chạy lại local để tái hiện) xuống dưới 5 phút (chỉ cần mở ảnh chụp).

Bài học rút ra: khi mất khả năng quan sát trực tiếp của headless, screenshot và page source chính là "đôi mắt" thay thế. Đây là thực hành bắt buộc cho bất kỳ suite headless nào chạy trên CI.

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

Ta sẽ cấu hình Selenium chạy Chrome headless với Python. Các bước tương tự áp dụng cho Java (dùng ChromeOptions) và cho Firefox.

Bước 1 — Cấu hình Chrome options cơ bản

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

options = Options() options.add_argument("--headless=new") # dùng new headless (Chrome 112+) options.add_argument("--window-size=1920,1080") # chuẩn hóa viewport options.add_argument("--disable-gpu") # cần thiết trên một số môi trường Windows/CI options.add_argument("--no-sandbox") # cần khi chạy trong Docker/container options.add_argument("--disable-dev-shm-usage") # tránh hết bộ nhớ /dev/shm trong Docker

driver = webdriver.Chrome(options=options) driver.get("https://vietnamcos.com") print(driver.title) driver.quit()

Giải thích nhanh các cờ quan trọng:

  • --headless=new: kích hoạt headless thế hệ mới, hành vi sát Chrome thật nhất.
  • --window-size: bắt buộc set để tránh bug viewport 800x600 như ví dụ 2.
  • --no-sandbox--disable-dev-shm-usage: gần như bắt buộc khi chạy trong Docker container, nếu thiếu Chrome thường crash ngay khi khởi động.

Bước 2 — Thêm screenshot khi test fail

import os

def teardown(driver, test_name, passed): if not passed: os.makedirs("screenshots", exist_ok=True) driver.save_screenshot(f"screenshots/{test_name}.png") with open(f"screenshots/{test_name}.html", "w", encoding="utf-8") as f: f.write(driver.page_source) driver.quit()

Trong pytest bạn có thể gói logic này vào một fixture hoặc hook pytest_runtest_makereport để tự động chụp mỗi khi có test đỏ.

Bước 3 — Cấu hình lưu artifact trên CI (GitHub Actions)

- name: Run headless tests
  run: pytest tests/

  • name: Upload screenshots on failure
if: failure() uses: actions/upload-artifact@v4 with: name: failure-screenshots path: screenshots/

Với if: failure(), ảnh chỉ được tải lên khi có test hỏng, tiết kiệm dung lượng.

Bước 4 — Kiểm tra chạy được cả headed lẫn headless

Một mẹo thực dụng: cho phép bật/tắt headless qua biến môi trường để lập trình viên debug local ở chế độ headed, còn CI chạy headless.

if os.getenv("HEADLESS", "true").lower() == "true":
    options.add_argument("--headless=new")

Khi cần debug, developer chỉ việc chạy HEADLESS=false pytest để xem trình duyệt thật.

Bước 5 — Chạy thử và so sánh thời gian

Chạy suite của bạn hai lần: một lần headed, một lần headless, và ghi lại thời gian. Con số cụ thể giúp bạn thuyết phục team và làm cơ sở đo lường về sau.

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

Lỗi 1 — Quên set window size. Đây là nguyên nhân số một khiến test pass local nhưng fail CI. Mặc định headless dùng viewport nhỏ khiến layout responsive khác đi. Luôn thêm --window-size tường minh.

Lỗi 2 — Chrome crash trong Docker. Nếu bạn thấy lỗi kiểu "DevToolsActivePort file doesn't exist" hoặc "session not created", gần như chắc chắn thiếu --no-sandbox--disable-dev-shm-usage. Container mặc định có /dev/shm chỉ 64MB, quá nhỏ cho Chrome.

Lỗi 3 — Dựa vào screenshot của headless để làm visual test. Headless và headed đôi khi render font hoặc anti-aliasing hơi khác. Nếu bạn làm visual regression (bài riêng), hãy chốt chạy trên cùng một môi trường headless nhất quán, đừng so ảnh giữa local headed và CI headless.

Lỗi 4 — Website chặn headless. Một số trang phát hiện navigator.webdriver hoặc thiếu các thuộc tính GUI để chặn bot. Với môi trường test của chính công ty bạn thì hiếm gặp, nhưng khi test bên thứ ba có thể vướng. Đây là vấn đề anti-bot, không nên "lách" khi test hệ thống không thuộc quyền kiểm soát của mình.

Mẹo 1 — Luôn giữ khả năng chạy headed để debug. Đừng hardcode headless. Dùng biến môi trường như bước 4 để linh hoạt.

Mẹo 2 — Set --disable-dev-shm-usage mặc định trên CI. Kể cả khi chưa gặp lỗi, cờ này gần như luôn an toàn và tránh lỗi khó hiểu về sau.

Mẹo 3 — Ghi log network hoặc bật browser log khi cần điều tra sâu. Vì bạn không nhìn thấy màn hình, log console của trình duyệt là nguồn thông tin quý giá.

Mẹo 4 — Đừng nhầm headless với chạy nhanh vô điều kiện. Headless nhanh hơn, nhưng phần lớn thời gian chậm của test UI đến từ wait không hợp lý và tải trang, chứ không phải render. Headless bổ trợ chứ không thay thế việc viết wait tốt.

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

  • Chuyển đổi cơ bản: Lấy một test Selenium bất kỳ bạn đã viết, thêm cấu hình --headless=new--window-size=1920,1080. Chạy và xác nhận nó vẫn pass mà không mở cửa sổ trình duyệt.
  • Đo hiệu năng: Chạy cùng một bộ 10 test ở chế độ headed rồi headless. Ghi lại thời gian mỗi lần và tính phần trăm chênh lệch. Viết một đoạn ngắn kết luận headless nhanh hơn bao nhiêu trên máy bạn.
  • Tái hiện bug viewport: Cố ý set --window-size=375,667 (kích thước iPhone) và chạy test trên một trang có responsive layout. Quan sát xem có element nào bị che hay thay đổi vị trí không. Chụp screenshot để so sánh với 1920x1080.
  • Screenshot on failure: Viết một fixture pytest (hoặc hook tương đương) tự động chụp ảnh và lưu page source mỗi khi test fail. Cố ý làm một test fail để kiểm tra ảnh có được tạo đúng không.
  • Toggle qua biến môi trường: Cấu hình test đọc biến HEADLESS. Xác nhận HEADLESS=false pytest mở cửa sổ thật còn HEADLESS=true pytest chạy ngầm.

Tóm tắt

Headless browser testing là chạy trình duyệt thật nhưng không render GUI — nhanh hơn 15–30%, nhẹ RAM, và chạy được trên server không màn hình, khiến nó trở thành mặc định thực tế cho mọi pipeline CI/CD. Điểm cốt lõi cần nhớ:

  • Headless vẫn là Chrome/Firefox thật, chỉ khác một cờ khởi động. Ưu tiên --headless=new để hành vi sát Chrome thật.
  • Luôn set --window-size tường minh — đây là nguyên nhân số một của lỗi "pass local, fail CI".
  • Trong Docker, thêm --no-sandbox--disable-dev-shm-usage để tránh crash.
  • Vì mất khả năng quan sát trực tiếp, hãy tự động chụp screenshot và lưu page source khi test fail, đưa lên làm CI artifact.
  • Giữ khả năng chạy headed để debug bằng một biến môi trường, đừng hardcode.
Nắm vững headless, bạn không chỉ làm test chạy nhanh hơn mà còn chủ động chẩn đoán được những khác biệt tinh vi giữa môi trường local và CI — kỹ năng phân biệt một QA automation vững vàng với người mới vào nghề.

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