Các bài kiểm thử hồi quy giao diện chạy ngon lành trên laptop của bạn. Bạn push code, pipeline chạy, và cũng chính những bài kiểm thử đó hoặc làm trình duyệt sập không một lời báo lỗi, hoặc báo năm mươi "thay đổi" mà thực chất chỉ là khác biệt khử răng cưa. Code không đổi một dòng nào, nhưng CI thì không nghĩ vậy.
Khoảng cách giữa "chạy được ở máy local" và "chạy được trên CI" gần như luôn là vấn đề hạ tầng, không phải vấn đề của bài kiểm thử. Để tự vận hành kiểm thử hồi quy giao diện trong pipeline CI một cách ổn định, bạn cần ghép đúng bốn mảnh: một CI runner dùng Docker executor, một container được cấu hình sao cho Chromium headless không cạn bộ nhớ chia sẻ, một công cụ so sánh ảnh xuất được báo cáo mà CI đọc hiểu, và một VPS có dung lượng bộ nhớ đủ cho mức mà trình duyệt headless thực sự tiêu thụ.
Hướng dẫn này dựng trọn bộ stack đó từ đầu đến cuối. Kết thúc bài viết, bạn sẽ có một file .gitlab-ci.yml chạy được (hoặc bản tương đương cho Gitea), một môi trường Docker giữ cho Chromium không sập, BackstopJS sinh ra báo cáo JUnit mà pipeline đọc được, và một VPS đúng cấu hình để chạy tất cả.
Tóm tắt nhanh
- Chạy trình duyệt trong cùng một Docker image ở local và trên CI. Phần lớn khác biệt báo sai đến từ chênh lệch phông chữ và kết xuất giữa các môi trường. Ghim cố định image thì chúng biến mất.
- Sửa mặc định 64MB của /dev/shm. kích thước bộ nhớ chia sẻ mặc định của Docker khiến tiến trình kết xuất của Chromium thiếu bộ nhớ và sập âm thầm trên CI. Hãy đặt shm_size: '2gb' trong docker-compose.yml, hoặc truyền --disable-dev-shm-usage cho Chromium.
- Dùng BackstopJS làm công cụ tự vận hành chính. Nó dùng giấy phép MIT (v6.3.25), ưu tiên Docker qua cờ --docker, và xuất báo cáo JUnit. Nếu bạn đã chạy Playwright, hàm toHaveScreenshot() có sẵn của nó là điểm khởi đầu tốt mà không phải cài thêm gì.
- Lấy 4GB RAM làm mức cơ sở khi chọn cấu hình. Hãy rộng tay với bộ nhớ. Trong các lần chạy CI thực tế, một job chụp ảnh màn hình bằng Chromium có thể ngốn nhiều bộ nhớ hơn hẳn khi chụp toàn trang so với lúc nhàn rỗi, nên 4GB RAM là mức cơ sở an toàn hơn cho một đến hai job chạy tuần tự hoặc song song nhẹ.
Những Gì Hướng Dẫn Này Không Đề Cập
Đây là hướng dẫn triển khai cho phần hạ tầng mà bạn đã quyết định dựng lên. Một vài nội dung được chủ ý để ngoài phạm vi bài viết:
- Đây không phải bài so sánh đầy đủ các công cụ. Hướng dẫn này tập trung vào stack mà bạn thực sự vận hành được trong môi trường CI tự quản: BackstopJS, Docker và runner của chính bạn.
- Bài viết không hướng dẫn tự vận hành Argos. Argos là mã nguồn mở, nhưng luồng sản phẩm công khai và tài liệu của nó xoay quanh ứng dụng Argos dạng dịch vụ và các tích hợp CI, chứ không phải một lộ trình tự vận hành đơn giản cho môi trường sản xuất. Với mục tiêu của hướng dẫn này, BackstopJS là ví dụ thực hành an toàn hơn.
- Bài viết không khuyến nghị Lost Pixel. Dự án đó đã bị lưu trữ vào tháng 4 năm 2026; hãy dùng BackstopJS cho các triển khai mới.
- Bài viết không bàn lại chuyện tự vận hành hay dùng SaaS có quản lý. Nếu bạn đọc đến đây, hẳn bạn đã loại Percy hay Chromatic khỏi danh sách rồi.
Điều kiện tiên quyết
Bạn cần chuẩn bị sẵn vài thứ trước khi gõ dòng lệnh đầu tiên:
- Một CI runner tự vận hành, hoặc kế hoạch triển khai một cái (được nói đến bên dưới).
- Docker đã được cài trên máy chủ chạy runner.
- Một dự án Node.js đã có mục tiêu kiểm thử (URL của ứng dụng đang chạy hoặc một tập route component để chụp).
- Quyền truy cập shell vào VPS hoặc máy chủ đặt runner.
Vì sao kiểm thử giao diện chạy tốt ở local nhưng hỏng trên CI
Trình duyệt kết xuất ảnh chuẩn của bạn trên macOS không phải là trình duyệt kết xuất ảnh so sánh trong container CI chạy Linux. Tài liệu của chính Playwright nói thẳng điều này: kết quả kết xuất của trình duyệt có thể thay đổi tùy theo OS của máy chủ, phiên bản, thiết lập, phần cứng, nguồn điện, chế độ headless và nhiều yếu tố khác. Khuyến nghị của họ cũng thẳng thắn không kém: để ảnh chụp nhất quán, hãy chạy kiểm thử trong đúng môi trường nơi ảnh chuẩn được tạo ra.
Chỉ một khuyến nghị đó thôi đã là lý do tồn tại của toàn bộ phần thiết lập còn lại. Ba kiểu lệch pha sau chiếm gần như toàn bộ các lần báo lỗi giả:
- Khác biệt về phông chữ và khử răng cưa. Máy dev của bạn và container CI đi kèm các gói phông chữ và cách kết xuất sub-pixel khác nhau. Những trang nhiều chữ sẽ báo khác biệt ở mọi lần chạy dù chẳng có gì thay đổi.
- Kết xuất headless so với có giao diện. Trình duyệt có giao diện và trình duyệt headless có thể dàn trang cùng một trang web hơi khác nhau.
- Cạn bộ nhớ chia sẻ. Đây là thủ phạm âm thầm. Mặc định /dev/shm của Docker là 64MB, trong khi Chromium headless dùng bộ nhớ chia sẻ cho các tiến trình kết xuất. Khi hết bộ nhớ, Chromium sập mà không để lại lỗi nào hữu ích. Job của bạn đơn giản là chết hoặc treo.
Hai vấn đề đầu được giải quyết bằng cách ghim một Docker image duy nhất cho cả khâu tạo ảnh chuẩn lẫn khâu so sánh. Vấn đề thứ ba được xử lý ở phần cấu hình Docker bên dưới. Sửa cả ba thì những bài kiểm thử vốn "chập chờn trên CI" sẽ trở nên ổn định và dự đoán được.

Chọn công cụ: BackstopJS hay bộ so sánh ảnh có sẵn của Playwright
Nếu bạn đã dùng Playwright cho kiểm thử end-to-end, con đường nhanh nhất là assertion toHaveScreenshot() có sẵn: không phải cài thêm phụ thuộc nào, so sánh ở mức điểm ảnh, và ảnh chuẩn tự động gắn hậu tố theo nền tảng. Đây là điểm dừng hoàn toàn hợp lý cho một bộ kiểm thử nhỏ, và với các nhóm có dưới khoảng năm mươi màn hình và không dùng thư viện component chung, nó có thể là tất cả những gì bạn cần.
Chỗ nó hụt hơi là quy trình duyệt thay đổi. Playwright chỉ so sánh ở mức điểm ảnh và không có giao diện duyệt PR, nên để chấp nhận một loạt thay đổi giao diện hợp lệ, bạn phải tạo lại snapshot bằng tay. Khi số lượng ảnh chuẩn tăng lên, vòng lặp duyệt thủ công đó trở nên rất mệt mỏi.
BackstopJS là công cụ chuyên dụng sinh ra đúng cho vòng lặp đó. Nó dùng giấy phép MIT (hiện là v6.3.25), ưu tiên Docker qua cờ --docker, và xuất ra JUnit XML mà CI runner đọc được ngay. Quy trình ảnh chuẩn của nó (tạo ảnh tham chiếu, kiểm thử đối chiếu, duyệt các khác biệt được chấp nhận thành ảnh chuẩn mới) chính là mô hình vận hành mà phần lớn hệ thống VRT tự quản rốt cuộc đều cần. Trong phần còn lại của hướng dẫn, BackstopJS là ví dụ thực hành.
Hai công cụ mà bạn có thể thấy được khuyến nghị ở nơi khác nằm ngoài bài hướng dẫn này: Argos không được đề cập vì luồng sản phẩm công khai của nó xoay quanh ứng dụng dạng dịch vụ và các tích hợp CI, còn Lost Pixel thì đã bị lưu trữ. BackstopJS là ví dụ thực hành an toàn hơn cho một hệ thống tự vận hành thực tế.
| Trục | BackstopJS | Playwright toHaveScreenshot() |
|---|---|---|
| Quy trình duyệt PR | Có sẵn báo cáo và lệnh duyệt | Tạo lại snapshot bằng tay |
| Quản lý ảnh chuẩn | Các lệnh reference / test / approve | File snapshot theo từng bài kiểm thử |
| Kết xuất chuẩn hóa qua Docker | Cờ --docker | Mang theo image của bạn |
| Chi phí cài đặt | Phụ thuộc riêng biệt | Đã có sẵn nếu bạn dùng Playwright |
Nếu muốn một cuộc so găng sâu hơn với nhiều công cụ và nhiều tiêu chí hơn, bài so sánh BackstopJS, Argos và Lost Pixel của chúng tôi bao quát toàn bộ quyết định chọn công cụ.
Triển khai CI runner tự vận hành trên VPS
Mảnh ghép thực sự mang lại sự nhất quán khi kết xuất chính là Docker executor: runner chạy mỗi job bên trong một container do bạn định nghĩa, nghĩa là môi trường trình duyệt luôn giống hệt nhau ở mọi lần chạy. Hãy chọn nền tảng runner dựa trên việc bạn còn muốn nó làm thêm những gì.
GitLab CE tự vận hành là lựa chọn nặng ký hơn: kho mã, CI/CD, registry, issue và merge request gói gọn trong một instance. Phần quan trọng với hướng dẫn này là Docker executor của GitLab Runner, thứ cho phép mỗi job kiểm thử hồi quy giao diện chạy trong cùng một container image được ghim cố định ở mọi lần chạy. GitLab, Gitea, Jenkins, Forgejo, Portainer và Docker đều có sẵn dưới dạng triển khai một cú nhấp trên marketplace Cloudzy, giúp rút ngắn đáng kể công đoạn cài đặt.
Gitea với act-runner là phương án nhẹ nhàng hơn. Gitea là một dịch vụ Git viết bằng Go với engine workflow tương thích GitHub Actions (thông qua act-runner), nên nếu nhóm của bạn đã quen cú pháp Actions, đây là một runner nhỏ gọn và dựng lên rất nhanh.
Dù chọn cái nào, mục tiêu cấu hình vẫn như nhau: một runner nhận job và thực thi chúng bằng Docker executor. Mọi thứ phía sau (cấu hình container, BackstopJS, pipeline mẫu) đều giả định executor đó đã sẵn sàng.
Cấu hình Docker để Chromium không sập
Đây là lỗi ngốn nhiều thời gian của người ta nhất. Docker mount /dev/shm ở mức 64MB theo mặc định. Chromium headless dùng phân vùng bộ nhớ chia sẻ đó cho các tiến trình kết xuất, và khi chụp ảnh toàn trang thì nó cần nhiều hơn 64MB rất nhiều. Khi hết bộ nhớ, tiến trình kết xuất chết, thường là không có thông báo lỗi nào chỉ về phía bộ nhớ chia sẻ. Job treo, hết thời gian chờ, hoặc báo một lỗi trình duyệt sập chung chung.
Có hai cách sửa, và cách nào cũng được.
Cách A: tăng kích thước bộ nhớ chia sẻ trong Compose. Khóa shm_size đặt kích thước phân vùng /dev/shm của container. tài liệu tham chiếu file Docker Compose của Docker xác nhận rằng shm_size cấu hình dung lượng bộ nhớ chia sẻ mà container dịch vụ được phép dùng. Hãy đặt nó trong khối service:
# docker-compose.yml
services:
vrt:
image: backstopjs/backstopjs:6.3.25
shm_size: '2gb' # override the 64MB default
volumes:
- ./:/src
working_dir: /src
Cách B: bảo Chromium đừng dùng /dev/shm nữa. Cờ --disable-dev-shm-usage khiến Chromium ghi các file bộ nhớ chia sẻ vào /tmp thay vì phân vùng bộ nhớ chia sẻ. Hãy truyền nó vào tham số khởi chạy trình duyệt. Trong cấu hình backstop.json, đó là engineOptions; còn trong Playwright là launchOptions:
// backstop.json (fragment)
{
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Cách A gọn gàng hơn khi bạn kiểm soát được file Compose; cách B là lựa chọn linh hoạt khi bạn chỉ chạm được vào các cờ khởi chạy trình duyệt. Áp dụng cả hai cũng chẳng hại gì.
Mẹo hay: Hãy dùng đúng cùng một Docker image ở local và trên CI. BackstopJS cho bạn điều đó miễn phí qua cờ --docker: nó chạy khâu chụp ảnh bên trong một image tham chiếu được ghim cố định, nên máy bạn và runner kết xuất y hệt nhau. Đây chính là thứ triệt tiêu các báo lỗi giả do phông chữ và khử răng cưa: ghim image một lần và thôi đuổi theo những khác biệt ma.

Thiết lập BackstopJS cho CI
BackstopJS sinh ra báo cáo JUnit XML để CI runner đọc và quyết định build đạt hay trượt, và đó chính là lý do bạn đưa nó vào pipeline thay vì chạy bằng tay. Hãy cài đặt và khởi tạo cấu hình:
npm install --save-dev backstopjs
npx backstop init
Sau đó trỏ backstop.json tới những trang bạn muốn chụp và bật báo cáo CI:
// backstop.json (fragment)
{
"id": "my_app_vrt",
"viewports": [
{ "label": "desktop", "width": 1440, "height": 900 }
],
"scenarios": [
{
"label": "Homepage",
"url": "http://app:3000/",
"selectors": ["document"]
}
],
"report": ["CI"],
"engine": "puppeteer",
"engineOptions": {
"args": ["--disable-dev-shm-usage", "--no-sandbox"]
}
}
Thiết lập báo cáo CI chính là thứ xuất ra JUnit XML. Hãy dùng cờ --docker khi chạy các lệnh này ở local trên máy của bạn. Còn bên trong một job CI vốn đã dùng image backstopjs/backstopjs, hãy chạy thẳng backstop test. Quy trình ảnh chuẩn gồm ba lệnh:
npx backstop reference --docker # capture the approved baseline
npx backstop test --docker # run the comparison, emit the report
npx backstop approve # accept the current diffs as the new baseline
Một lưu ý về quản lý ảnh chuẩn: mỗi thay đổi UI hợp lệ đều đồng nghĩa với việc phải chạy approve để công nhận ảnh chụp mới. Vượt quá khoảng một trăm kịch bản, bước duyệt đó trở thành một chi phí vận hành thực sự: phải có người soi từng khác biệt và quyết định cái nào là cố ý, và gánh nặng này tăng theo quy mô nhóm sử dụng. Công việc bảo trì đó mới là cái giá thật của kiểm thử hồi quy giao diện ở quy mô lớn, và bạn nên tính trước chi phí này trước khi cam kết với một bộ kiểm thử đồ sộ.
Một cấu hình pipeline CI chạy được
Pipeline chạy theo hai phần logic: tạo ảnh chuẩn một lần (hoặc khi cần), rồi so sánh với nó ở mỗi thay đổi. Ví dụ bên dưới là một cấu hình GitLab CI dùng Docker executor, đã áp sẵn cách xử lý bộ nhớ chia sẻ thông qua các cờ khởi chạy trong backstop.json.
# .gitlab-ci.yml
stages:
- visual-regression
visual_regression:
stage: visual-regression
image:
name: backstopjs/backstopjs:6.3.25
entrypoint: [""]
script:
- backstop test
artifacts:
when: always
reports:
junit: backstop_data/ci_report/xunit.xml
paths:
- backstop_data/bitmaps_test
- backstop_data/html_report
expire_in: 1 week
Dòng junit chuyển báo cáo cho GitLab để các lỗi hiện lên ngay trong giao diện merge request; dòng paths giữ lại các ảnh bitmap khác biệt để bạn xem chính xác cái gì đã đổi. Trên Gitea với act-runner, các bước tương tự ánh xạ sang một workflow theo kiểu Actions:
# .gitea/workflows/visual-regression.yaml
name: Visual Regression
on: [push, pull_request]
jobs:
vrt:
runs-on: ubuntu-latest
container:
image: backstopjs/backstopjs:6.3.25
options: --shm-size=2gb # raise /dev/shm for the job container
steps:
- uses: actions/checkout@v4
- run: backstop test
Chú ý tùy chọn container --shm-size=2gb trong workflow của Gitea. Đó chính là cách xử lý bộ nhớ chia sẻ giống hệt cách A, chỉ khác là áp ở cấp container của job, nơi không có file Compose riêng để chỉnh.
Chọn cấu hình VPS cho Chromium headless
Ở đây RAM mới là giới hạn, không phải CPU. Chromium headless dùng CPU theo kiểu bùng nổ từng đợt (vọt lên lúc chụp ảnh rồi nghỉ giữa các lần), nên số nhân khiêm tốn vẫn theo kịp thoải mái. Bộ nhớ mới là trần quyết định bạn chạy được bao nhiêu job cùng lúc. Một tiến trình Chromium headless nằm ở khoảng 300–500MB khi nhàn rỗi và leo lên 1–2GB khi chụp ảnh toàn trang, nên yếu tố quyết định cấu hình là lượng RAM cho mỗi job chạy song song.
| RAM | Phù hợp cho |
|---|---|
| 2GB | Mức tối thiểu sát nút: một job chạy tuần tự |
| 4GB | Mức cơ sở khuyến nghị: 1–2 job song song |
| 8GB | 3–4 job song song |
| 16GB | Bộ kiểm thử lớn và pipeline chạy song song |
Các con số này là kinh nghiệm thực tế rút ra từ những lần chạy CI thật, không phải benchmark của nhà cung cấp, nên hãy xem chúng là điểm khởi đầu ước lượng và theo dõi mức bộ nhớ đỉnh của chính bạn.
Điểm cốt lõi của phần này: 4GB RAM là mức cơ sở đáng tin cậy; cộng thêm khoảng 2GB dự phòng cho mỗi job kiểm thử hồi quy giao diện chạy song song bổ sung.
Điểm đáng lưu ý khi tự vận hành VRT là một runner thiếu bộ nhớ sẽ không báo lỗi ồn ào, nó làm Chromium sập một cách âm thầm, đúng vào triệu chứng mà toàn bộ thiết lập này sinh ra để tránh. Cấp đủ dư địa bộ nhớ là khoản bảo hiểm rẻ nhất bạn có thể mua. Nếu bạn không muốn dựng máy chủ runner từ đầu, marketplace của Cloudzy có sẵn các bản triển khai một cú nhấp cho GitLab tự vận hành và Gitea tự vận hành tự vận hành, đưa bạn tới một instance CI đang chạy chỉ trong vài phút, trên một VPS mà bạn có thể chọn cấu hình đúng bằng mức dự phòng Chromium headless mà bộ kiểm thử của bạn cần.

Câu hỏi thường gặp
Làm sao để thiết lập kiểm thử hồi quy giao diện trong pipeline CI/CD?
Hãy cài BackstopJS, trỏ backstop.json tới các trang mục tiêu, và bật tùy chọn báo cáo CI để xuất JUnit XML. Dùng backstop reference --docker và backstop test --docker khi chạy từ máy local để ảnh chụp được kết xuất bên trong Docker image đã ghim. Trên CI, hãy chạy job bên trong image backstopjs/backstopjs và gọi thẳng backstop test, rồi công bố báo cáo JUnit từ backstop_data/ci_report/xunit.xml. Dùng một runner tự vận hành với Docker executor, cấu hình tối thiểu 4GB RAM cho Chromium headless.
Chromium headless cần bao nhiêu RAM trong container CI?
Một tiến trình Chromium headless dùng khoảng 300–500MB khi nhàn rỗi và vọt lên 1–2GB khi chụp ảnh toàn trang. Để CI chạy ổn định, hãy dự trù khoảng 4GB RAM cho một đến hai job song song, và cộng thêm khoảng 2GB cho mỗi job song song bổ sung. CPU chỉ bùng nổ theo đợt chứ không phải nút thắt; RAM mới là thứ quyết định bạn chạy được bao nhiêu job cùng lúc.
Vì sao kiểm thử hồi quy giao diện của tôi chạy tốt ở local nhưng trượt trên CI?
Trình duyệt kết xuất khác nhau giữa các môi trường: phông chữ, khử răng cưa, chế độ headless so với có giao diện, và cả OS đều làm thay đổi kết quả. Cách sửa là chạy cả khâu tạo ảnh chuẩn lẫn khâu so sánh trong cùng một Docker image để hai bên kết xuất y hệt nhau. Cờ --docker của BackstopJS làm đúng việc đó bằng cách chụp ảnh bên trong một image tham chiếu đã ghim cố định.
Làm sao để khắc phục lỗi /dev/shm khi chạy Chromium trong Docker trên CI?
Mặc định /dev/shm của Docker là 64MB, quá nhỏ cho tiến trình kết xuất của Chromium headless và gây ra những cú sập âm thầm. Hoặc bạn tăng nó lên bằng shm_size: '2gb' trong docker-compose.yml (hoặc --shm-size=2gb trên container của job), hoặc truyền --disable-dev-shm-usage vào cờ khởi chạy Chromium để nó ghi các file bộ nhớ chia sẻ vào /tmp thay thế.
Nên dùng Playwright toHaveScreenshot hay một công cụ kiểm thử hồi quy giao diện chuyên dụng?
Hàm toHaveScreenshot() có sẵn của Playwright là đủ cho các bộ kiểm thử nhỏ: dưới khoảng năm mươi màn hình, không dùng thư viện component chung và không cần giao diện duyệt PR. Hãy chuyển sang công cụ chuyên dụng như BackstopJS khi bạn cần một quy trình duyệt ảnh chuẩn đàng hoàng, một báo cáo để xem xét, và khả năng kết xuất được chuẩn hóa qua Docker, những thứ trở nên quan trọng khi số lượng ảnh chuẩn tăng lên.
Lost Pixel còn được duy trì không?
Không. Lost Pixel đã bị lưu trữ vào tháng 4 năm 2026 và không còn là ứng viên cho các triển khai mới. Hãy dùng BackstopJS cho một hệ thống kiểm thử hồi quy giao diện tự vận hành.