NỘI DUNG BÀI HỌC
🕵️ Phần 1: Giải mã Kiến trúc Đa Tiến Trình (Multi-Process)
🧪 Phần 2: Chiến lược Dữ Liệu & Parameterized Testing
🏭 Phần 3: Cấu hình "Nhà Máy" Test (Workers)
🚀 Phần 4: Chế độ Fully Parallel (Tối ưu tốc độ)
🕵️ PHẦN 1: BẰNG CHỨNG THỰC TẾ - PLAYWRIGHT ĐỌC FILE NHƯ THẾ NÀO?
Chúng ta sẽ sử dụng biến process.pid (Process ID - Mã định danh tiến trình) để vạch mặt xem AI đang chạy dòng code nào.
🧪 1. File Code Minh Chứng (tests/lifecycle-proof.spec.ts)
Bạn hãy tạo file này và copy đoạn code sau. Mình đã gài các "máy nghe lén" (console.log) ở khắp nơi.
import { test } from '@playwright/test';
// -----------------------------------------------------------
// VÙNG 1: TOP-LEVEL SCOPE (Code nằm ngoài cùng)
// -----------------------------------------------------------
// Dòng này sẽ chạy ngay khi file được import/load
console.log(`\n📢 [TOP-LEVEL] Đang đọc file... (PID: ${process.pid})`);
const DATA_HEAVY = { id: 1, name: 'Big Data' };
test.describe('Nhóm Test Demo', () => {
// ---------------------------------------------------------
// VÙNG 2: DESCRIBE SCOPE (Code nằm trong nhóm)
// ---------------------------------------------------------
// Dòng này cũng chạy ngay khi file load để đăng ký nhóm
console.log(`📋 [DESCRIBE] Đang đăng ký Group... (PID: ${process.pid})`);
test('Test Case A', async ({ page }) => {
// -------------------------------------------------------
// VÙNG 3: TEST BODY (Code nằm trong bài test)
// -------------------------------------------------------
// Dòng này CHỈ CHẠY khi trình duyệt mở
console.log(`🚀 [TEST BODY] Bắt đầu chạy Test A... (PID: ${process.pid})`);
// Chứng minh Worker có thể đọc được biến ở Top-level
console.log(` -> Worker đọc dữ liệu: ${DATA_HEAVY.name}`);
});
});
📺 2. Kết quả khi chạy (The Output)
Hãy chạy lệnh sau và nhìn kỹ từng dòng log:
npx playwright test tests/lifecycle-proof.spec.ts
👇 LOG THỰC TẾ SẼ HIỆN RA NHƯ SAU:
# --- GIAI ĐOẠN 1: MAIN PROCESS (QUÉT FILE) ---
📢 [TOP-LEVEL] Đang đọc file... (PID: 1111) <-- Main Process đang quét
📋 [DESCRIBE] Đang đăng ký Group... (PID: 1111)
Running 1 test using 1 worker
# --- GIAI ĐOẠN 2: WORKER PROCESS (CHẠY TEST) ---
📢 [TOP-LEVEL] Đang đọc file... (PID: 2222) <-- Worker (PID khác) đọc lại!
📋 [DESCRIBE] Đang đăng ký Group... (PID: 2222)
# --- GIAI ĐOẠN 3: LOGIC TEST ---
🚀 [TEST BODY] Bắt đầu chạy Test A... (PID: 2222)
-> Worker đọc dữ liệu: Big Data
(Lưu ý: PID 1111 và PID 2222 là số giả lập, máy bạn sẽ hiện số khác nhau, nhưng chắc chắn là 2 số khác nhau).
🧠 3. Phân tích hiện trường (Giải mã Log)
Dựa vào log trên, ta thấy rõ 2 sự thật:
Sự thật 1: Code Top-level chạy 2 lần ở 2 nơi khác nhau.
-
Lần đầu (
PID 1111): Là Main Process. Nó chạy để biết trong file có bao nhiêu bài test (test.describe,test). Nó KHÔNG chạy vào trong hàmasync ({ page }). -
Lần sau (
PID 2222): Là Worker Process. Nó load lại file để lấy ngữ cảnh (biếnDATA_HEAVY) vào bộ nhớ của nó.
Sự thật 2: Hàm Test Body chỉ chạy 1 lần.
-
Dòng
🚀 [TEST BODY]chỉ xuất hiện ởPID 2222. -
Main Process (
PID 1111) chỉ "nhìn" thấy cái vỏ bài test, nó không dám đụng vào cái ruột.
Đây là câu chuyện về "Một File Test, Hai Số Phận".
🎬 Giai đoạn 1: Main Process - "Người Quản Lý" (The Manager)
Khi bạn gõ lệnh npx playwright test, Main Process khởi động. Nhiệm vụ của nó là Lập Kế Hoạch, chứ không phải thực thi.
-
Quét File (Parsing):
-
Main Process yêu cầu Node.js đọc file
example.spec.ts. -
Toàn bộ code bên ngoài (Top-level) được chạy để khởi tạo biến.
-
Nó gặp hàm
test('Tên Test', ...)vàtest.describe(...).
-
-
Cơ chế "Ghi Danh" (Registration Only):
-
Tại đây, hàm
test()hoạt động như một thư ký. -
Nó KHÔNG chạy cái hàm
async ({ page }) => { ... }(cái ruột). -
Nó chỉ GHI LẠI: "Ok, trong file này có một bài tên là 'Test A', dòng số 10, thuộc nhóm 'Group 1', có tag '@smoke'".
-
-
Lọc và Chia Việc (Filtering & Sharding):
-
Sau khi đọc xong hết các file, nó có một Danh sách tổng (Manifest).
-
Nó áp dụng bộ lọc
--grep(ví dụ: chỉ lấy bài@smoke). -
Nó tính toán xem cần bao nhiêu Worker (ví dụ: 2 workers).
-
Nó chia việc: "Worker 1, mày phụ trách file A. Worker 2, mày phụ trách file B".
-
📡 Giai đoạn 2: Giao Việc (The Handover)
Main Process không gửi code. Nó gửi Mệnh Lệnh (Instruction) qua kênh IPC (Inter-Process Communication):
🗣️ Main Process nói với Worker 1:
"Này Worker 1, nhiệm vụ của mày là xử lý file tests/example.spec.ts.
Cụ thể là chạy bài test có tiêu đề là: 'Test A'."
👷 Giai đoạn 3: Worker Process - "Người Thợ" (The Executor)
Worker 1 khởi động. Nó là một tờ giấy trắng (New Process). Nó nhận lệnh và bắt đầu làm việc.
-
Load Lại File (Re-evaluation):
-
Để chạy được, Worker bắt buộc phải
importlại filetests/example.spec.ts. -
⚠️ Tại sao code Top-level chạy lại? Vì Worker cần tái tạo lại môi trường (biến, hằng số, import) y hệt như lúc Main Process đọc.
-
-
Cơ chế "Thi Hành" (Execution Mode):
-
Nó lại gặp hàm
test('Test A', body). -
Lần này, Playwright bên trong Worker thông minh hơn. Nó so sánh tên bài test với "Mệnh Lệnh" nhận được từ Main.
-
Nó tự hỏi: "Bài này có phải bài tao được giao không?"
-
Nếu ĐÚNG (
'Test A'): Nó nhảy vào chạy cái ruộtbody. -
Nếu SAI (
'Test B'): Nó bỏ qua (Skip) ngay lập tức, không tốn thời gian khởi tạo trình duyệt cho bài đó.
-
-
-
Chạy Logic Test:
-
Lúc này dòng code
await page.goto(...)mới thực sự được chạy.
-
🧬 So sánh hành vi của hàm test()
Điểm mấu chốt nằm ở hàm test() của Playwright. Nó hoạt động khác nhau tùy vào việc ai là người gọi nó.
Có thể hình dung mã giả (Pseudo-code) của hàm test() bên trong thư viện Playwright như sau:
// Mã giả mô phỏng logic bên trong Playwright
function test(title, testFunction) {
if (dang_o_Main_Process) {
// 1. Chỉ ghi danh sách
testList.push({ title: title, status: 'pending' });
// ❌ KHÔNG chạy testFunction()
}
else if (dang_o_Worker_Process) {
// 2. Kiểm tra xem có phải bài được giao không
if (title === targetTestName) {
// ✅ CHẠY NGAY
setupBrowser();
await testFunction();
teardownBrowser();
} else {
// ⏭️ Bỏ qua
return;
}
}
}
🎯 Tóm tắt sự khác biệt
| Đặc điểm | Ở Main Process | Ở Worker Process |
| Mục đích đọc file | Để biết "Có những bài test nào?" | Để lấy "Ngữ cảnh & Code" để chạy. |
| Code Top-level | Chạy 1 lần. | Chạy 1 lần (cho mỗi Worker). |
Hàm test(...) |
Chỉ lưu tên & metadata. | Thực thi logic test (body). |
| Bộ nhớ (RAM) | Chứa danh sách tên test. | Chứa biến cục bộ, trình duyệt. |
Đó là lý do tại sao bạn thấy log 2 lần, nhưng logic test chỉ chạy 1 lần. Đó là một sự phối hợp nhịp nhàng giữa Quản lý (lên danh sách) và Thợ (làm việc).
🏗️ Phần 2: GIẢI MÃ KIẾN TRÚC PLAYWRIGHT: TẠI SAO WORKER PHẢI "ĐỌC LẠI" FILE TEST?
Một trong những câu hỏi thú vị nhất khi tìm hiểu sâu về Playwright là:
"Tại sao Main Process đã quét file, biết rõ có test nào rồi, mà khi giao việc cho Worker, thằng Worker lại phải hì hục load (parse) lại file đó từ đầu? Tại sao không gửi thẳng code test sang cho nhanh?"
Thoạt nghe, việc này có vẻ lãng phí tài nguyên (Double Parsing). Tuy nhiên, đây là sự đánh đổi kỹ thuật bắt buộc để đảm bảo tính ổn định và chính xác trong môi trường Node.js.
Dưới đây là 3 tầng lý do giải thích cho kiến trúc này.
1. Rào Cản Tuần Tự Hóa (The Serialization Barrier) 📦
Lý do đầu tiên và quan trọng nhất nằm ở cách các Process giao tiếp với nhau.
-
Bối cảnh: Main Process và Worker Process là hai thực thể độc lập. Chúng nói chuyện qua một "đường dây điện thoại" gọi là kênh IPC (Inter-Process Communication).
-
Giới hạn: Kênh IPC này chỉ vận chuyển được dữ liệu dạng văn bản thuần túy (Text) hoặc JSON. Nó không thể vận chuyển một Hàm (Function).
Tại sao JSON.stringify thất bại với Hàm?
Trong JavaScript, một hàm là một thực thể sống, không phải dữ liệu tĩnh.
// Dữ liệu tĩnh (Gửi được)
const data = { name: "Playwright" };
JSON.stringify(data); // ✅ OK: '{"name":"Playwright"}'
// Hàm logic (Không gửi được)
const myTest = async ({ page }) => { await page.click('button'); };
JSON.stringify(myTest); // ❌ LỖI: Trả về undefined hoặc ném lỗi
👉 Kết luận: Main Process không thể đóng gói "cái ruột" của bài test (logic code) để gửi sang Worker. Worker bắt buộc phải tự mở file gốc ra để lấy code.
2. Vấn đề Ngữ Cảnh & Bao Đóng (Context & Closures) 🔗
Giả sử chúng ta dùng "tà thuật" (function.toString()) để biến hàm thành chuỗi văn bản và gửi sang Worker. Liệu nó có chạy được không?
Câu trả lời là KHÔNG. Nó sẽ sập ngay lập tức vì thiếu Ngữ Cảnh (Context).
Một bài test hiếm khi đứng một mình. Nó phụ thuộc vào các biến và thư viện bên ngoài (Closure).
Hãy xem ví dụ "thảm họa" này:
// 📁 File: example.spec.ts
import { loginUser } from './auth-helper'; // 1️⃣ Import bên ngoài
const ENV_URL = 'https://staging.com'; // 2️⃣ Biến toàn cục
test('Login Test', async ({ page }) => {
// Hàm test này sử dụng 2 thứ bên trên
await page.goto(ENV_URL);
await loginUser(page);
});
Kịch bản lỗi:
-
Main Process gửi nội dung hàm
async ({ page }) => { ... }sang Worker. -
Worker nhận được và cố gắng chạy.
-
Worker hét lên:
-
"Ủa
ENV_URLlà cái gì? Tao không có biến này trong bộ nhớ!" -
"Ủa hàm
loginUserlấy ở đâu? Tao chưa import file helper!"
-
👉 Kết luận: Để code chạy được, Worker cần cả "môi trường sống" của đoạn code đó (Imports, Variables). Cách duy nhất để tái tạo môi trường này là Load lại toàn bộ file từ dòng đầu tiên.
3. Sự Cô Lập Bộ Nhớ (Memory Isolation) 🛡️
Playwright (và Node.js) sử dụng mô hình Process Isolation.
-
Main Process: Là "Tổng quản". Chỉ giữ danh sách tên bài test, không chạy logic test.
-
Worker Process: Là "Thợ làm việc". Mỗi Worker là một thế giới riêng biệt, có bộ nhớ (Heap Memory) trắng trơn, không chia sẻ gì với Main hay Worker khác.
Vì bộ nhớ hoàn toàn tách biệt, Worker không thể "nhìn thấy" biến data hay config đang nằm bên Main Process. Nó bắt buộc phải tự nạp (import) lại mọi thứ vào bộ nhớ riêng của mình thì mới có dữ liệu để làm việc.
💡 Ví dụ Hình Tượng: Tổng Đài Taxi 🚕
Để dễ hình dung, hãy tưởng tượng:
-
Main Process: Là Tổng Đài Viên.
-
Worker Process: Là Tài Xế Taxi.
-
File Test (
.spec.ts): Là Tấm Bản Đồ Thành Phố.
-
Khách gọi điện đặt xe đi từ A đến B.
-
Tổng Đài Viên (Main) biết yêu cầu này. Tuy nhiên, Tổng Đài Viên KHÔNG THỂ truyền "kỹ năng lái xe" hay "hình ảnh con đường" vào đầu Tài Xế qua bộ đàm được.
-
Tổng Đài Viên chỉ có thể nói: "Alo xe số 1, hãy mở Tấm Bản Đồ trang 5, thực hiện lộ trình số 3".
-
Tài Xế (Worker) phải tự mở Tấm Bản Đồ của riêng mình (Load file), tìm trang 5, và dùng kỹ năng lái xe + bản đồ đó để đón khách.
🎯 Tổng Kết Quy Trình (The Lifecycle)
Dù Worker phải đọc lại file, nhưng quy trình thực tế đã được tối ưu rất kỹ:
-
Main Process: Quét file -> Lên danh sách 100 bài test -> Chia bài (Sharding) cho Worker.
-
Worker Process:
-
Nhận lệnh: "Chạy bài test số 1 đến 25 trong file X".
-
Import File X: Node.js biên dịch lại file để nạp biến và hàm vào bộ nhớ.
-
Filter (Lọc): Worker âm thầm bỏ qua (skip) các bài từ 26-100 để tiết kiệm RAM.
-
Execute: Chạy 25 bài được giao với đầy đủ ngữ cảnh.
-
Đây chính là lý do tại sao bạn thấy log "Load file..." xuất hiện 2 lần. Đó là dấu hiệu của một hệ thống kiến trúc phân tán, an toàn và mạnh mẽ.
📘 Phần 3: CẨM NANG: CHIẾN LƯỢC CỐ ĐỊNH DỮ LIỆU (DATA FIXATION)
Từ cơ chế "Main đọc trước, Worker đọc sau" và sự tách biệt bộ nhớ, chúng ta rút ra NGUYÊN TẮC VÀNG cho Playwright:
"Dữ liệu dùng để SINH test (Parameters) phải là DỮ LIỆU TĨNH (Static) hoặc ĐỒNG BỘ (Synchronous) tại thời điểm chạy lệnh."
Tại sao lại khắt khe như vậy? Hãy cùng đi sâu vào "trái tim" của hệ thống.
Bản Chất Kiến Trúc: Sự "Lệch Pha" Tiềm Ẩn
Playwright hoạt động dựa trên mô hình Đa Tiến Trình Cô Lập (Isolated Multi-Process):
-
Main Process: Chỉ quản lý, lên danh sách.
-
Worker Process: Thực thi logic, chạy trình duyệt.
Vấn đề nằm ở chỗ: Hai tiến trình này không chia sẻ bộ nhớ. Để cả hai cùng hiểu một file test, chúng buộc phải ĐỌC FILE ĐÓ 2 LẦN ở 2 thời điểm khác nhau.
Chính sự chênh lệch thời gian này tạo ra rủi ro nếu dữ liệu của bạn không cố định.
Giải Mã Qua Mô Hình: "Phát Vé - Soát Vé" 🎟️
Để hiểu rõ tại sao API/Random gây lỗi, hãy tưởng tượng quy trình chạy test như một rạp chiếu phim:
-
Tấm Vé = Tiêu đề bài test (Ví dụ:
Login: Case A). Đây là thứ duy nhất Main chuyển cho Worker. -
Danh sách khách = Dữ liệu đầu vào (
users = ['A', 'B']).
✅ Kịch bản Chuẩn (Happy Path)
Cả hai bên cùng sử dụng một Danh sách khách cố định (Static Data).
-
Main Process (Người Phát Vé):
-
Mở danh sách khách cố định. Thấy khách A.
-
In ra tấm vé: "Login: Case A".
-
Gửi vé này xuống cổng kiểm soát.
-
-
Worker Process (Người Soát Vé):
-
Tự mở danh sách khách cố định (Load lại file). Cũng thấy khách A.
-
Tự in ra tấm vé: "Login: Case A".
-
Khớp Lệnh: Worker nhận vé từ Main, so với vé mình vừa in -> TRÙNG KHỚP.
-
👉 Mời vào (Chạy test).
-
❌ Kịch bản Lỗi (Khi dùng Data Động / API / Random)
Dữ liệu thay đổi theo thời gian hoặc không ổn định.
-
Main Process (Lúc 8:00):
-
Gọi API/Random -> Ra khách A.
-
In vé: "Login: Case A". Gửi đi.
-
-
Worker Process (Lúc 8:01):
-
Load lại file. Gọi lại API/Random.
-
Do mạng lag hoặc thuật toán random -> Ra khách B.
-
Nó in ra vé của riêng nó: "Login: Case B".
-
-
Hậu quả (Mâu thuẫn):
-
Worker cầm vé Main đưa (Case A).
-
Worker nhìn vào danh sách vé của mình (Case B).
-
Worker hét lên: "Vé giả! Trong danh sách của tao làm gì có Case A?"
-
👉 Lỗi:
Test not foundhoặcWorker failed to load test.
-
Đây là ví dụ cụ thể và hình tượng hóa về thảm họa khi sử dụng Dữ liệu ngẫu nhiên (Non-deterministic Data) như Math.random() hoặc Date.now() để đặt tên test.
Vấn đề cốt lõi không chỉ là API bị chậm, mà là Sự "lệch pha" về logic sinh dữ liệu giữa hai thế giới song song.
🎲 Thảm Họa "Vé Số Độc Đắc": Khi Code Chơi Trò May Rủi
Hãy tưởng tượng quy tắc vào rạp phim (Playwright) thay đổi như sau:
-
Quy tắc: "Mã vé của khách sẽ là Số Ngẫu Nhiên do máy tính tự quay ngay lúc in vé".
1. Kịch bản lỗi với Math.random()
Hãy xem đoạn code trông có vẻ vô hại này:
import { test } from '@playwright/test';
// ❌ SAI LẦM CHẾT NGƯỜI: Sinh số ngẫu nhiên ở Top-level
// Biến này sẽ được tính toán LẠI mỗi khi file được load
const luckyNumber = Math.floor(Math.random() * 100);
// Đặt tên test dựa trên số ngẫu nhiên này
test(`Test Vé Số: ${luckyNumber}`, async ({ page }) => {
console.log('Tôi trúng số rồi!');
});
🎬 Diễn biến vụ án:
Giai đoạn 1: Main Process (Người Phát Vé) 👮
-
Main load file lên.
-
Chạy dòng
Math.random()-> Ra số 55. -
Main in ra tấm vé tên là:
"Test Vé Số: 55". -
Main đưa vé 55 cho Worker và ra lệnh: "Xuống dưới tìm và chạy bài 55 cho tao!".
Giai đoạn 2: Worker Process (Người Soát Vé) 👷
-
Worker nhận lệnh tìm bài 55.
-
Worker bắt buộc phải load lại file để có code chạy.
-
Nó chạy lại dòng
Math.random(). -
Oái oăm thay: Lần này máy tính quay ra số 88 (Vì là ngẫu nhiên mà!).
-
Worker in ra danh sách vé của nó: Chỉ có bài
"Test Vé Số: 88".
Giai đoạn 3: Cuộc đối chất (Khớp Lệnh) ⚔️
-
Worker: "Đại ca bảo chạy bài 55. Nhưng trong sổ của em chỉ có bài 88 thôi."
-
Kết quả: Worker không tìm thấy bài test nào khớp tên.
-
Báo lỗi:
Test not foundhoặcWorker failed to load test.
2. Kịch bản lỗi với Date.now() (Thời gian) ⏰
Một sai lầm phổ biến khác là dùng thời gian hiện tại để tạo sự độc nhất.
// ❌ SAI LẦM: Dùng thời gian thực
const timestamp = Date.now();
test(`Test lúc: ${timestamp}`, async ({ page }) => { ... });
🎬 Diễn biến:
-
Main Process (Lúc 8:00:00):
-
Date.now()=1700000000. -
In vé:
"Test lúc: 1700000000". -
Gửi lệnh cho Worker.
-
-
Worker Process (Lúc 8:00:01): (Worker khởi động mất 1 giây)
-
Load lại file.
-
Date.now()=1700000001(Lệch 1 giây hoặc thậm chí 1 milisecond). -
In vé:
"Test lúc: 1700000001".
-
-
Kết quả:
-
Vé Main đưa:
...00 -
Vé Worker có:
...01 -
👉 LỆCH PHA -> LỖI.
-
🧠 Tại sao Main và Worker lại "Lệch" nhau?
Bạn có thể thắc mắc: "Tại sao không copy cái số 55 đó từ Main sang Worker cho nhanh?"
Lý do nằm ở cơ chế "Cô lập hoàn toàn" (Total Isolation).
-
Playwright muốn Worker là một môi trường sạch sẽ 100%.
-
Nó không muốn Worker bị "nhiễm" các biến rác từ Main.
-
Do đó, nó bắt Worker tự chạy lại code để tự xây dựng môi trường.
-
Nếu code của bạn là Code Động (Dynamic/Random), thì mỗi lần chạy lại nó sẽ ra một kết quả khác nhau.
✅ Giải Pháp: Biến "Vé Số" thành "Căn Cước Công Dân"
Để Main và Worker luôn nhìn thấy nhau, dữ liệu định danh (Tên Test) phải giống như Số Căn Cước Công Dân:
-
Dù bạn check lúc 8h sáng hay 8h tối.
-
Dù Công an check hay Ngân hàng check.
-
=> Số đó KHÔNG ĐỔI.
Cách sửa code random:
-
Cách 1: Hardcode (Cứng)
const luckyNumber = 55; // Luôn luôn là 55 test(`Test Vé Số: ${luckyNumber}`, ...); -
Cách 2: Tính toán dựa trên Input cố định (Deterministic)
Nếu bạn muốn tên test trông có vẻ ngẫu nhiên nhưng vẫn đồng bộ, hãy dùng thuật toán băm (Hash) hoặc dựa vào index.
const users = ['Alice', 'Bob']; // Dù chạy bao nhiêu lần, Alice luôn có index là 0 test(`Test ${users.indexOf('Alice')}`, ...);
🎯 Tóm lại
Bất cứ thứ gì bạn đưa vào Tên Bài Test (test('Tên...', ...)):
-
❌ KHÔNG ĐƯỢC:
Math.random(),Date.now(),uuid.v4()(nếu tạo mới mỗi lần chạy),fetch()API động. -
✅ PHẢI LÀ: String cứng, số cứng, hoặc dữ liệu từ file JSON/CSV tĩnh.
3. Giải Pháp: Chiến Lược Cố Định Dữ Liệu
Để tránh thảm họa trên, chúng ta có 2 chiến lược để đảm bảo Danh sách khách là bất biến.
🛡️ Chiến Lược 1: Static Data (Code/JSON) - Khuyên Dùng
Dữ liệu nằm cứng trong file. Dù đọc 1 tỷ lần thì 'A' vẫn là 'A'.
-
Cách làm: Import trực tiếp file
.jsonhoặc biếnconsttrong.ts. -
Ưu điểm: Nhanh, đồng bộ tuyệt đối.
🔄 Chiến Lược 2: Dynamic via Pre-Script (Bước đệm)
Nếu data bắt buộc phải lấy từ Server (API/DB):
-
Bước 1 (Pre-script): Chạy một script riêng trước khi chạy test. Script này gọi API và ghi kết quả xuống file
data.json. -
Bước 2 (Test Run): Playwright đọc file
data.jsonđó. (Lúc này Động đã hóa thành Tĩnh).
4. Triển Khai Code Chuẩn Kiến Trúc
import { test, expect } from '@playwright/test';
// ============================================================
// 1️⃣ CHUẨN BỊ DỮ LIỆU CỨNG (STATIC DATA)
// ============================================================
// Đây chính là "Danh sách khách mời" cố định.
// Main Process và Worker Process đều nhìn thấy mảng này y hệt nhau.
const TEST_CASES = [
{
id: 'TC01',
description: 'Đăng nhập thành công (Standard User)',
username: 'standard_user',
password: 'secret_sauce',
shouldPass: true,
},
{
id: 'TC02',
description: 'User bị khóa (Locked Out)',
username: 'locked_out_user',
password: 'secret_sauce',
shouldPass: false,
expectedError: 'Epic sadface: Sorry, this user has been locked out.'
},
{
id: 'TC03',
description: 'Sai mật khẩu',
username: 'standard_user',
password: 'wrong_password',
shouldPass: false,
expectedError: 'Epic sadface: Username and password do not match any user in this service'
},
{
id: 'TC04',
description: 'Bỏ trống Username',
username: '',
password: 'secret_sauce',
shouldPass: false,
expectedError: 'Epic sadface: Username is required'
}
];
// ============================================================
// 2️⃣ SINH TEST TỰ ĐỘNG (PARAMETERIZED)
// ============================================================
test.describe('SauceDemo Login Data-Driven', () => {
// Vòng lặp chạy ngay khi Main Process quét file
for (const data of TEST_CASES) {
// Tạo tên test DUY NHẤT bằng cách ghép ID + Description
test(`[${data.id}] ${data.description}`, async ({ page }) => {
// --- BƯỚC 1: Truy cập trang ---
await page.goto('https://www.saucedemo.com/');
// --- BƯỚC 2: Điền dữ liệu (Nếu có) ---
// Nếu data.username rỗng thì thôi không điền (để test case bỏ trống)
if (data.username) {
await page.locator('[data-test="username"]').fill(data.username);
}
if (data.password) {
await page.locator('[data-test="password"]').fill(data.password);
}
// --- BƯỚC 3: Click Login ---
await page.locator('[data-test="login-button"]').click();
// --- BƯỚC 4: Kiểm tra kết quả (Assertion) ---
if (data.shouldPass) {
// ✅ Kịch bản mong đợi Thành Công
// Check URL đổi sang trang inventory
await expect(page).toHaveURL(/.*inventory.html/);
// Check tiêu đề "Products" hiện ra
await expect(page.locator('.title')).toHaveText('Products');
} else {
// ❌ Kịch bản mong đợi Lỗi (Negative)
// Tìm cái thông báo lỗi màu đỏ
const errorMsg = page.locator('[data-test="error"]');
await expect(errorMsg).toBeVisible();
await expect(errorMsg).toHaveText(data.expectedError!); // Dấu ! là báo TS biến này chắc chắn có
}
});
}
});
🔍 Phân tích tại sao ví dụ này "Chuẩn Kiến Trúc"
Dù đơn giản, nhưng nó tuân thủ mọi nguyên tắc:
-
Dữ liệu Tĩnh (
TEST_CASES):-
Được khai báo bằng
constngay đầu file. -
Không gọi API, không Random.
-
=> Main Process và Worker Process đảm bảo nhìn thấy 4 test case y hệt nhau.
-
-
Định danh Duy nhất:
-
Tên test:
[TC01] Đăng nhập thành công.... -
Không bao giờ bị trùng tên, giúp Report rõ ràng.
-
-
Logic "Rẽ Nhánh" (
if/else):-
Chúng ta dùng chung 1 logic
fill/click. -
Nhưng phần Assertion (kiểm tra) được tách ra dựa vào biến
shouldPass. -
Cách này giúp tận dụng code (Code Reuse) tối đa.
-
Áp dụng vào hệ thống AnhCRM
import { test, expect } from '@playwright/test';
// Import Helper và Data Catalog từ file index
import { testDataCatalog, getTestDataSimple } from './data/index';
// Reset storage để đảm bảo mỗi test chạy sạch sẽ
test.use({ storageState: { cookies: [], origins: [] } });
// ============================================================
// 🔍 GIAI ĐOẠN 1: PARSE & PLAN (Main & Worker cùng chạy)
// ============================================================
// Giai đoạn này chạy siêu nhanh, chỉ xử lý logic trên RAM để chia nhóm test
console.log(`📦 [PARSE] Loading keys from testDataCatalog... (PID: ${process.pid})`);
const loginCases = testDataCatalog.loginCases;
type LoginCaseKey = keyof typeof loginCases;
// 1. Lấy toàn bộ Keys
const allKeys = Object.keys(loginCases) as LoginCaseKey[];
// 2. Chia nhóm Positive/Negative ngay lập tức (Static Logic)
// Main Process dùng cái này để biết test nào thuộc Group nào
const positiveKeys = allKeys.filter(key => loginCases[key].data.expectedResult === 'success');
const negativeKeys = allKeys.filter(key => loginCases[key].data.expectedResult === 'error');
console.log(` 👉 Found ${positiveKeys.length} positive cases.`);
console.log(` 👉 Found ${negativeKeys.length} negative cases.`);
// ============================================================
// 🧪 GIAI ĐOẠN 2: TEST GENERATION (Main ghi danh)
// ============================================================
// --- GROUP 1: POSITIVE CASES (@smoke) ---
test.describe('Login - Positive Cases', { tag: '@smoke' }, () => {
for (const key of positiveKeys) {
// Lấy description để đặt tên Test (Chỉ lấy metadata, chưa clone data nặng)
const { description } = loginCases[key];
test(`${key}: ${description}`, async ({ page }) => {
// ======================================================
// 🚀 GIAI ĐOẠN 3: EXECUTION (Chỉ Worker chạy)
// ======================================================
// Clone data mới tinh cho bài test này.
// Đảm bảo không bị "nhiễm bẩn" từ các bài test khác.
const testData = getTestDataSimple('loginCases', key);
console.log(`▶️ Running Positive Case: ${key}`);
await page.goto('/admin/authentication');
await page.locator('#email').fill(testData.email);
await page.locator('#password').fill(testData.password);
await page.getByRole('button', { name: 'Login' }).click();
// Verify redirect (Ép kiểu nhẹ vì ta biết chắc chắn đây là success case)
await expect(page).toHaveURL(new RegExp((testData as any).expectedUrl));
});
}
});
// --- GROUP 2: NEGATIVE CASES (@regression) ---
test.describe('Login - Negative Cases', { tag: '@regression' }, () => {
for (const key of negativeKeys) {
const { description } = loginCases[key];
test(`${key}: ${description}`, async ({ page }) => {
// Clone data mới tinh
const testData = getTestDataSimple('loginCases', key);
console.log(`▶️ Running Negative Case: ${key}`);
await page.goto('/admin/authentication');
const emailInput = page.locator('#email');
const passwordInput = page.locator('#password');
// Xử lý điền dữ liệu (có thể rỗng)
await emailInput.fill(testData.email);
await passwordInput.fill(testData.password);
await page.getByRole('button', { name: 'Login' }).click();
// --- LOGIC CHECK LỖI ---
// Dùng (testData as any) để truy cập các trường đặc thù của negative case
const validationType = (testData as any).validationType;
const expectedError = (testData as any).expectedError;
if (validationType === 'browser') {
// Case 1: Browser Validation (HTML5 Bubble)
// Check 1: Input phải ở trạng thái invalid
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(isInvalid).toBe(true);
// Check 2: Message trình duyệt
const validationMessage = await emailInput.evaluate((el: HTMLInputElement) => el.validationMessage);
// Lưu ý: Message trình duyệt phụ thuộc ngôn ngữ OS, nên dùng toContain cho an toàn
expect(validationMessage).toContain(expectedError);
} else {
// Case 2: Server Validation (Alert Box)
// Locator này tùy dự án, ví dụ check alert chung
const alertBox = page.locator('.alert-danger, .alert-warning, div[role="alert"]');
await expect(alertBox).toBeVisible();
await expect(alertBox).toContainText(expectedError);
}
// Verify vẫn đứng yên ở trang login
await expect(page).toHaveURL(/authentication/);
});
}
});
5. Tại sao cấu trúc này "Bất Tử"?
-
Sự Nhất Quán (Consistency):
Nhờ loginTestKeys được lấy từ nguồn tĩnh (testDataCatalog), Main và Worker luôn nhìn thấy cùng một bản danh sách. Tấm vé Main phát ra luôn tìm thấy người nhận ở phía Worker.
-
Hiệu Suất (Efficiency):
-
Main Process: Chỉ thao tác với mảng string (
keys) rất nhẹ. Quét 1000 test case trong tích tắc. -
Worker Process: Chỉ khi nào chạy test nào thì mới clone data nặng của test đó. Không chiếm dụng RAM vô ích.
-
Khả Năng Truy Vết (Traceability):
Tên bài test được gắn cứng với Key và Description. Dù bạn chạy lại bao nhiêu lần, lịch sử test trên Report/CI vẫn thẳng tắp, không bị nhảy lung tung như khi dùng random ID.
🕵️ Phần 4: GIẢI PHẪU QUY TRÌNH CHẠY TEST (THE LIFECYCLE)
Với Parameterized Testing (sinh ra 10, 20 hay 100 test case từ data), nếu chạy tuần tự (cái này xong mới tới cái kia) thì rất lâu. Chạy Parallel (Song song) sẽ giúp tốc độ nhanh gấp N lần (N = số lượng Worker/CPU của máy).
Hãy tưởng tượng hệ thống Playwright là một Nhà Hàng Lớn.
-
Bạn (User): Là khách hàng, người bấm nút gọi món (
npx playwright test). -
Main Process: Là Quản Lý Nhà Hàng (Chỉ điều phối, không nấu ăn).
-
Worker Process: Là Đầu Bếp (Người trực tiếp nấu ăn - chạy test).
-
File Test (
.spec.ts): Là Cuốn Sách Công Thức.
🔤 1. Quy Tắc Sắp Xếp: "Thực Đơn A-Z" (Sorting)
Trước khi giờ làm việc bắt đầu, Quản Lý (Main) cầm một xấp order trên tay (đã quét xong từ các file). Ông ấy là một người cực kỳ ngăn nắp, nên ông ấy sắp xếp các phiếu order lên tường theo quy tắc Alphabet (A -> Z).
-
Nguyên tắc: File nào có tên bắt đầu bằng chữ
Asẽ được xếp bên trái cùng (ưu tiên nhất). File tênZxếp cuối cùng. -
Ví dụ: Trên tường sẽ treo các phiếu theo thứ tự:
-
01_Appetizer.spec.ts(Món Khai Vị) - Xếp đầu tiên. -
02_Beefsteak.spec.ts(Món Bò) - Xếp thứ hai. -
03_Cake.spec.ts(Món Bánh) - Xếp cuối cùng.
-
👨🏫 Playwright mặc định chạy test theo thứ tự tên file. Muốn bài nào chạy trước, hãy đặt tên file bắt đầu bằng số
01_...,02_....
🏃 2. Cơ Chế "Ai Nhanh Hưởng Nhiều" (Greedy Queue)
Bây giờ, nhà hàng mở cửa. Chúng ta có 2 Đầu Bếp (Worker 1 và Worker 2). Quy trình làm việc diễn ra như một cuộc đua tiếp sức không ngừng nghỉ.
🎬 Cảnh 1: Xuất Phát (Phân chia ban đầu)
Hai đầu bếp cùng lao đến bảng công việc.
-
Đầu Bếp 1: Nhanh tay giật lấy tờ phiếu đầu tiên: Món Khai Vị (
01). -
Đầu Bếp 2: Giật lấy tờ phiếu tiếp theo: Món Bò (
02).
🎬 Cảnh 2: Sự Chênh Lệch Tốc Độ
-
Đầu Bếp 1: Món Khai Vị rất dễ làm (Test nhanh). Anh ta làm vèo cái là xong trong 2 phút.
-
Đầu Bếp 2: Món Bò Beefsteak cần nướng kỹ (Test chậm/nặng). Anh ta vẫn đang hì hục nướng.
🎬 Cảnh 3: Quay Lại Hàng Chờ (Re-queueing)
Đây là đoạn quan trọng nhất!
Đầu Bếp 1 sau khi làm xong Món Khai Vị, anh ta KHÔNG ĐƯỢC NGHỈ.
-
Báo cáo: Hét lên với Quản lý: "Sếp ơi, Món Khai Vị xong rồi (Passed)!".
-
Nhận việc mới: Anh ta lao ngay lại Bảng Công Việc.
-
Lấy phiếu: Lúc này trên tường chỉ còn lại Món Bánh (
03). Anh ta giật luôn tờ phiếu này.
Kết quả:
Đầu Bếp 1 làm: Món
01+ Món03.Đầu Bếp 2 làm: Món
02(Vẫn chưa xong).
🔄 Tóm Tắt Quy Trình "Vòng Lặp Vô Tận"
-
START: Đứng trước Bảng Công Việc.
-
CHECK: Còn phiếu order nào trên tường không?
-
Nếu CÒN: Bóc lấy tờ phiếu nằm ngoài cùng bên trái (Alphabet). -> Chuyển sang bước 3.
-
Nếu HẾT: Đi về ngủ (Shutdown Worker).
-
-
WORK: Mở sách công thức (Load file) -> Nấu ăn (Run test).
-
REPORT: Báo kết quả cho Quản Lý.
-
LOOP: Quay lại bước 1.
💡 Ví dụ Minh Họa Sinh Động
Hãy tưởng tượng Bảng Công Việc có 4 món: A (Nhẹ), B (Nặng), C (Nhẹ), D (Nhẹ).
Và có 2 Đầu Bếp.
| Thời gian | Bảng Công Việc (Trên tường) | Đầu Bếp 1 (Nhanh) | Đầu Bếp 2 (Chậm) |
| 00:00 | [A, B, C, D] |
Lấy A | Lấy B (Món siêu nặng) |
| 00:02 | [C, D] |
Xong A. Quay lại lấy C. | Vẫn đang làm B... |
| 00:04 | [D] |
Xong C. Quay lại lấy D. | Vẫn đang làm B... (toát mồ hôi) |
| 00:06 | [trống] |
Xong D. Nhìn bảng thấy hết. Đi nghỉ. | Vẫn đang làm B... |
| 00:10 | [trống] |
Đang uống trà đá. | Cuối cùng cũng Xong B. Đi nghỉ. |
👉 Kết luận
Playwright KHÔNG chia đều (Mỗi người 2 món).
Playwright chia theo năng lực Ai làm xong trước thì lấy việc tiếp theo. Đây là cơ chế tối ưu nhất để nhà hàng không bao giờ bị lãng phí nhân lực!
🏭 Phần 5: CẤU HÌNH NHÀ MÁY TEST: WORKERS & FULLY PARALLEL
1. Thiết Lập Workers: "Quyết định số lượng Đầu Bếp" 👨🍳
Trong file cấu hình playwright.config.ts, tham số workers chính là số lượng đầu bếp bạn muốn thuê.
⚙️ Cách viết Code:
🧠
-
Mặc định (Nếu không set): Playwright rất thông minh. Nó sẽ nhìn xem máy tính của bạn có bao nhiêu "lõi" (CPU Cores).
-
Ví dụ máy bạn có 8 lõi -> Nó thuê khoảng 4-6 đầu bếp (để chừa lại sức cho máy làm việc khác).
-
-
Set cứng (
workers: 4): Bạn ép buộc nhà hàng phải có đúng 4 người làm. -
Lưu ý quan trọng: Không phải cứ thuê 100 đầu bếp là nhanh!
-
Ví dụ: Cái bếp (RAM/CPU) bé tẹo mà nhét 100 ông đầu bếp vào -> Dẫm đạp lên nhau -> Chậm hơn và Crash máy!
-
👉 Lời khuyên: Nên để khoảng 50-75% số lõi CPU của máy.
-
2. Fully Parallel: "Cuộc Cách Mạng Giao Việc" 🚀
Đây là phần thú vị nhất. Sự khác biệt giữa chế độ thường và fullyParallel nằm ở ĐƠN VỊ CÔNG VIỆC (Unit of Work).
🐢 Chế độ Mặc Định (fullyParallel: false)
"Giao Nguyên Quyển Menu"
-
Quy tắc: Quản lý coi 1 FILE là một gói công việc không thể tách rời.
-
Ví dụ: Có file
Menu_Gà.spec.tschứa 10 món gà (10 test cases). -
Diễn biến:
-
Quản lý đưa cả quyển
Menu_Gàcho Đầu Bếp A. -
Đầu Bếp A phải tự mình nấu lần lượt: Cánh gà -> Đùi gà -> Ức gà... (Tuần tự).
-
Trong lúc đó, Đầu Bếp B đang rảnh, muốn vào phụ làm món "Đùi gà" cũng KHÔNG ĐƯỢC.
-
Quy tắc là: "Sách này của tao, tao thầu hết!"
-
👉 Nhược điểm: Nếu trong quyển Menu đó có 1 món nấu mất 1 tiếng, Đầu Bếp A bị kẹt ở đó mãi. Đầu Bếp B ngồi chơi xơi nước.
🐇 Chế độ Fully Parallel (fullyParallel: true)
"Xé Lẻ Từng Tờ Order"
-
Quy tắc: Quản lý không quan tâm file nào. Ông ấy XÉ LẺ mọi cuốn sách ra thành từng tờ order riêng biệt (Từng Test Case).
-
Ví dụ: File
Menu_Gà.spec.tscó 10 món. Quản lý xé ra thành 10 tờ phiếu, trộn chung với các phiếu của fileMenu_Bò. -
Diễn biến:
-
Tất cả các món (Gà, Bò, Cá...) được đổ chung vào một cái rổ lớn giữa nhà hàng.
-
Đầu Bếp A nhặt được tờ "Cánh gà" (thuộc file Gà).
-
Đầu Bếp B nhặt được tờ "Đùi gà" (cũng thuộc file Gà).
-
👉 Cả 2 người cùng nấu 1 thực đơn cùng 1 lúc!
-
👉 Ưu điểm: Tận dụng tối đa nhân lực. Không ai phải chờ đợi ai chỉ vì quy tắc "của tao".
3. Bảng So Sánh Tổng Kết
| Đặc điểm | Mặc định (false) | Fully Parallel (true) |
| Đơn vị giao việc | 1 File (.spec.ts) |
1 Test Case (test(...)) |
| Cách làm việc | 1 File chỉ do 1 Worker làm. Các test trong file chạy lần lượt. | 1 File bị xâu xé bởi nhiều Worker. Các test trong file chạy song song. |
| Hình tượng | Giao nguyên cuốn sách. | Xé lẻ từng trang sách. |
| Tốc độ | Ổn, nhưng dễ bị nghẽn nếu có file quá nặng. | Tốc độ tối đa (Max Speed). |
| Rủi ro | Thấp (Vì test chạy thứ tự). | Cao hơn (Test phải viết độc lập hoàn toàn, không được dùng chung biến). |
4. Code Thực tế (không full parallel)
4. Code Thực tế (không full parallel)
OK, chúng ta sẽ dựng một "Vở kịch" thực tế để kiểm chứng lý thuyết: Worker nào rảnh thì làm, File xếp hàng theo tên.
Chúng ta sẽ tạo 4 files:
1. `01_fast.spec.ts` (Chạy nhanh - 2s)
2. `02_slow.spec.ts` (Chạy cực lâu - 10s - đây là "cục tạ")
3. `03_fast.spec.ts` (Chạy nhanh - 2s)
4. `04_fast.spec.ts` (Chạy nhanh - 2s)
Và ép cấu hình chỉ có **2 Workers**.
⚙️ Bước 1: Cấu hình `playwright.config.ts`
Bạn hãy sửa tạm file config để ép số lượng worker về 2 và tắt chế độ `fullyParallel` (để dễ quan sát file-level parallel).
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/demo-parallel', // Gom vào 1 folder chạy cho dễ
// ⛔ QUAN TRỌNG: Ép chạy đúng 2 công nhân
workers: 2,
// Tắt cái này để Playwright phân chia theo FILE (File A xong mới đến File khác)
fullyParallel: false,
reporter: 'list', // Để xem log chạy realtime cho sướng
use: {
headless: true,
},
});
📂 Bước 2: Tạo 4 File Test (Copy Paste nhanh)
Hãy tạo thư mục `tests/demo-parallel` và tạo 4 file sau. Tôi đã thêm code `console.log` kèm theo **Worker ID** để bạn thấy rõ ai đang làm gì.
File 1: `tests/demo-parallel/01_fast.spec.ts`
(File này nhẹ, Worker A sẽ ăn xong sớm)*
import { test } from '@playwright/test';
test('Test 01 (Nhanh)', async ({ page }, testInfo) => {
console.log(`🚀 [Worker ${testInfo.workerIndex}] BẮT ĐẦU: 01_fast.spec.ts`);
// Giả vờ làm việc 2 giây
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(`✅ [Worker ${testInfo.workerIndex}] KẾT THÚC: 01_fast.spec.ts`);
});
File 2: `tests/demo-parallel/02_slow.spec.ts`
(File này là "Cục tạ", Worker B vớ phải cái này sẽ bị kẹt rất lâu)*
import { test } from '@playwright/test';
test('Test 02 (Siêu Chậm)', async ({ page }, testInfo) => {
console.log(`🐢 [Worker ${testInfo.workerIndex}] BẮT ĐẦU: 02_slow.spec.ts (Xui xẻo vớ phải bài nặng)`);
// Giả vờ làm việc 10 giây
test.setTimeout(15000); // Tăng timeout kẻo bị fail
await new Promise(resolve => setTimeout(resolve, 10000));
console.log(`✅ [Worker ${testInfo.workerIndex}] KẾT THÚC: 02_slow.spec.ts`);
});
File 3: `tests/demo-parallel/03_fast.spec.ts`
(File này sẽ được Worker A cướp lấy vì B đang bận)*
import { test } from '@playwright/test';
test('Test 03 (Nhanh)', async ({ page }, testInfo) => {
console.log(`🚀 [Worker ${testInfo.workerIndex}] BẮT ĐẦU: 03_fast.spec.ts`);
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(`✅ [Worker ${testInfo.workerIndex}] KẾT THÚC: 03_fast.spec.ts`);
});
File 4: `tests/demo-parallel/04_fast.spec.ts`
(File này cũng sẽ do Worker A cướp nốt)*
import { test } from '@playwright/test';
test('Test 04 (Nhanh)', async ({ page }, testInfo) => {
console.log(`🚀 [Worker ${testInfo.workerIndex}] BẮT ĐẦU: 04_fast.spec.ts`);
await new Promise(resolve => setTimeout(resolve, 2000));
console.log(`✅ [Worker ${testInfo.workerIndex}] KẾT THÚC: 04_fast.spec.ts`);
});
🎬 Bước 3: Chạy và Xem Kết Quả
Chạy lệnh sau trong terminal:
npx playwright test
🔮 Dự đoán Kết quả (Bạn sẽ thấy logs hiện ra như sau)
Bạn hãy để ý `Worker Index` (0 hoặc 1).
1. **Lúc 00s:**
* `[Worker 0]` nhận file `01_fast`.
* `[Worker 1]` nhận file `02_slow` (Do thứ tự alphabet).
2. **Lúc 02s:**
* `[Worker 0]` xong file 01. Nó rảnh tay.
* `[Worker 1]` vẫn đang hì hục chạy file 02 (mới được 20% tiến độ).
* 👉 **Hành động:** `[Worker 0]` lập tức lấy tiếp file `03_fast`.
3. **Lúc 04s:**
* `[Worker 0]` xong file 03. Nó lại rảnh tay.
* `[Worker 1]` vẫn đang kẹt ở file 02 (mới được 40%).
* 👉 **Hành động:** `[Worker 0]` tham lam lấy nốt file cuối cùng `04_fast`.
4. **Lúc 06s:**
* `[Worker 0]` xong file 04. Hết việc để làm -> Đi ngủ.
* `[Worker 1]` vẫn chưa xong file 02 (60%).
5. **Lúc 10s:**
* `[Worker 1]` cuối cùng cũng xong file 02.
📊 Tổng kết màn trình diễn
* **Worker 0:** "Gánh team", xử lý 3 files (01, 03, 04).
* **Worker 1:** "Bị đì", xử lý đúng 1 file nặng (02).
Đây chính là minh chứng rõ ràng nhất cho cơ chế **Greedy (Tham lam)** và **Alphabetical Queue** của Playwright. Bạn cứ chạy thử đi, kết quả sẽ y hệt như tôi mô tả!
5. Code thực tế full parallel
Chúng ta sẽ sửa file 02_slow.spec.ts thêm console.log kèm theo Worker ID.
🛠️ Bước 1: Cập nhật Code tests/demo-parallel/02_slow.spec.ts
Chúng ta in ra log lúc BẮT ĐẦU và KẾT THÚC để thấy sự đan xen.
import { test } from '@playwright/test';
test.describe('Bộ Test Nặng Ký (5 món)', () => {
test('Món A', async ({}, testInfo) => {
console.log(`🔴 [Worker ${testInfo.workerIndex}] ▶️ Bắt đầu làm Món A`);
await new Promise(r => setTimeout(r, 2000));
console.log(`🔴 [Worker ${testInfo.workerIndex}] ✅ Xong Món A`);
});
test('Món B', async ({}, testInfo) => {
console.log(`🔵 [Worker ${testInfo.workerIndex}] ▶️ Bắt đầu làm Món B`);
await new Promise(r => setTimeout(r, 2000));
console.log(`🔵 [Worker ${testInfo.workerIndex}] ✅ Xong Món B`);
});
test('Món C', async ({}, testInfo) => {
console.log(`🟢 [Worker ${testInfo.workerIndex}] ▶️ Bắt đầu làm Món C`);
await new Promise(r => setTimeout(r, 2000));
console.log(`🟢 [Worker ${testInfo.workerIndex}] ✅ Xong Món C`);
});
test('Món D', async ({}, testInfo) => {
console.log(`🟠 [Worker ${testInfo.workerIndex}] ▶️ Bắt đầu làm Món D`);
await new Promise(r => setTimeout(r, 2000));
console.log(`🟠 [Worker ${testInfo.workerIndex}] ✅ Xong Món D`);
});
test('Món E', async ({}, testInfo) => {
console.log(`🟣 [Worker ${testInfo.workerIndex}] ▶️ Bắt đầu làm Món E`);
await new Promise(r => setTimeout(r, 2000));
console.log(`🟣 [Worker ${testInfo.workerIndex}] ✅ Xong Món E`);
});
});
⚙️ Bước 2: Cấu hình bắt buộc (Để thấy sự khác biệt)
Trong playwright.config.ts, bạn hãy chỉnh như sau để ép chạy 2 worker và bật chế độ xé lẻ:
export default defineConfig({
testDir: './tests/demo-parallel',
workers: 2, // ✌️ 2 Đầu bếp
fullyParallel: true, // 🚀 Chế độ "Xé lẻ order"
reporter: 'list',
});
📺 Bước 3: Chạy và Xem Log (Bằng chứng thép)
Chạy lệnh: npx playwright test 02_slow.spec.ts
👇 KẾT QUẢ LOG SẼ HIỆN RA NHƯ SAU:
Hãy chú ý: Món A và Món B bắt đầu CÙNG MỘT LÚC bởi 2 người khác nhau!
Running 5 tests using 2 workers
[00s] 🔴 [Worker 0] ▶️ Bắt đầu làm Món A <-- Đầu bếp 0 lấy món A
[00s] 🔵 [Worker 1] ▶️ Bắt đầu làm Món B <-- Đầu bếp 1 nhảy vào lấy món B (Cùng file!)
[02s] 🔴 [Worker 0] ✅ Xong Món A
[02s] 🔵 [Worker 1] ✅ Xong Món B
[02s] 🟢 [Worker 0] ▶️ Bắt đầu làm Món C <-- Đầu bếp 0 rảnh tay, lấy tiếp món C
[02s] 🟠 [Worker 1] ▶️ Bắt đầu làm Món D <-- Đầu bếp 1 rảnh tay, lấy tiếp món D
[04s] 🟢 [Worker 0] ✅ Xong Món C
[04s] 🟠 [Worker 1] ✅ Xong Món D
[04s] 🟣 [Worker 0] ▶️ Bắt đầu làm Món E <-- Đầu bếp 0 nhanh tay lấy nốt món cuối
[06s] 🟣 [Worker 0] ✅ Xong Món E
🧐 So sánh nếu TẮT fullyParallel: false
Nếu bạn tắt chế độ này đi (false), log sẽ trông buồn tẻ thế này:
Running 5 tests using 2 workers
[00s] 🔴 [Worker 0] ▶️ Bắt đầu làm Món A
[02s] 🔴 [Worker 0] ✅ Xong Món A
[02s] 🔴 [Worker 0] ▶️ Bắt đầu làm Món B <-- Vẫn là Worker 0 làm (Worker 1 ngồi chơi)
[04s] 🔴 [Worker 0] ✅ Xong Món B
...
(Worker 0 làm hết cả 5 món, tổng hết 10 giây)
👨🏫
"Cùng là một tờ thực đơn (File
02_slow), nhưng Món A thì do anh Worker 0 nấu, còn Món B lại do anh Worker 1 nấu cùng lúc đó. Đây chính là bằng chứng cho thấy Playwright đã 'xé lẻ' tờ thực đơn này ra để chia cho mọi người cùng làm."
Áp dụng cho code chạy 4 file test với 1 file chứa nhiều test case
Chạy thử lại ví dụ trên nhưng chạy cả folder với các file test khác
Hãy cùng phân tích từng giây của cái log này để thấy sự thông minh của Playwright.
🕵️ Phân Tích Hiện Trường (Scene Investigation)
Chúng ta có:
-
Worker 1: Chuyên gia chạy nhanh.
-
Worker 2: Chuyên gia chạy chậm (ban đầu).
-
Hàng đợi:
01(1 test),02(5 tests A-E),03(1 test),04(1 test).
Giai Đoạn 1: Xuất Phát (Mạnh ai nấy chạy)
-
Worker 1: Nhận file
01_fast. -
Worker 2: Nhận file
02_slow-> Món A.👉 Nhận xét: Ban đầu trông có vẻ như chia theo file (mỗi người 1 file).
Giai Đoạn 2: "Cứu Viện" (Key Moment) 🌟
Đây là đoạn đắt giá nhất trong log của bạn:
[chromium] › tests\lessons\parallel\01_fast.spec.ts:3:1 › Test 01 (Nhanh)
✅ [Worker 1] KẾT THÚC: 01_fast.spec.ts
[chromium] › tests\lessons\parallel\02_slow.spec.ts:17:3 › Bộ Test Nặng Ký (5 món) › Món C
🟢 [Worker 1] ▶️ Bắt đầu làm Món C
-
Worker 1 làm xong file
01rất nhanh. -
Đáng lẽ theo logic thường (nếu tắt fullyParallel), nó phải nhảy sang file
03. -
NHƯNG KHÔNG! Nó nhìn thấy file
02(mà Worker 2 đang làm) vẫn còn nhiều món chưa nấu (C, D, E). -
👉 Hành động: Worker 1 NHẢY VÀO GIỮA file 02, cướp lấy Món C để làm giúp.
Chứng minh: File
02bị xé lẻ. Worker 2 làm A, B. Worker 1 nhảy vào làm C.
Giai Đoạn 3: Song Kiếm Hợp Bích (Xâu xé file 02)
Cả 2 Worker cùng "đánh hội đồng" cái file 02_slow:
-
Worker 2: Xong B -> Làm tiếp D.
-
Worker 1: Xong C -> Làm tiếp E.
👉 File
02được xử lý nhanh gấp đôi vì có 2 người làm cùng lúc.
Giai Đoạn 4: Chia nhau phần còn lại
Khi file 02 hết món:
-
Worker 2: Làm xong D -> Rảnh tay -> Bốc file
03_fast. -
Worker 1: Làm xong E -> Rảnh tay -> Bốc file
04_fast.
📊 Bảng Tóm Tắt Phân Chia Tài Sản
Nhìn vào log, ta thấy sự phân chia như sau:
| File Test | Ai làm? | Nhận xét |
01_fast.spec.ts |
Worker 1 | |
02_slow (Món A) |
Worker 2 | |
02_slow (Món B) |
Worker 2 | |
02_slow (Món C) |
Worker 1 | 🌟 Worker 1 nhảy vào làm giúp |
02_slow (Món D) |
Worker 2 | |
02_slow (Món E) |
Worker 1 | 🌟 Worker 1 làm nốt món cuối |
03_fast.spec.ts |
Worker 2 | |
04_fast.spec.ts |
Worker 1 |
🧠 Bài học rút ra c
-
Ranh giới File bị xóa bỏ: Hãy nhìn Món C và Món E. Chúng nằm trong file
02, nhưng lại do Worker 1 (người khởi đầu bằng file01) xử lý. -
Không có sự chờ đợi: Worker 1 không bao giờ ngồi chơi. Hết việc nhà mình (File 01), nó sang làm việc nhà hàng xóm (File 02) ngay lập tức.
-
Tối ưu hóa thời gian: Nếu không có
fullyParallel, Worker 1 sẽ làm xong01,03,04rồi ngồi chơi dài cổ chờ Worker 2 gánh hết cái file02. Với log này, cả 2 worker kết thúc gần như cùng lúc (nhìn 2 dòng cuối log).
Bí mật nằm ở "Cái Hàng Đợi" (The Queue)
Khi bạn bật fullyParallel: true, Playwright sẽ làm phẳng (flatten) tất cả các test case thành một danh sách dài từ trên xuống dưới, dựa theo tên file (Alphabet) và thứ tự dòng code.
Với ví dụ của chúng ta, Hàng Đợi thực tế trông như thế này:
🔽 ĐẦU HÀNG (Được lấy trước)
-
01_fast.spec.ts(1 test) -
02_slow.spec.ts- Món A -
02_slow.spec.ts- Món B -
02_slow.spec.ts- Món C -
02_slow.spec.ts- Món D -
02_slow.spec.ts- Món E -
03_fast.spec.ts(1 test) -
04_fast.spec.ts (1 test)
🔼 CUỐI HÀNG (Được lấy sau)
Giải mã hiện tượng trong Log
Tại sao Worker 1 làm xong 01 lại nhảy vào làm 02-Món C mà không nhảy sang 03?
Hãy nhìn vào hàng đợi ở trên:
-
Lúc bắt đầu:
-
Worker 1 lấy số 1 (
01_fast). -
Worker 2 lấy số 2 (
02_slow - A). -
(Món B đang chờ).
-
-
Worker 1 làm xong
01(Rất nhanh):-
Nó quay lại hàng đợi.
-
Nó thấy số 2 (Món A) đã có người lấy.
-
Nó thấy số 3 (Món B) chắc chắn Worker 2 sẽ lấy tiếp (hoặc đã lấy rồi).
-
Vị trí tiếp theo còn trống là số 4:
02_slow - Món C. -
👉 Nó BẮT BUỘC phải lấy Món C.
-
-
Tại sao không lấy
03?-
Vì file
03nằm ở vị trí số 7. -
Muốn lấy số 7, thì phải giải quyết xong số 4, 5, 6 (C, D, E) đã.
-
Nguyên tắc là First Come First Served (Đến trước phục vụ trước).
-
Giả sử thay đổi tên file thì sao?
Để chứng minh nó không hề "ưu tiên xử lý hết file", bạn hãy thử đổi tên file 03_fast.spec.ts thành 01_a_fast.spec.ts (để nó chen vào giữa 01 và 02).
Hàng đợi mới sẽ là:
-
01_fast -
01_a_fast(Chen ngang) -
02_slow(Món A...E)
Lúc này kịch bản sẽ là:
-
Worker 1 làm xong
01_fast. -
Nó sẽ lấy ngay
01_a_fast. -
Worker 2 vẫn đang làm
02_slow.
👉 Kết luận:
Playwright không quan tâm "File này xong chưa". Nó chỉ quan tâm "Thằng nào đứng đầu hàng đợi mà chưa ai làm thì tao làm".
Việc bạn thấy nó "xâu xé" hết file 02 rồi mới sang 03 là do sự sắp xếp ngẫu nhiên (Alphabet) khiến các bài test của file 02 đứng liền nhau một cục lớn chắn đường các file sau thôi.
