NỘI DUNG BÀI HỌC
-
Bảo mật & Clean Code: Loại bỏ hoàn toàn Hardcode (URL, Password) bằng hệ thống
dotenv-flow. Quản lý biến môi trường theo tầng: Default -> Staging -> Local Secret. -
Chuẩn hóa Cross-Platform: Giải quyết triệt để xung đột lệnh giữa Windows (PowerShell) vs Mac/Linux bằng
cross-env, đảm bảo script chạy mượt mà trên cả máy cá nhân và CI/CD. -
Làm chủ Test Lifecycle: Hiểu sâu về "Tứ trụ"
beforeAll,beforeEach,afterEach,afterAll. Biết chính xác khi nào code chạy và phạm vi ảnh hưởng của chúng. -
Giải mã cơ chế Worker & Parallel: Phân tích rủi ro Race Condition (Xung đột) khi dùng Hooks trong chế độ chạy song song. Tại sao biến toàn cục (
let page) là "kẻ thù" của Automation hiện đại?
🔐 Phần 1: Bảo Mật & Quản Lý Đa Môi Trường (dotenv-flow)
Vấn đề: "Bom nổ chậm" trong code
Khi viết code chúng ta có thể hard code viết các thông tin như:
// ❌ CÁCH CŨ: Rất nguy hiểm
const ADMIN_PASS = '123456';
const BASE_URL = 'https://dev.crm.anhtester.com';
Rủi ro:
- Lộ bí mật: Nếu đẩy code này lên GitHub, cả thế giới biết mật khẩu của bạn.
- Cứng nhắc: Sếp bảo: "Chạy test trên server Staging đi em". Bạn phải đi sửa lại URL trong hàng chục file test -> Không thể mở rộng (Not Scalable).
👉 Giải pháp: Sử dụng thư viện dotenv-flow.
Cài đặt & Kiến Trúc Chuẩn
Bước 1: Cài thư viện
Mở Terminal tại thư mục dự án:
npm install dotenv-flow --save-dev
Bước 2: Tạo cấu trúc file (Mô hình Sandwich)
Hãy tạo các file sau ngay tại Thư mục Gốc (Root) của dự án (ngang hàng package.json).
Cấu trúc file chuẩn cho 2 môi trường (Dev & Staging):
my-project/
├── .env (1. Cấu hình CHUNG - Ai cũng giống nhau)
├── .env.development (2. Cấu hình riêng cho DEV)
├── .env.staging (3. Cấu hình riêng cho STAGING)
├── .env.local (4. BÍ MẬT - Chỉ máy bạn có - Không Up Git)
└── .gitignore (Quan trọng)
Nội dung chi tiết từng file:
📄 .env (Mặc định)
PROJECT_NAME=CRM_Automation
DEFAULT_TIMEOUT=30000
📄 .env.development (Môi trường Dev)
BASE_URL=https://dev.crm.anhtester.com
# Password giả (để giữ chỗ)
ADMIN_PASSWORD=pass_fake_dev
📄 .env.staging (Môi trường Staging)
BASE_URL=https://staging.crm.anhtester.com
ADMIN_PASSWORD=pass_fake_staging
📄.env.development.local (Bí mật thật sự)
# File này sẽ GHI ĐÈ lên tất cả file trên
# Điền mật khẩu thật của bạn vào đây
ADMIN_PASSWORD=MatKhauThat123!@#
Đây là Bảng Xếp Hạng Quyền Lực chính xác 100% của dotenv-flow theo thứ tự từ thấp đến cao.
Nguyên tắc cốt lõi: "Cái load sau sẽ GHI ĐÈ (Overwrite) cái load trước".
🏆 Tháp Quyền Lực (Hierarchy) của dotenv-flow
Giả sử bạn đang chạy lệnh: NODE_ENV=test (Môi trường Test).
dotenv-flow sẽ đọc và nạp biến theo thứ tự từ 1 đến 4:
|
Thứ tự Load |
Tên File |
Độ Ưu Tiên |
Giải thích |
|
1 (Đầu tiên) |
.env |
Thấp nhất |
Cấu hình mặc định cho tất cả môi trường. |
|
2 |
.env.local |
Trung bình |
Cấu hình Override cho máy cá nhân (nhưng áp dụng cho mọi môi trường). |
|
3 |
.env.test |
Cao |
Cấu hình CHUYÊN BIỆT cho môi trường Test. Nó sẽ ghi đè .env.local. |
|
4 (Cuối cùng) |
.env.test.local |
VUA (Cao nhất) |
Cấu hình BÍ MẬT chuyên biệt cho môi trường Test trên máy cá nhân. |
🧪 Ví dụ Minh Họa "Sự Ghi Đè"
Hãy xem biến PASSWORD biến đổi như thế nào qua từng lớp file.
Giả sử ta có 4 file với nội dung sau:
.env: PASSWORD=PassMacDinh
.env.local: PASSWORD=PassCuaMayToi
.env.test: PASSWORD=PassCuaServerTest
.env.test.local: PASSWORD=PassXinChiMinhToiBiet
▶️ Kịch bản 1: Chạy NODE_ENV=test
Quá trình dotenv-flow xử lý:
Đọc .env: Password = PassMacDinh
Đọc .env.local: Ghi đè thành PassCuaMayToi
Đọc .env.test: Ghi đè thành PassCuaServerTest
(⚠️ Đây là chỗ mọi người hay nhầm: File môi trường cụ thể .env.test mạnh hơn file .env.local chung chung).
Đọc .env.test.local: Ghi đè thành PassXinChiMinhToiBiet
👉 Kết quả cuối cùng: Code nhận được PassXinChiMinhToiBiet.
▶️ Kịch bản 2: Nếu KHÔNG CÓ file .env.test.local?
Đọc .env: PassMacDinh
Đọc .env.local: PassCuaMayToi
Đọc .env.test: PassCuaServerTest (Ghi đè lên PassCuaMayToi)
👉 Kết quả cuối cùng: Code nhận được PassCuaServerTest.
(Lúc này PassCuaMayToi ở .env.local bị vô hiệu hóa vì trùng tên biến).
💡 Tại sao thiết kế lại như vậy?
Thư viện dotenv-flow tư duy rằng:
"Khi bạn đã chỉ định chạy môi trường TEST, thì những cấu hình của TEST (.env.test) phải quan trọng hơn những cấu hình LOCAL chung chung (.env.local)."
Chỉ khi bạn tạo ra một file vừa là Test, vừa là Local (.env.test.local), nó mới được coi là quan trọng nhất.
📝 Kết luận cho cấu trúc dự án của bạn
Để tránh bị rối, bạn hãy tuân thủ quy tắc 3 lớp này trong thực tế (bỏ qua lớp .env.local trung gian nếu không cần thiết):
.env: Chứa biến không bao giờ đổi (Project Name).
.env.test / .env.development: Chứa biến theo môi trường (URL, Public Key).
.env.test.local / .env.development.local: Chứa biến bí mật (Password, Private Key).
Như vậy bạn sẽ luôn kiểm soát được biến nào đang được sử dụng!
Cấu hình playwright.config.ts
Chúng ta cần kích hoạt dotenv-flow ngay khi Playwright khởi động.
File: playwright.config.ts
import dotenvFlow from 'dotenv-flow';
if (!process.env.NODE_ENV) {
process.env.NODE_ENV = process.env.NODE_ENV || process.env.ENV_NAME || 'development';
}
dotenvFlow.config({
default_node_env: 'development',
});
Nguyên lý Sống Còn: "Đứng Ở Đâu?" (Root Directory)
Đây là lỗi 90% người mới gặp phải: Code đúng nhưng chạy báo undefined.
Nguyên lý:
dotenv-flow hoạt động dựa trên process.cwd() (Current Working Directory - Thư mục hiện tại bạn đang đứng gõ lệnh).
- Nó sẽ tìm file .env ngay dưới chân bạn đứng.
- File .env nằm ở thư mục ROOT.
❌ Cách chạy SAI (Chui vào hang):
cd tests
npx playwright test
👉 Hậu quả: Bạn đang đứng ở tests/. dotenv tìm file .env trong thư mục tests/. Không thấy -> Không load gì cả -> Lỗi.
✅ Cách chạy ĐÚNG (Đứng ở đỉnh núi):
Luôn mở Terminal tại thư mục chứa package.json.
# Đứng tại my-project/
npx playwright test
👉 Kết quả: Tìm thấy .env -> Load biến thành công.
Thực Hành: Chạy Test Đa Môi Trường
Chúng ta sẽ viết một file test nhỏ để kiểm tra xem nó load biến nào.
File: tests/env-demo.spec.ts
import { test } from '@playwright/test';
test('Check Environment Variables', async ({ page }) => {
const url = process.env.BASE_URL;
const password = process.env.ADMIN_PASSWORD;
console.log('-----------------------------------');
console.log(`🔗 URL đang test: ${url}`);
console.log(`🔑 Mật khẩu dùng: ${password}`);
console.log('-----------------------------------');
// Mở thử trang web
if(url) await page.goto(url);
});
Trường hợp A: Chạy mặc định (Sẽ load .env.development)
npx playwright test tests/env-demo.spec.ts
Trường hợp B: Chạy môi trường Staging
- Windows (PowerShell):
$env:NODE_ENV="staging"; npx playwright test tests/env-demo.spec.ts
- Mac / Linux / Git Bash:
NODE_ENV=staging npx playwright test tests/env-demo.spec.ts
Bảo mật Git (.gitignore)
Cuối cùng và quan trọng nhất: Giấu biến.
Mở file .gitignore và thêm ngay dòng sau:
# Chặn tất cả file .local (Chứa password thật)
.env.local
.env.*.local
Tại sao dotenv-flow lại phụ thuộc vào NODE_ENV?
Bạn hãy tưởng tượng dotenv-flow giống như một người thủ thư.
- Kho sách gồm các file: .env.development, .env.staging, .env.production.
- NODE_ENV chính là cái Phiếu Yêu Cầu.
Quy trình hoạt động:
- Người thủ thư (dotenv-flow) nhìn vào cái phiếu (NODE_ENV).
- Nếu trên phiếu ghi chữ "staging".
- Thủ thư sẽ đi tìm đúng cuốn sách tên là .env.staging để đưa cho bạn.
Nếu không có cái phiếu NODE_ENV này (hoặc phiếu trắng), thủ thư sẽ bối rối và thường sẽ lấy cuốn mặc định (development) hoặc không lấy gì cả (tùy config).
// 👇 Bước 1: Kiểm tra xem có phiếu (NODE_ENV) chưa?
if (!process.env.NODE_ENV) {
// 👇 Bước 2: Nếu chưa có, thử tìm phiếu phụ (ENV_NAME) hoặc điền mặc định là 'development'
process.env.NODE_ENV = process.env.ENV_NAME || 'development';
}
// 👇 Bước 3: Đưa phiếu cho thủ thư và yêu cầu lấy sách
dotenvFlow.config({
default_node_env: 'development', // Lưới an toàn cuối cùng
});
- Tác dụng: Đoạn code này đảm bảo rằng dù bạn quên gõ lệnh NODE_ENV=... ở Terminal, thì code vẫn tự hiểu là chạy môi trường development. Nó giúp bạn không bao giờ gặp lỗi undefined khi chạy ở máy local.
Tại sao BẮT BUỘC phải để trong playwright.config.ts?
Đây là câu chuyện về "Thời Điểm Vàng" (Timing).
Để hiểu điều này, bạn cần biết vòng đời khởi chạy của Playwright:
- Giai đoạn 0 (Khởi động): Bạn gõ lệnh npx playwright test.
- Giai đoạn 1 (Đọc Config - QUAN TRỌNG): Playwright tìm và đọc file config.ts ĐẦU TIÊN.
- Tại đây, nó cần biết: Base URL là gì? Chạy trình duyệt nào? Timeout bao lâu?
- Giai đoạn 2 (Setup): Chạy các Global Setup (nếu có).
- Giai đoạn 3 (Worker): Chia bài test cho các Worker để chạy.
Lý do 1: Vấn đề "Con Gà và Quả Trứng"
Hãy nhìn đoạn config này:
export default defineConfig({
use: {
// 👇 Dòng này chạy NGAY LẬP TỨC khi file được đọc
baseURL: process.env.BASE_URL,
},
});
Nếu bạn load dotenv-flow ở trong file test (tests/example.spec.ts):
Lúc Playwright đọc Config (Giai đoạn 1), file test chưa hề được chạy.
=> env.BASE_URL lúc này là undefined.
=> Config bị lỗi hoặc nhận giá trị sai.
👉 Kết luận: Bạn phải load biến môi trường TRƯỚC KHI Playwright kịp đọc bất kỳ dòng cấu hình nào. Nơi duy nhất làm được điều đó chính là đầu file playwright.config.ts.
Lý do 2: Phạm vi ảnh hưởng (Global Scope)
Khi bạn import dotenv ở file config tổng:
- Biến môi trường sẽ được nạp vào bộ nhớ của tiến trình chính (Main Process).
- Từ Main Process, nó sẽ được truyền xuống cho tất cả các thành phần con:
- Global Setup / Teardown.
- Project Dependencies (như file setup.ts).
- Các Worker chạy test.
Nếu bạn để ở chỗ khác, có thể Worker này có biến, nhưng Worker kia lại không -> Gây ra lỗi chập chờn (Flaky) rất khó sửa.
🎯 Tóm lại
- NODE_ENV là chìa khóa: Nó quyết định dotenv-flow sẽ mở cánh cửa nào (.env.test hay .env.prod). Đoạn code if của bạn là cơ chế "tự điền chìa khóa" nếu người dùng quên mang.
- config.ts là cửa ngõ: Phải nạp đạn (biến môi trường) ngay tại cổng vào thì súng (Playwright) mới bắn được. Nạp chậm là hỏng hết cấu hình.
⚡ Phần 2: Chuẩn Hóa Lệnh Chạy Đa Nền Tảng (cross-env & scripts)
Ở bài trước, để chạy môi trường Staging, chúng ta phải làm như sau:
- Team dùng Mac/Linux:
NODE_ENV=staging npx playwright test
- Team dùng Windows (PowerShell):
$env:NODE_ENV="staging"; npx playwright test
- Team dùng Windows (CMD cũ):
set NODE_ENV=staging && npx playwright test
👉 Bất cập:
- Khó nhớ: Lệnh quá dài và dễ gõ sai cú pháp.
- Khó chia sẻ: Bạn không thể viết một file hướng dẫn chung cho cả team được.
- Chết khi lên CI/CD: Server CI (Linux) sẽ báo lỗi ngay lập tức nếu bạn lỡ viết lệnh theo kiểu Windows vào file cấu hình.
- Giải pháp: cross-env (Người Phiên Dịch)
cross-env là một thư viện nhỏ giúp "phiên dịch" lệnh gán biến môi trường thành một ngôn ngữ chung mà mọi hệ điều hành đều hiểu.
Cài đặt:
npm install cross-env --save-dev
Cách dùng:
Thay vì gõ $env:... hay export..., bạn chỉ cần gõ:
npx cross-env NODE_ENV=staging ...
Lúc này, cross-env sẽ tự động nhìn xem máy bạn là Windows hay Mac để dịch sang lệnh native tương ứng.
Vai trò của package.json (Trung Tâm Điều Khiển)
Chúng ta không muốn mỗi lần chạy test phải gõ lại npx cross-env NODE_ENV=staging....
Chúng ta sẽ lưu các lệnh dài này vào mục "scripts" trong file package.json.
Hãy coi package.json giống như cái Menu quán ăn:
- Khách gọi: "Cho món số 1" (npm run test:dev).
- Nhà bếp thực hiện: "Lấy cross-env, gán NODE_ENV=development, rồi chạy playwright test...".
Tại sao phải viết vào đây?
- Phím tắt (Alias): Biến lệnh dài 100 ký tự thành 1 lệnh ngắn gọn.
- Ngữ cảnh (Context): Khi chạy qua npm run, lệnh luôn được thực thi từ Thư mục Root. Bạn không lo vấn đề đứng sai thư mục (lỗi undefined biến môi trường ở bài trước).
- Đồng bộ: Cả team chỉ cần biết lệnh npm run test:dev, không cần quan tâm máy ai dùng OS gì.
- Thực Hành: Thiết lập Scripts
Mở file package.json và tìm mục "scripts". Hãy sửa nó thành như sau:
{
"scripts": {
// 1. Chạy mặc định (thường là Dev hoặc Test local)
"test": "npx playwright test",
// 2. Chạy môi trường DEV
// 👉 Giải thích: Gán NODE_ENV=development -> dotenv-flow load .env.development
"test:dev": "cross-env NODE_ENV=development npx playwright test",
// 3. Chạy môi trường STAGING
// 👉 Giải thích: Gán NODE_ENV=staging -> dotenv-flow load .env.staging
"test:staging": "cross-env NODE_ENV=staging npx playwright test",
// 4. Chạy môi trường PRODUCTION (Chỉ chạy trình duyệt Chrome)
"test:prod": "cross-env NODE_ENV=production npx playwright test --project=chromium",
// 5. Chạy Debug (Có giao diện)
"test:debug": "cross-env NODE_ENV=development npx playwright test --headed"
}
}
Giải Mã Luồng Chạy (Deep Dive)
Khi bạn gõ lệnh:
npm run test:staging
Điều gì sẽ xảy ra bên dưới?
- npm (Node Package Manager): Nhận lệnh, tìm trong json xem test:staging định nghĩa là gì.
- cross-env: Nhận thấy lệnh cần thực thi là gán NODE_ENV=staging.
- Nếu máy là Windows: Nó ngầm hiểu là $env:NODE_ENV="staging".
- Nếu máy là Linux: Nó ngầm hiểu là export NODE_ENV=staging.
- Shell: Thực thi lệnh đã được dịch.
- Playwright: Khởi động.
- Playwright chạy config.ts.
- Trong config có config().
- dotenv-flow thấy NODE_ENV đang là staging.
- -> Nó load file .env.staging.
- Kết quả: Test chạy trên URL của Staging.
- Tổng Kết
Bây giờ quy trình làm việc của team bạn sẽ cực kỳ chuyên nghiệp:
- Dev A (Dùng Mac): Muốn chạy dev? Gõ npm run test:dev.
- Dev B (Dùng Windows): Muốn chạy dev? Cũng gõ npm run test:dev.
- Server CI (Linux): Muốn chạy staging? Cấu hình lệnh npm run test:staging.
Không ai phải nhớ lệnh dài. Không ai bị lỗi cú pháp.
⚓ Phần 3: Test Hooks & Sự Tiến Hóa Lên Fixture
"Tứ Trụ" Hooks (Lifecycle)
Trong mọi Framework testing (Jest, Mocha, Selenium, Playwright), chúng ta luôn có 4 vị thần hộ pháp để quản lý vòng đời:
|
Tên Hook |
Phạm vi (Scope) |
Tần suất chạy |
Nhiệm vụ phổ biến |
|
beforeAll |
Worker |
1 lần duy nhất / file |
Khởi tạo Data DB, Kết nối Server, Login lấy Token. |
|
beforeEach |
Test |
Chạy lặp lại trước MỖI test |
Mở trang web (goto), Reset trạng thái sạch. |
|
test |
Test |
Chạy 1 lần |
Thân bài test (Thao tác & Verify). |
|
afterEach |
Test |
Chạy lặp lại sau MỖI test |
Chụp ảnh lỗi, Xóa dữ liệu rác vừa tạo. |
|
afterAll |
Worker |
1 lần duy nhất / file |
Xóa DB, Đóng kết nối, Gửi báo cáo. |
Code Demo: Kiểu "Cổ Điển" (Classic Style)
Đây là cách viết code mà 90% người mới (hoặc chuyển từ Selenium qua) sẽ viết.
File: tests/hooks-demo.spec.ts
import { test, expect, Page } from '@playwright/test';
// Khai báo biến toàn cục (Nhược điểm lớn của Hooks)
let page: Page;
test.beforeAll(async () => {
console.log('🏁 [beforeAll] Khởi động DB...');
});
test.beforeEach(async ({ browser }) => {
console.log('🔄 [beforeEach] Mở trang mới...');
// Tự tạo page (Nếu không dùng fixture page có sẵn)
const context = await browser.newContext();
page = await context.newPage();
await page.goto('https://crm.anhtester.com');
});
test('Test Case 1: Login', async () => {
console.log('🧪 [Test 1] Đang chạy...');
await expect(page).toHaveTitle(/CRM/);
});
test('Test Case 2: Check Footer', async () => {
console.log('🧪 [Test 2] Đang chạy...');
// beforeEach lại chạy một lần nữa trước khi vào đây
await expect(page.locator('footer')).toBeVisible();
});
test.afterEach(async () => {
console.log('🧹 [afterEach] Dọn dẹp...');
await page.close();
});
test.afterAll(async () => {
console.log('🛑 [afterAll] Ngắt kết nối DB.');
});
Tại sao phải khai báo let page: Page ở ngoài?
Trong JavaScript/TypeScript, biến có Phạm vi hoạt động (Scope). Biến được khai báo ở đâu thì chỉ sống được trong cái ngoặc nhọn {} đó thôi.
Hãy xem sơ đồ luồng dữ liệu
A[Global Scope] --> B{let page}
B --> C[beforeEach Scope]
B --> D[Test 1 Scope]
B --> E[Test 2 Scope]
B --> F[afterEach Scope]
C -- Gán giá trị mới --> B
D -- Sử dụng --> B
E -- Sử dụng --> B
F -- Đóng/Hủy --> B
Kịch bản xảy ra:
- Nếu bạn khai báo const page BÊN TRONG beforeEach:
- Biến page được sinh ra khi beforeEach chạy.
- Ngay khi beforeEach chạy xong, biến page bị hủy (chết).
- Đến lúc hàm test() chạy, nó hỏi: "Ủa page đâu?" -> Lỗi: page is not defined.
- Khi bạn khai báo let page BÊN NGOÀI (như trong code):
- Biến page được tạo ra ở cấp cao nhất (Global của file).
- beforeEach: "Mượn" cái vỏ biến đó để nhét dữ liệu (browser mới) vào.
- test: "Mượn" cái vỏ đó để dùng (vì biến nằm ở ngoài nên ai cũng thấy).
- afterEach: "Mượn" cái vỏ đó để dọn dẹp.
👉 Kết luận: Khai báo bên ngoài giống như việc đặt một cái Bảng Trắng ở hành lang chung. Ông A (beforeEach) viết lên đó, ông B (Test) đọc, ông C (afterEach) xóa đi. Nếu bảng nằm trong phòng riêng của ông A thì ông B không thể đọc được.
Tại sao Hooks (let page) lại có quá nhiều nhược điểm?
Mặc dù cách dùng let ở ngoài giúp code chạy được, nhưng trong các dự án lớn, đây là "Mầm mống của tai họa". Dưới đây là 4 lý do chi tiết:
❌ Nhược điểm 1: Mất an toàn kiểu dữ liệu (Type Safety Nightmare)
Khi bạn khai báo:
let page: Page;
Về mặt kỹ thuật, lúc mới khởi tạo, giá trị của nó là undefined.
Nhưng trong code test, bạn dùng nó như thể nó luôn tồn tại (page.click(...)).
- Rủi ro: Nếu vì lý do nào đó mà beforeEach bị lỗi hoặc quên gán page, thì khi vào test, code sẽ crash với lỗi Cannot read properties of undefined.
- TypeScript: Sẽ liên tục bắt bẻ bạn: "Biến này có thể là undefined nha bạn". Bạn sẽ phải dùng dấu ! (page!.click) hoặc tắt strict check, làm giảm độ an toàn của code.
❌ Nhược điểm 2: Chết khi chạy Serial (Tuần tự) trong 1 Worker
Như ta đã thảo luận ở bài trước: Playwright sẽ tái sử dụng (reuse) Worker để tiết kiệm RAM.
- Test 1 chạy xong, gán page = Page A (đã login).
- Test 2 chạy tiếp (trên cùng worker đó), beforeEach có thể quên reset sạch sẽ hoặc dùng lại biến cũ.
- 👉 Lỗi: State Pollution (Ô nhiễm trạng thái). Test 2 bị ảnh hưởng bởi dữ liệu rác của Test 1.
❌ Nhược điểm 3: Khó đọc và Khó bảo trì (Spaghetti Code)
Trong một file test dài 500 dòng:
- Dòng 1: Khai báo let page.
- Dòng 50: beforeEach khởi tạo page.
- Dòng 400: test sử dụng page.
Khi bạn đọc dòng 400, bạn phải cuộn chuột lên tận dòng 50 mới biết page này được config như thế nào (có bật video không? view port bao nhiêu?). Logic bị phân tán khắp nơi.
❌ Nhược điểm 4: Không thể tái sử dụng (Non-reusable)
Nếu file test khác (login.spec.ts) cũng cần quy trình khởi tạo page y hệt như vậy, bạn làm gì?
- Hooks: Bạn phải Copy-Paste đoạn beforeEach đó sang file mới -> Code lặp (Duplicate).
- Fixture: Bạn chỉ cần viết 1 lần trong file fixture, và import dùng cho 100 file test khác nhau.
❌ Nhược điểm 5: Khó cấu hình riêng (Configuration Hell)
Giả sử Test A cần màn hình to (Desktop), Test B cần màn hình nhỏ (Mobile).
Nếu dùng let page chung trong beforeEach:
// beforeEach chung cho cả file
test.beforeEach(async () => {
// Bạn chỉ có thể cấu hình 1 kiểu Viewport ở đây
page = await context.newPage();
});
Bạn không thể custom cho riêng Test B được (trừ khi viết if/else rất xấu).
Ví dụ về việc dùng sai hooks và biến toàn cục
💥 Ví dụ 1: "Kẻ Phá Hoại" (State Pollution)
Tình huống: Test A dùng xong tiện tay đóng trình duyệt. Test B đến sau không còn gì để dùng.
File: tests/bad-practice-serial.spec.ts
import { test, expect, Page, chromium } from '@playwright/test';
// ⚠️ CẤU HÌNH: Chạy tuần tự để chứng minh Test A ảnh hưởng Test B
test.describe.configure({ mode: 'serial' });
// ❌ SAI LẦM: Khai báo biến toàn cục
let sharedPage: Page;
test.beforeAll(async () => {
console.log('🏁 [beforeAll] Khởi tạo trình duyệt chung...');
const browser = await chromium.launch();
const context = await browser.newContext();
sharedPage = await context.newPage();
await sharedPage.goto('https://crm.anhtester.com/admin/authentication');
});
test('Test A: Login và... Phá hoại', async () => {
console.log('🧪 [Test A] Đang chạy...');
await expect(sharedPage).toHaveTitle(/CRM/);
// 🧨 HÀNH ĐỘNG PHÁ HOẠI:
// Test A chạy xong, tiện tay đóng luôn Page
console.log('💣 [Test A] Đóng trình duyệt!');
await sharedPage.close();
});
test('Test B: Kiểm tra Footer', async () => {
console.log('🧪 [Test B] Đang chạy...');
// 💥 BÙM! Test B fail ngay dòng này
// Lỗi: "Target page, context or browser has been closed"
// Vì sharedPage đã bị thằng A đóng mất rồi!
await expect(sharedPage.locator('button[type="submit"]')).toBeVisible();
});
🗣️Biến sharedPage giống như cái điều khiển TV dùng chung. Ông A xem xong tiện tay đập vỡ cái điều khiển (close). Ông B vào sau cầm lên bấm thì dĩ nhiên là lỗi!"
💥 Ví dụ 2: "Cuộc Đua Tử Thần" (Resource Race Condition)
import { test } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DB_FILE = path.join(__dirname, 'temp-user-db.txt');
// ⚠️ Bắt buộc chạy song song
test.describe.configure({ mode: 'parallel' });
test.beforeAll(async () => {
const workerId = process.env.TEST_WORKER_INDEX;
const mySignature = `Worker ${workerId}`;
console.log(`🛠️ [${mySignature}] Bắt đầu...`);
// 1. Giả lập độ trễ ngẫu nhiên để 2 worker dễ đụng nhau hơn
const delay = Math.floor(Math.random() * 500);
await new Promise(r => setTimeout(r, delay));
// 2. GHI DỮ LIỆU
// (Trong thực tế đây là lệnh INSERT vào DB)
fs.writeFileSync(DB_FILE, mySignature);
console.log(`✍️ [${mySignature}] Đã ghi tên mình vào file.`);
// 3. ĐỢI CHÚT... (Để thằng kia có cơ hội ghi đè)
await new Promise(r => setTimeout(r, 200));
// 4. KIỂM TRA LẠI (VERIFY)
// Đọc file xem có đúng là tên mình không? Hay thằng khác ghi đè rồi?
const currentContent = fs.readFileSync(DB_FILE, 'utf-8');
if (currentContent !== mySignature) {
// 💥 BẮT QUẢ TANG!
console.error(`🔥 [${mySignature}] BÙM! Dữ liệu của tui đâu? Thấy: "${currentContent}"`);
throw new Error(`RACE CONDITION DETECTED! [${mySignature}] bị [${currentContent}] ghi đè dữ liệu!`);
} else {
console.log(`✅ [${mySignature}] An toàn.`);
}
});
test('Test A', async ({ page }) => { await page.waitForTimeout(100); });
test('Test B', async ({ page }) => { await page.waitForTimeout(100); });
👀 Kịch bản bạn sẽ thấy (Fail):
- Worker 0 ghi: "Worker 0".
- Worker 1 (chạy song song) ghi đè ngay lập tức: "Worker 1".
- Worker 0 đọc lại file: Thấy chữ "Worker 1".
- Worker 0 hét lên: "BÙM! Dữ liệu của tui đâu? Thấy Worker 1". ->
💡 Bài học rút ra:
- Parallel rất nhanh: 2 Worker chạy gần như cùng lúc từng mili-giây.
- Biến toàn cục/Tài nguyên chung (File/DB) cực nguy hiểm:
- Nếu dùng beforeAll để tạo dữ liệu chung (VD: file config, user admin cố định).
- Khi chạy song song, Worker này sẽ đạp lên chân Worker kia.
- Cách sửa:
- Dùng Fixture (Tạo biến cục bộ).
- Nếu buộc dùng beforeAll: Phải tạo dữ liệu Ngẫu nhiên (VD: txt, user_worker2.txt) để không ai đụng ai.
Ví dụ 3:
- Trong chế độ Parallel (Song song): Các biến toàn cục (let page) KHÔNG BAO GIỜ bị ghi đè lẫn nhau.
- Lý do: Mỗi Worker là một Process (Tiến trình) riêng biệt của hệ điều hành. Worker 1 có RAM riêng, Worker 2 có RAM riêng. Biến page của Worker 1 và Worker 2 là 2 biến khác nhau hoàn toàn.
- Kết luận: Trong Parallel, bạn an toàn về biến số, chỉ sợ xung đột DB/File thôi.
- Mối nguy hiểm thực sự nằm ở chế độ Serial (Tuần tự) hoặc Tái sử dụng (Reuse) trong cùng 1 Worker.
- Đây là lúc biến let page trở thành "kẻ giết người" vì nó lưu giữ trạng thái bẩn (State Pollution) từ bài test trước sang bài test sau.
Dưới đây là ví dụ minh họa cách let page làm "bẩn" môi trường như thế nào.
💥 Ví dụ: "Cái Bếp Bẩn" (State Pollution)
Kịch bản:
Bạn dùng biến toàn cục let page và cố gắng tái sử dụng nó trong beforeEach (một cách tối ưu hóa sai lầm thường gặp) để đỡ phải mở lại trình duyệt.
- Test 1: Vào web, chuyển sang chế độ Dark Mode (Lưu vào LocalStorage).
- Test 2: Vào web, mong đợi mặc định là Light Mode.
- Kết quả: Test 2 thấy Dark Mode (do Test 1 để lại) -> Fail.
File: tests/pollution.spec.ts
import { test, expect, Page, chromium } from '@playwright/test';
// ⚠️ Cấu hình chạy tuần tự để thấy rõ hậu quả dùng chung biến
test.describe.configure({ mode: 'serial' });
// ❌ BIẾN TOÀN CỤC (Shared State)
let sharedPage: Page;
test.beforeAll(async () => {
const browser = await chromium.launch();
// Tạo context 1 lần duy nhất
const context = await browser.newContext();
sharedPage = await context.newPage();
});
// hook này chạy trước mỗi test
test.beforeEach(async () => {
// Thay vì tạo mới, ta dùng lại biến sharedPage (SAI LẦM)
await sharedPage.goto('https://example.com');
});
test('Test 1: User bật chế độ tối (Dark Mode)', async () => {
console.log('🌑 Test 1 chạy...');
// Hành động làm "bẩn" môi trường: Set LocalStorage
await sharedPage.evaluate(() => {
localStorage.setItem('theme', 'dark');
});
// Verify
const theme = await sharedPage.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBe('dark');
});
test('Test 2: User mới vào mong đợi chế độ sáng (Light Mode)', async () => {
console.log('☀️ Test 2 chạy...');
// 💣 BÙM! Test 2 mong đợi là 'light' (mặc định)
// NHƯNG thực tế nó nhận được 'dark' do Test 1 để lại trong sharedPage
const theme = await sharedPage.evaluate(() => localStorage.getItem('theme'));
// Fail đỏ lòm tại đây
expect(theme).toBeNull(); // Mong đợi chưa set gì cả
});
🔍 Giải thích sự thất bại
Test 1:
Dùng sharedPage.
Ghi vào bộ nhớ trình duyệt: theme = dark.
Kết thúc test, nhưng sharedPage không bị đóng, bộ nhớ vẫn còn đó.
Test 2:
beforeEach chạy: sharedPage.goto(...). (Lưu ý: goto chỉ tải lại trang, không xóa LocalStorage).
Code test chạy: Kiểm tra theme.
Nhận được dark -> Sai logic nghiệp vụ -> Fail.
🛡️ Cách Fixture giải quyết vấn đề này
Nếu bạn dùng Fixture { page } chuẩn của Playwright, điều kỳ diệu sẽ xảy ra:
// Dùng Fixture
test('Test 1', async ({ page }) => {
// Playwright tạo Context A (Sạch)
await page.evaluate(() => localStorage.setItem('theme', 'dark'));
// Kết thúc -> Playwright HỦY Context A (Xóa sạch mọi thứ)
});
test('Test 2', async ({ page }) => {
// Playwright tạo Context B (Mới tinh)
// Context B không hề biết gì về Context A
const theme = await page.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBeNull(); // ✅ PASS
});
🎯Parallel (Song song): Biến toàn cục let an toàn (vì khác RAM), nhưng Tài nguyên chung (DB/File) thì chết.
Serial (Tuần tự): Biến toàn cục let cực kỳ nguy hiểm vì nó mang rác từ test trước sang test sau (Cookies, LocalStorage, Session).
Chân lý: Đừng bao giờ tiếc công khởi tạo page mới.
Test chậm mà đúng còn hơn Test nhanh mà sai (Flaky).
Luôn dùng Fixture để đảm bảo mỗi bài test có một "căn phòng sạch sẽ" riêng biệt.
Lưu ý về parallel
Đúng là nếu chạy Fully Parallel (mỗi test 1 worker riêng biệt), thì Test B sẽ Pass vì nó có một beforeAll riêng và sharedPage riêng.
Tuy nhiên, trong thực tế kỹ thuật phần mềm, cách làm này vẫn bị coi là Bad Practice (Tệ hại) và Cấm kỵ.
Tại sao? Vì bạn đang chơi trò "xổ số" với kiến trúc của Playwright. Dưới đây là 3 lý do chí mạng:
1. Cơ chế "Tái sử dụng Worker" (Worker Reuse)
Đây là lý do quan trọng nhất.
- Lý thuyết: Bạn nghĩ 2 test sẽ chạy trên 2 worker khác nhau.
- Thực tế: Khởi động 1 Worker (bật Browser) rất tốn RAM và CPU. Playwright rất thông minh, nó sẽ cố gắng tái sử dụng
Kịch bản chết người:
Giả sử bạn có 100 bài test trong file đó, nhưng máy bạn chỉ có 4 CPU (4 Workers).
- Worker 1 chạy beforeAll (Mở Page).
- Worker 1 chạy Test A (Login -> Page chuyển sang Dashboard).
- Worker 1 chưa tắt, nó nhận tiếp Test B.
- Lúc này, beforeAll KHÔNG CHẠY LẠI (vì nó là beforeAll - chạy 1 lần cho cả file trong worker đó).
- Test B nhận lại cái sharedPage cũ từ Test A (đang ở Dashboard).
- BÙM! 💥 Test B fail.
👉 Kết luận: Bạn không thể đảm bảo 100% rằng Test A và Test B luôn chạy trên 2 worker khác nhau. Chỉ cần chúng chạy chung 1 worker, code của bạn sẽ gãy.
2. Lỗi "Chập chờn" (Flaky Tests)
Trong Automation, kẻ thù lớn nhất không phải là Bug, mà là Flaky Test (Lúc xanh lúc đỏ).
- Hôm nay bạn chạy trên máy cty (máy mạnh, 16 workers) -> Test A và B tách ra -> Pass.
- Ngày mai bạn đẩy lên CI (Github Actions miễn phí, chỉ có 2 workers) -> Playwright dồn Test A và B vào chung 1 worker -> Fail.
Bạn sẽ mất hàng giờ để debug xem tại sao code không đổi mà kết quả lại khác nhau. Nguyên nhân chính là do Shared State (Dùng chung biến page).
3. Vi phạm nguyên tắc "Cô lập" (Isolation)
Nguyên tắc vàng: Mỗi bài test phải tự chịu trách nhiệm cho cuộc đời của nó.
Nếu Test B fail vì Test A login, thì đó là lỗi của kiến trúc test, không phải lỗi của phần mềm.
- Test B muốn test "Quên mật khẩu"? -> Test B phải tự mở trang Login.
- Test A muốn test "Dashboard"? -> Test A phải tự Login.
Việc phụ thuộc vào "thằng đi trước để lại gì dùng nấy" giống như việc bạn vào khách sạn mà người dọn phòng không thay ga giường vậy. Rất rủi ro! 🤢
✅ Cách sửa chuẩn (Dù chạy Parallel hay Serial đều ngon)
Hãy dùng Fixture hoặc beforeEach. Nó đảm bảo dù Playwright có tái sử dụng Worker hay không, thì trước khi Test B chạy, nó luôn nhận được một page mới tinh.
🎯 Tóm lại
Dùng beforeAll khởi tạo page chỉ an toàn tuyệt đối khi:
- File đó CHỈ CÓ 1 Test Case duy nhất.
- Hoặc các Test Case bên trong KHÔNG HỀ thay đổi trạng thái của Page (chỉ Read-only).
✅ Trường hợp Dùng beforeAll
Khi logic đó là Độc nhất vô nhị cho file test này, không file nào khác cần dùng.
Ví dụ: File test-import-excel.spec.ts.
- Bạn cần đọc file xlsx vào bộ nhớ.
- Chỉ mỗi file test này cần file excel đó.
- 👉 Viết beforeAll ngay trong file cho nhanh. Tạo Fixture làm gì cho mệt?
let excelData;test.beforeAll(async () => { excelData = await readExcel(); // Làm nhanh gọn lẹ});
✅ Trường hợp Dùng beforeEach
✅ Trường hợp 1: Điều hướng chung (Common Navigation)
Fixture thường chỉ cung cấp cho bạn một trang web "đã đăng nhập" (authedPage), nhưng nó thường đứng ở trang chủ (Dashboard).
Nếu file test của bạn là products.spec.ts (chuyên test màn hình sản phẩm), bạn không muốn bài test nào cũng phải viết lại dòng goto('/products').
import { test } from '../fixtures/my-fixtures';
// 1. Fixture: Lo việc Login + Tạo Page (Nặng nhọc)
// 2. beforeEach: Lo việc đi đến đúng chỗ cần test (Nhẹ nhàng)
test.beforeEach(async ({ authedPage }) => {
await authedPage.goto('/products');
});
test('Search Product', async ({ authedPage }) => {
// Vào thẳng việc search, không cần goto nữa
await authedPage.fill('#search', 'iPhone');
});
test('Add Product', async ({ authedPage }) => {
await authedPage.click('#add-btn');
});
👉 Tác dụng: Giúp code trong hàm test() ngắn gọn, tập trung vào nghiệp vụ chính.
✅ Trường hợp 2: Mocking/Interception Cục Bộ (Đặc thù file)
Giả sử toàn bộ dự án đều cần API hoạt động bình thường (Happy Path). Nhưng riêng file error-handling.spec.ts này, bạn muốn test xem giao diện xử lý thế nào khi Server bị lỗi 500.
Bạn không thể sửa Fixture gốc (vì sẽ làm hỏng các bài test khác). Bạn dùng beforeEach để "ghi đè" mạng chỉ cho file này.
test.beforeEach(async ({ page }) => {
// Chặn API và trả về lỗi 500 cho MỌI test trong file này
await page.route('**/api/products', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' })
});
});
});
test('Hiển thị thông báo lỗi khi API chết', async ({ page }) => {
await page.goto('/products');
// Kỳ vọng hiện thông báo đỏ
await expect(page.locator('.error-toast')).toBeVisible();
});
👉 Tác dụng: Biến đổi môi trường test cho một mục đích cụ thể mà không ảnh hưởng toàn dự án.
✅ Trường hợp 3: Reset trạng thái UI (Clean State)
Đôi khi, Fixture cung cấp một trang web, nhưng thao tác của Test A làm thay đổi giao diện (ví dụ: mở một cái Modal, hoặc bật Dark Mode) mà Fixture không tự reset được.
Bạn dùng beforeEach để đảm bảo Test B luôn bắt đầu với trạng thái sạch.
test.beforeEach(async ({ page }) => {
// Đảm bảo luôn tắt Dark Mode trước khi test
await page.evaluate(() => localStorage.setItem('theme', 'light'));
// Đảm bảo không có modal nào đang mở
await page.keyboard.press('Escape');
});
💡Fixture là Xương sống (Backbone), còn Hooks là Da thịt.
- Bạn luôn bắt đầu với Fixture.
- Khi vào một file cụ thể, nếu thấy lặp lại code goto nhiều quá -> Dùng beforeEach.
- Nếu thấy cần chuẩn bị dữ liệu nặng chỉ cho file này -> Dùng beforeAll.
Như vậy cấu trúc test của bạn sẽ vừa mạnh mẽ (nhờ Fixture) vừa linh hoạt (nhờ Hooks).
So sánh beforeAll vs beforeEach trong kịch bản Worker Reuse
Giả sử Worker 1 chạy cả Test A và Test B (Tái sử dụng worker).
❌ Kịch bản beforeAll (Như bạn vừa đọc)
Nó giống như việc Dùng chung bàn chải đánh răng.
- beforeAll: Mua 1 cái bàn chải (page).
- Test A: Dùng bàn chải đó đánh răng. (Bàn chải bị bẩn).
- Test B: Vào sau, thấy bàn chải đã có sẵn (do beforeAll chỉ chạy 1 lần). Test B cầm cái bàn chải bẩn đó dùng tiếp.
👉 Kết quả: Lây bệnh (Fail).
✅ Kịch bản beforeEach
Nó giống như việc Khách sạn thay bàn chải mới mỗi ngày.
- Test A chuẩn bị chạy: beforeEach chạy -> Mua bàn chải Mới (Page 1).
- Test A: Dùng Page 1. Xong vứt đi.
- Test B chuẩn bị chạy: beforeEach chạy LẠI -> Mua bàn chải Mới Tinh (Page 2).
- Test B: Dùng Page 2.
👉 Kết quả: Sạch sẽ, không ai bị lây bệnh ai (Pass).
2. Vậy tại sao vẫn chê beforeEach + let page?
Nếu beforeEach giải quyết được vấn đề "Dùng chung state", tại sao mình vẫn khuyên bạn dùng Fixture?
Vì dù beforeEach chạy đúng logic, nhưng cách viết code của nó (kết hợp với let page ở ngoài) tạo ra "Rác Code":
⚠️ Vấn đề 1: Biến toàn cục (Global Variable)
Bạn vẫn phải khai báo let page: Page lơ lửng ở đầu file.
- TypeScript sẽ la ó vì biến này lúc có lúc không (undefined).
- Nếu bạn quên gán page trong beforeEach, Test sẽ crash với lỗi khó hiểu.
⚠️ Vấn đề 2: Quên dọn dẹp (Memory Leak)
Trong beforeEach, bạn mở Page mới: page = await context.newPage().
Vậy ai sẽ đóng nó?
- Bạn phải viết thêm afterEach để close().
- Nếu quên viết afterEach -> Máy tính bị đầy RAM vì mở quá nhiều tab ẩn danh mà không tắt.
⚠️ Vấn đề 3: Không tái sử dụng được (Copy-Paste)
File login.spec.ts bạn viết beforeEach để tạo page.
Sang file product.spec.ts, bạn lại phải Copy-Paste y chang đoạn code đó.
-> Code bị lặp (Duplicate), vi phạm nguyên tắc DRY (Don't Repeat Yourself).
3. Fixture là "Chân Ái"
Fixture giải quyết trọn vẹn cả 3 vấn đề trên:
- Giống beforeEach: Nó tự động tạo Page mới cho mỗi bài Test (Sạch sẽ).
- Không cần let: Page được truyền thẳng vào hàm test ({ page }).
- Tự động dọn dẹp: Test chạy xong, Fixture tự đóng Page, tự giải phóng RAM (Bạn không cần viết afterEach).
- Tái sử dụng: Viết 1 lần trong file ts, dùng cho cả 1000 file test.
🎯 Tóm tắt
- beforeAll + Worker Reuse: ❌ Rất nguy hiểm (Test sau dùng lại rác của Test trước).
- beforeEach + Worker Reuse: ✅ An toàn về dữ liệu (Mỗi test có đồ mới), nhưng Tệ về cấu trúc code (Rối rắm, lặp code).
- Fixture: ✅✅ Hoàn hảo (Vừa an toàn, vừa sạch code, vừa tự dọn dẹp).
