Kiểm thử tự động và bài toán cách ly môi trường: từ --user-data-dir đến trình duyệt chống phát hiện
Nếu bạn từng cho năm, sáu job E2E chạy song song trên CI/CD với cùng một tài khoản staging, gần như chắc chắn đã gặp cảnh này: job A vừa đăng nhập xong, job B cũng đăng nhập bằng tài khoản đó, và job A lập tức bị đăng xuất giữa chừng. Bạn retry, lần này pass. Retry lần nữa, lại fail. Log sạch sẽ, code không đổi, test vẫn flaky.
Lỗi không nằm ở logic test, mà ở chỗ cả năm job đang chia sẻ cùng một trạng thái đăng nhập. Bài viết này bàn từ chính câu chuyện đó: vì sao kiểm thử tự động (automated testing) bắt buộc phải cách ly môi trường (environment isolation), tại sao tách cookie bằng --user-data-dir chỉ giải quyết một nửa vấn đề, và cách kết hợp trình duyệt chống phát hiện (antidetect browser) với local API để mỗi luồng test sở hữu một môi trường độc lập, lặp lại được qua từng lần chạy pipeline.
Ô nhiễm trạng thái tài khoản trong kiểm thử tự động
Một lần đăng nhập thành công không chỉ sinh ra một cookie. Nó tạo ra cả một cụm trạng thái rải khắp nhiều tầng:
- Cookie session và refresh token phía client
- localStorage / sessionStorage / IndexedDB, nơi nhiều SPA lưu token và cache dữ liệu người dùng
- Cache của service worker, khiến phiên đăng nhập tồn tại cả khi cookie đã bị xoá
- Server-side session phía backend, gắn với thiết bị, IP và thời điểm đăng nhập
Trong kiểm thử tự động, khi nhiều test case song song dùng chung một tài khoản, chúng đang cùng ghi vào một trạng thái. Hệ quả rất cụ thể:
- Huỷ session chéo (session invalidation). Nhiều backend xoay vòng token khi phát hiện đăng nhập từ môi trường lạ, hoặc chỉ cho phép một session hoạt động tại một thời điểm. Job này đăng nhập là job kia bị đá ra, ngay giữa bước assert.
- False negative. Test fail vì môi trường, không phải vì code. Đây là dạng flaky khó debug nhất, vì chạy tuần tự để reproduce thì lại pass.
- Side effect giữa các test. Test A tạo dữ liệu, test B xoá dữ liệu, cả hai cùng chạy trên một session sẽ can thiệp lẫn nhau.
Ba cách sửa phổ biến nhưng đều chỉ che triệu chứng:
- Đăng nhập lại trước mỗi test case: hợp lý về mặt logic, nhưng làm tần suất đăng nhập của tài khoản test tăng vọt. Nhiều hệ thống coi đây là dấu hiệu bất thường và siết xác thực (thêm captcha, chặn tạm, yêu cầu xác thực hai yếu tố).
- Thêm sleep và retry: test chạy chậm hơn, đồng thời làm mờ nguyên nhân gốc.
- Buộc tuần tự hoá (serialize): hết xung đột, nhưng pipeline giờ chạy chậm bằng tổng thời gian của từng test cộng lại.
Cách ly môi trường mới là lớp nền đúng. Môi trường ở đây gồm ba tầng, thiếu một tầng là vẫn còn lỗ:
- Trạng thái phiên: cookie, storage, cache, tách biệt bằng profile directory.
- Danh tính thiết bị: fingerprint gồm UA, canvas, WebGL, timezone, font, tách biệt bằng trình duyệt chống phát hiện.
- Danh tính mạng: IP ra ngoài và vị trí địa lý, tách biệt bằng proxy gán theo từng profile.
--user-data-dir: tách được cookie, không tách được danh tính
Cách tách cookie kinh điển nhất là nhờ flag --user-data-dir của Chrome. Mỗi lần khởi chạy, bạn trỏ đến một thư mục riêng; cookie, cache và localStorage nằm trong thư mục đó nên không đụng nhau. Playwright có thể dùng dưới dạng persistent context (launchPersistentContext), Puppeteer có tham số userDataDir, Selenium thì set --user-data-dir qua ChromeOptions.
Nó giải quyết tốt tầng 1, tầng trạng thái phiên. Nhưng với bài toán test song song, chừng đó là chưa đủ:
- Vân tay trình duyệt giống hệt. Các instance đều chạy trên cùng một máy với cùng một build Chrome, nên UA, độ phân giải màn hình, timezone, bộ font, hash của canvas và WebGL, số CPU và RAM khai báo đều giống nhau. Với các hệ thống có cơ chế phát hiện bất thường, năm thiết bị khác nhau nhưng giống nhau đến từng thông số chính là năm tín hiệu liên kết rõ ràng.
- Cùng một IP ra ngoài. --user-data-dir không đụng gì đến network. Muốn mỗi instance một IP, bạn phải tự cấu hình proxy theo tiến trình, và tuyệt đối không dùng proxy cấp hệ thống (system proxy), vì lúc đó IP lại bị chia sẻ.
- Không mô phỏng được thiết bị khác. Muốn test luồng mobile, bạn vẫn phải tự vá UA, viewport và touch event. Đây là công việc lặp lại, dễ sai và khó đồng nhất giữa các nhóm.
- Headless bị phát hiện. Chế độ headless truyền thống có hàng loạt dấu vết nhận diện (thiếu plugin, khác biệt ở API rendering). Tất nhiên bạn có thể tự patch, nhưng đó là xây lại từ đầu một lớp giả lập thiết bị.
- Gánh nặng vận hành. Tiến trình Chrome treo (zombie), xung đột cổng debug, profile bị corrupt khi job bị kill giữa chừng, rồi việc dọn rác thư mục profile trên runner. Với CI/CD chạy trên runner tạm (ephemeral), profile còn mất sạch sau mỗi job: đăng nhập một lần, chạy xong là quên.
Tóm lại: --user-data-dir tách dữ liệu lưu trữ, không tách danh tính. Hai khái niệm này khác nhau, và chính khoảng cách đó sinh ra phần lớn lỗi flaky liên quan đến tài khoản.
Trình duyệt chống phát hiện (antidetect browser) trong kiểm thử tự động
Trình duyệt chống phát hiện là trình duyệt Chromium cho phép tạo nhiều profile, mỗi profile là một browser instance với:
- Cookie, storage, cache riêng, tương đương tầng trạng thái phiên
- Fingerprint riêng: UA, canvas, WebGL, audio context, font, timezone, hardware concurrency, WebRTC, tương đương tầng danh tính thiết bị
- Proxy riêng, gán theo từng profile, tương đương tầng danh tính mạng
Nhìn từ góc độ test, mô hình này khớp với bốn nhu cầu thực tế:
- Mỗi tài khoản test một profile, đăng nhập một lần dùng lại nhiều lần. Profile lưu trạng thái đăng nhập bền vững. Pipeline lần sau chỉ cần mở profile là vào thẳng trạng thái đã đăng nhập, khỏi cần đăng nhập lại từ đầu. Điều này vừa giảm số lần đăng nhập của tài khoản test, vừa loại bỏ một nguồn gây chậm và một nguồn bị siết xác thực.
- Các job song song trông như các thiết bị khác nhau. Fingerprint khác nhau kết hợp với IP khác nhau, hai job dùng cùng một tài khoản cũng không còn trông như một máy đăng nhập năm chỗ nữa.
- Test được tính năng phụ thuộc vùng miền. Gán proxy theo quốc gia cho từng profile là cách sạch nhất để test hành vi theo địa lý, mà không cần dựng server staging ở nhiều region.
- Môi trường test trở thành tài sản, không phụ thuộc runner. Profile nằm trong ứng dụng (và có thể đồng bộ cloud), không nằm trong thư mục tạm của runner. Job chạy trên bất kỳ máy nào cũng mở lại đúng môi trường cũ. Một lưu ý về phạm vi: mô hình này dùng cho tài khoản và dữ liệu test trên môi trường staging, nhằm mục đích kiểm thử. Với hệ thống đang test, hãy tuân thủ điều khoản sử dụng của hệ thống đó, và đừng mang tài khoản thật vào automation.
BitBrowser local API: quản lý profile môi trường test bằng code
BitBrowser là một trình duyệt chống phát hiện có local API đầy đủ, miễn phí 10 profile, đủ cho một test matrix cỡ vừa và nhỏ. Điểm khiến nó hợp với automation là bạn không phải click chuột trong giao diện: tạo, mở, đóng profile đều qua HTTP.
Thiết lập một lần. Trong ứng dụng, mở Settings, tìm mục Local API: bật dịch vụ, ghi lại URL và port (mặc định http://127.0.0.1:54345), và tạo API key. Key này gửi kèm mọi request qua header x-api-key.
Quy ước chung. Mọi endpoint đều là POST, tham số nằm trong body JSON, và response có dạng thống nhất: { "success": true, "data": {}, "msg": "" }
success: false nghĩa là thao tác thất bại, lý do nằm trong msg. Viết helper kiểm tra success trước khi đọc data sẽ cứu bạn khỏi không ít lần KeyError lúc ba giờ sáng.
Các endpoint cốt lõi cho test:
- Tạo profile (POST /browser/update): name, browserFingerPrint (truyền {} để random), proxyMethod, proxyType.
- Mở profile (POST /browser/open): id, args (vd ["--headless"]), queue.
- Đóng profile (POST /browser/close): id.
- Đóng toàn bộ (POST /browser/close/all): không cần tham số.
- Danh sách profile (POST /browser/list): page, pageSize.
- Chi tiết profile (POST /browser/detail): id.
Lưu ý nhỏ: trong API hiện tại, tạo profile mới dùng chính endpoint /browser/update; fingerprint là bắt buộc, muốn sinh ngẫu nhiên thì truyền object rỗng.
Response của /browser/open là phần quan trọng nhất: { "success": true, "data": { "ws": "ws://127.0.0.1:53325/devtools/browser/857b2d0d-aae6-4852-ab3c-0784f0b2c1fb", "http": "127.0.0.1:53325", "coreVersion": "112", "driver": "/path/to/chromedriver", "pid": 31295 } }
- ws: WebSocket endpoint của Chrome DevTools Protocol (CDP), dùng để Playwright và Puppeteer kết nối
- http: địa chỉ debug, dùng cho Selenium qua debuggerAddress
- driver: đường dẫn chromedriver đúng phiên bản kernel, khỏi lo version mismatch
Tham số queue: true đáng chú ý: nó xếp hàng nếu profile đang được mở, tránh trường hợp hai pipeline cùng mở một profile và giành nhau session.
Ví dụ: Playwright + BitBrowser
Đoạn code dưới đây (Node.js, cần Node 18 trở lên vì dùng fetch sẵn có) thể hiện đúng vòng đời mà một job test cần: tạo profile, mở profile, kết nối Playwright qua CDP, chạy test, rồi dọn dẹp trong finally.
import { chromium } from 'playwright';
const API_BASE = 'http://127.0.0.1:54345'; // URL và port xem trong Settings, mục Local API const API_KEY = 'your-api-key'; // API key tạo trong Settings, mục Local API
async function callApi(path, body = {}) {
const res = await fetch(${API_BASE}${path}, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY,
},
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.success) {
throw new Error(${path} thất bại: ${data.msg});
}
return data.data;
}
// 1. Tạo profile test với fingerprint ngẫu nhiên async function createProfile(name) { return callApi('/browser/update', { name, browserFingerPrint: {}, // {} = sinh fingerprint ngẫu nhiên proxyMethod: 2, proxyType: 'noproxy', // test nội bộ có thể bỏ qua proxy }); }
// 2. Mở profile và lấy ws endpoint để Playwright kết nối async function openProfile(id) { return callApi('/browser/open', { id, args: [], // thêm ['--headless'] khi chạy CI không có màn hình queue: true, // chống hai tiến trình cùng mở một profile }); }
// 3. Chạy test trên môi trường vừa mở async function runTest(profileId) { const opened = await openProfile(profileId); const browser = await chromium.connectOverCDP(opened.ws); const context = browser.contexts()[0]; const page = context.pages()[0] || (await context.newPage());
await page.goto('https://staging.example.com/login'); // ... các bước test của bạn: page.fill, page.click, expect ... console.log('Xong test, tiêu đề trang:', await page.title());
// browser.close() chỉ ngắt kết nối CDP, cửa sổ trình duyệt vẫn chạy await browser.close(); }
async function main() { const name = 'test-account-01'; // Muốn tái sử dụng profile qua nhiều lần chạy: gọi /browser/list // để tra id theo tên, chỉ tạo mới khi chưa tồn tại const profile = await createProfile(name);
try { await runTest(profile.id); } finally { // Đóng và dọn dẹp profile, kể cả khi test fail await callApi('/browser/close', { id: profile.id }); } }
main();
Ba điểm dễ nhầm trong đoạn code trên:
- browser.close() không đóng cửa sổ. Với browser kết nối qua connectOverCDP, lệnh này chỉ đóng kết nối điều khiển. Muốn giải phóng tài nguyên, phải gọi /browser/close để ứng dụng tự dọn dẹp.
- Luôn đóng trong finally. Job bị hủy giữa chừng là lúc profile bị bỏ lại trạng thái đang mở nhiều nhất.
- Tạo lại mỗi lần chạy thì đơn giản, nhưng tốn. Nếu tài khoản test cần giữ trạng thái đăng nhập lâu dài, hãy tái sử dụng profile cũ (tra qua /browser/list), và xoá bằng /browser/delete khi account test bị retire.
Nếu dùng công cụ khác: Puppeteer kết nối bằng puppeteer.connect({ browserWSEndpoint: opened.ws }); Selenium thì set debuggerAddress bằng opened.http và truyền opened.driver vào Service.
Tích hợp vào pipeline CI/CD
Với API ở trên, việc tích hợp vào pipeline trở thành việc viết đúng bốn bước:
- Ensure: /browser/list tra profile theo tên, chưa có thì /browser/update tạo mới. Idempotent, chạy lại không sinh trùng.
- Open: /browser/open với queue: true.
- Test: connectOverCDP rồi chạy toàn bộ suite.
- Close: /browser/close cho profile vừa dùng, hoặc /browser/close/all ở bước teardown với điều kiện always.
API key nên nằm trong CI secrets, không hardcode. Và một thực tế cần biết trước khi lên kế hoạch: BitBrowser là ứng dụng desktop (chủ yếu Windows), local API chỉ listen trên 127.0.0.1. Nghĩa là pipeline phải chạy trên self-hosted runner cùng máy với ứng dụng; hosted runner kiểu cloud của GitHub Actions hay GitLab CI không dùng trực tiếp được. Với team nhỏ, một runner Windows chạy matrix 5 đến 10 profile song song là đủ, và 10 profile miễn phí vừa đủ cho test matrix đó. Nếu cần lớn hơn thì cân nhắc gói trả phí, chi tiết xem trực tiếp trên site của họ.
Kết luận
Cách ly môi trường trong kiểm thử tự động không dừng ở việc tách cookie. Nó là tách cả danh tính thiết bị và danh tính mạng, để mỗi luồng test là một người dùng độc lập. --user-data-dir là bước khởi đầu tốt, nhưng để pipeline chạy song song bền vững, bạn cần một lớp môi trường có fingerprint, proxy và trạng thái đăng nhập riêng cho từng tài khoản test.
Trình duyệt chống phát hiện với local API biến việc quản lý môi trường test thành phần có thể lập trình (infrastructure as code): tạo, mở, đóng, dọn dẹp đều là lệnh. Nếu đang bị flaky test do xung đột tài khoản hành hạ, thử xây một profile pool nhỏ cho staging, có thể bạn sẽ bỏ được phần lớn mấy cái retry đang cắm trong CI.
All rights reserved