NỘI DUNG BÀI HỌC
Thấu hiểu kiến trúc lõi: Phân biệt rõ 4 tầng Browser > Context > Page trong Playwright.
Kỹ thuật Manual Launch: Tự điều khiển trình duyệt (Headless/Headed, SlowMo) để Debug hiệu quả.
Cơ chế Isolation: Cách ly tuyệt đối Cookies & Session giữa các User (như chế độ Ẩn danh).
Thực chiến Đa tài khoản: Code kịch bản Admin chat với Guest song song trên cùng 1 bài test.
🏗️ Phần 1: Khởi Nguyên - BrowserType & Launch
Mục tiêu: Hiểu được Cái gì tạo ra trình duyệt? Tại sao nó chạy ngầm (Headless)? Làm sao để điều khiển tốc độ của nó? Đây là tầng thấp nhất (Low-level) của Playwright.
Trước giờ dùng Fixture { page } thấy trình duyệt tự hiện lên (hoặc chạy ngầm). Nhưng ai đứng sau điều khiển nó? Hôm nay chúng ta sẽ đóng vai 'Kiến trúc sư', tự tay khởi động trình duyệt từ con số 0 mà không nhờ sự trợ giúp của Robot Fixture."
Kim Tự Tháp Quyền Lực (The Hierarchy)
Để hiển thị được một trang web, Playwright phải đi qua 4 tầng kiến tạo. Hãy tưởng tượng quy trình xây dựng một Khách Sạn:
- Tầng 1: BrowserType (Bản vẽ thiết kế)
- Là các biến: chromium, firefox, webkit.
- Nó nằm im lìm, chưa chạy.
- Tầng 2: Browser (Tòa nhà khách sạn)
- Được tạo ra bằng lệnh .launch().
- Đây là lúc phần mềm Chrome được bật lên trong Task Manager.
- Tầng 3: Context (Phòng ngủ)
- Được tạo ra bằng lệnh .newContext().
- Nơi cách ly dữ liệu (Cookies, Session).
- Tầng 4: Page (Cái Tivi/Tab)
- Được tạo ra bằng lệnh .newPage().
- Nơi ta thao tác click/gõ.
- Thực Hành: "Tay Không Bắt Giặc" (Manual Launch)
Chúng ta sẽ viết một đoạn script không dùng bất kỳ Fixture nào (không dùng { page }, không dùng { browser } luôn). Chúng ta tự import chromium để khởi động mọi thứ từ con số 0.
// 👇 Import ông trùm 'chromium' từ thư viện gốc
import { test, chromium } from '@playwright/test';
test('Khởi nguyên: Tự tay khởi động trình duyệt', async () => {
console.log('🚀 BƯỚC 1: Launch Browser (Xây nhà)');
// Đây là lúc chúng ta quyết định "Hình hài" của trình duyệt
const browser = await chromium.launch({
headless: false, // false = Hiện giao diện (Có đầu), true = Chạy ngầm (Không đầu)
slowMo: 2000, // Chuyển động chậm (2 giây mỗi thao tác) để kịp nhìn
channel: 'chrome' // (Tùy chọn) Bắt buộc dùng Google Chrome thật thay vì Chromium
});
console.log('🚪 BƯỚC 2: New Context (Mở phòng)');
// Tùy chỉnh kích thước màn hình, quay video... ở bước này
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
recordVideo: { dir: 'videos/' } // Tự động quay video lưu vào folder
});
console.log('📄 BƯỚC 3: New Page (Mở Tab)');
const page = await context.newPage();
console.log('👉 BƯỚC 4: Thao tác');
await page.goto('https://crm.anhtester.com');
await page.fill('input[name="email"]', 'admin@example.com');
// Nhờ slowMo: 2000, bạn sẽ thấy nó gõ từng chữ rất từ tốn
console.log('🛑 BƯỚC 5: Đóng cửa (Quan trọng)');
await browser.close();
// Nếu không close, cái cửa sổ Chrome đó sẽ treo mãi
});
Tại sao phải học cái này?
Dùng Fixture { page } sướng hơn, code có 1 dòng. Tại sao phải học cái code dài ngoằng này?
Lý do "Sống còn" 1: Bỏ qua Login khó (Persistent Context)
Vấn đề:
Trang web có CAPTCHA hình ảnh, có OTP gửi về điện thoại, hoặc chặn Bot cực gắt.
- Dùng Fixture { page }: Mỗi lần chạy test là một trình duyệt mới toanh -> Bị bắt nhập CAPTCHA/OTP lại từ đầu -> Test Fail ngay cửa gửi xe.
Giải pháp (Chỉ Manual Launch làm được):
Kỹ thuật launchPersistentContext.
- Bạn dùng Chrome thật của mình, login tay 1 lần, lưu hết profile (cookies, history) vào folder.
- Trong code, bạn dùng lệnh Launch trỏ vào folder đó.
- Kết quả: Trình duyệt bật lên là đã Login sẵn, vượt qua mọi loại CAPTCHA/2FA.
// Fixture bó tay, phải dùng cái này:
const browser = await chromium.launchPersistentContext('C:/Users/MyProfile', {
headless: false
});
// Bật lên là vào thẳng Dashboard, không cần login nữa!
👉 Do đó: "Nếu gặp dự án bắt nhập OTP điện thoại, dùng Fixture mặc định thì chỉ có nước 'khóc thét'. Chỉ có cách Launch thủ công này mới cứu được."
Lý do "Sống còn" 2: Giả lập thiết bị "Động" (Dynamic Emulation)
Vấn đề:
Sếp yêu cầu: "Trong cùng 1 bài test, hãy kiểm tra giao diện trên iPhone 12, sau đó chuyển sang iPad Pro, rồi chuyển sang Màn hình 4K xem có bị vỡ layout không."
- Dùng Fixture { page }: Cấu hình thiết bị nằm chết cứng trong config.ts. Muốn đổi thiết bị phải tách ra thành nhiều Project hoặc file config khác nhau. Rất cồng kềnh.
Giải pháp (Manual Launch):
Bạn có thể tạo ra bao nhiêu thiết bị tùy thích trong cùng 1 file test.
test('Test Responsive', async ({ playwright }) => {
// 1. Tạo ông dùng iPhone
const iphone = await playwright.devices['iPhone 12'];
const browser = await chromium.launch();
const contextMobile = await browser.newContext({ ...iphone }); // 👉 Bơm cấu hình vào đây
// 2. Tạo ông dùng Desktop 4K
const contextDesktop = await browser.newContext({
viewport: { width: 3840, height: 2160 }
});
// Chạy song song kiểm tra
});
👉 "Fixture giống như đi xe Bus, lộ trình cố định. Manual Launch giống như đi xe Taxi, khách muốn rẽ hướng nào (đổi thiết bị, đổi địa điểm) cũng chiều được hết ngay lập tức."
Lý do "Sống còn" 3: Test Extension & Permissions (Quyền hạn đặc biệt)
Vấn đề:
Test tính năng:
- Upload file nhưng chặn quyền truy cập Camera/Micro.
- Web yêu cầu cài Chrome Extension (Ví dụ: ví Metamask cho dự án Blockchain).
Giải pháp:
Fixture mặc định rất khó để load Extension hoặc tinh chỉnh Permission cho từng bài test riêng lẻ.
Với Manual Launch, bạn có toàn quyền kiểm soát tham số khởi động của Chrome (--load-extension, --disable-gpu...).
const context = await browser.newContext({
permissions: ['geolocation'], // Cho phép định vị
geolocation: { latitude: 52.52, longitude: 13.39 }, // Fake vị trí sang Đức
locale: 'de-DE' // Fake ngôn ngữ Đức
});
👉 Khi cần test những thứ chuyên sâu như Fake GPS, Fake ngôn ngữ, hay test ví điện tử... Fixture mặc định sẽ không đủ linh hoạt. Lúc đó phải biết cách tự build trình duyệt.
Đồng ý là Fixture { page } sướng hơn. Nó giống như chế độ Auto (Tự động) trên máy ảnh.
Nhưng nếu muốn trở thành Nhiếp ảnh gia chuyên nghiệp (Senior Automation), phải biết chụp chế độ Manual (Thủ công).
👉 Kết luận:
- Manual Launch: Dành cho việc học hiểu bản chất và viết tool riêng lẻ.
- Config + Fixture: Dành cho việc viết Test Case chuyên nghiệp trong dự án.
🌌 Phần 2: Đa Vũ Trụ - Browser Context (Isolation)
Lý Thuyết: Mô Hình "Khách Sạn Trình Duyệt"
Để hiểu Context, hãy tưởng tượng Trình Duyệt (Browser) của chúng ta là một Tòa Nhà Khách Sạn.
- Browser (chromium): Là Tòa nhà.
- Nó chứa cơ sở hạ tầng chung (Engine, Network process).
- Browser Context: Là các Phòng Ngủ riêng biệt (Room 101, Room 102...).
- Quy tắc vàng: "Chuyện gì xảy ra trong phòng 101, ở lại phòng 101".
- Đồ đạc của khách (Cookies, LocalStorage, Cache) được khóa kín trong phòng.
- Phòng 101 (Admin) có "Thẻ từ" (Cookie) để đi thang máy VIP.
- Phòng 102 (Hacker) không có thẻ từ, ra khỏi phòng là bị bảo vệ chặn.
- Tương đương thực tế: Đây chính là chế độ Ẩn danh (Incognito Mode).
- Page: Là cái Tivi (Tab) trong phòng.
- Một phòng (Context) có thể bật nhiều Tivi (Page/Tab).
Tại sao Fixture { page } lại "Yếu"?
Trước giờ các bạn viết:
test('Login', async ({ page }) => { ... })
Lúc này, Playwright tự động thuê giúp bạn DUY NHẤT 1 PHÒNG.
- Nếu bạn Login Admin -> Cả phòng biến thành Admin.
- Nếu bạn muốn test Hacker? Bạn phải Logout Admin ra -> Phòng trống -> Hacker vào.
- Hệ quả: Bạn không thể test cảnh Admin và Hacker cùng online một lúc.
👉 Giải pháp: Xin Playwright cái { browser } (Tòa nhà), rồi tự tay thuê 2 phòng riêng biệt (newContext).
Thực Hành: Kịch Bản "Kẻ Đột Nhập" (The Intruder)
Kịch bản kiểm thử:
- Vũ trụ 1 (Admin): Đăng nhập vào CRM thành công. Lấy đường link trang Dashboard mật.
- Vũ trụ 2 (Hacker): Không đăng nhập. Cố tình dán đường link mật đó vào trình duyệt.
- Mong đợi:
- Admin: Vẫn lướt Dashboard bình thường.
- Hacker: Bị hệ thống ĐÁ về trang Login (vì không có Cookie của Admin).
import { test, expect } from '@playwright/test';
// ⚠️ CHÚ Ý: Ta dùng fixture '{ browser }' thay vì '{ page }'
test('Đa Vũ Trụ: Admin và Hacker (Browser Context Isolation)', async ({ browser }) => {
// ============================================================
// 🟢 VŨ TRỤ 1: ADMIN (PHÒNG 101)
// ============================================================
console.log('🏗️ [Setup] Khởi tạo môi trường cho Admin...');
// 1. Tạo Context riêng (Có thể setup quay video, khổ màn hình riêng)
const adminContext = await browser.newContext({
viewport: { width: 1920, height: 1080 }, // Màn hình to
recordVideo: { dir: 'videos/admin/' }, // Quay video Admin
});
// 2. Mở Tab làm việc
const adminPage = await adminContext.newPage();
await test.step('Admin đăng nhập và vào vùng an toàn', async () => {
console.log('👤 [Admin] Đang đăng nhập...');
await adminPage.goto('https://crm.anhtester.com/admin/authentication');
await adminPage.fill('input[name="email"]', 'admin@example.com');
await adminPage.fill('input[name="password"]', '123456');
await adminPage.click('button[type="submit"]');
// Đợi vào được Dashboard
await adminPage.waitForURL(/.*admin\//);
console.log('✅ [Admin] Đã vào Dashboard.');
});
// Lấy "Chìa khóa" (URL mật)
const secretDashboardUrl = adminPage.url();
// ============================================================
// 🔴 VŨ TRỤ 2: HACKER (PHÒNG 102 - INCOGNITO)
// ============================================================
console.log('🏗️ [Setup] Khởi tạo môi trường cho Hacker...');
// 1. Tạo Context mới hoàn toàn (SẠCH TRƠN COOKIES)
// 👉 Đây là dòng code quan trọng nhất bài!
const hackerContext = await browser.newContext({
viewport: { width: 375, height: 667 }, // Giả lập Hacker dùng iPhone
recordVideo: { dir: 'videos/hacker/' },
});
const hackerPage = await hackerContext.newPage();
await test.step('Hacker cố tình truy cập link mật', async () => {
console.log(`🕵️ [Hacker] Thử dán link: ${secretDashboardUrl}`);
// Hacker truy cập thẳng vào link Dashboard của Admin
await hackerPage.goto(secretDashboardUrl);
});
// ============================================================
// ⚖️ PHÁN QUYẾT (ASSERTION)
// ============================================================
console.log('⚖️ Kiểm tra kết quả...');
// 1. Kiểm tra Admin: Vẫn phải bình yên vô sự
expect(adminPage.url()).toContain('admin');
// 2. Kiểm tra Hacker: PHẢI BỊ CHẶN
// CRM sẽ redirect Hacker về trang login vì không tìm thấy session cookie
await expect(hackerPage).toHaveURL(/.*authentication/);
// Kiểm tra kỹ hơn: Thấy chữ "Login" trên màn hình Hacker
await expect(hackerPage.locator('button[type="submit"]')).toContainText('Login');
console.log('✅ Kiểm thử thành công: Dữ liệu giữa 2 Context được cách ly tuyệt đối!');
// ============================================================
// 🧹 DỌN DẸP (TEARDOWN)
// ============================================================
// Vì ta tự tạo ("Manual"), ta phải tự đóng.
// Nếu dùng fixture { page } thì Playwright tự đóng giúp.
await adminContext.close();
await hackerContext.close();
});
Giải Thích Chuyên Sâu (Deep Dive)
Tại sao Hacker không vào được?
- Cookie Jar (Hũ bánh quy):
- Khi Admin login, Server trả về một cái bánh quy tên là session_id=XYZ. Admin cất vào túi của adminContext.
- Incognito:
- hackerContext được tạo mới bằng newContext(). Cái túi của Hacker rỗng tuếch.
- Check-in:
- Khi Hacker vào link Dashboard, Server hỏi: "Đưa bánh quy session đây xem nào?".
- Hacker móc túi ra: Không có gì.
- Server: "Biến! Về trang Login ngay."
- Khi Nào Dùng Kỹ Thuật Này?
Bạn bắt buộc phải dùng browser.newContext() trong 3 trường hợp:
- Test Phân Quyền (RBAC): Admin tạo User mới -> User mới đăng nhập kiểm tra quyền hạn (mà không cần logout Admin).
- Test Real-time (Chat/Noti): Admin nhắn tin -> User nhận tin nhắn ngay lập tức.
- Test Concurrency (Đồng thời): Giả lập 10 người mua hàng cùng lúc để xem kho hàng trừ số lượng đúng không.
Kịch bản Thực Tế (Real-world Scenario)
Tình huống: Khách hàng phàn nàn: "Chức năng chat trên hợp đồng bị lỗi. Khách hàng nhắn tin nhưng Admin không thấy (hoặc ngược lại)."
Thách thức: Nếu test thủ công (Manual Test), bạn phải mở 1 trình duyệt Chrome (Admin) và 1 trình duyệt Firefox (Khách) để chat qua lại. Rất mất công. Nếu dùng Automation cũ (Selenium), việc điều khiển 2 trình duyệt cực kỳ phức tạp.
Giải pháp Playwright: Sử dụng Browser Context để tạo ra 2 "Vũ trụ" song song ngay trong 1 bài test:
- Vũ trụ 1: Admin (Đăng nhập, có quyền quản lý).
- Vũ trụ 2: Guest (Ẩn danh, không có quyền).
import { test, expect } from '@playwright/test';
const CONTRACT_URL = 'https://crm.anhtester.com/contract/65/ec79760f1ac5e966a9abee90e07f64de';
// Chúng ta cần fixture 'browser' để tự tạo context
test('Demo thủ công: Admin chat với Guest (Browser Context)', async ({ browser }) => {
// ===============================================
// 🟢 BƯỚC 1: TẠO VŨ TRỤ 1 (ADMIN)
// ===============================================
console.log('👤 [Setup] Tạo Context cho Admin...');
// Tạo một trình duyệt sạch
const adminContext = await browser.newContext();
const adminPage = await adminContext.newPage();
// 👉 Admin phải tự Login (Vì chưa có Fixture Gatekeeper)
await test.step('Admin đăng nhập', async () => {
await adminPage.goto('https://crm.anhtester.com/admin/authentication');
await adminPage.fill('input[name="email"]', 'admin@example.com');
await adminPage.fill('input[name="password"]', '123456');
await adminPage.click('button[type="submit"]');
});
// Admin vào trang Contract và đợi page load xong
await adminPage.goto(CONTRACT_URL);
await adminPage.waitForLoadState('networkidle');
// ===============================================
// ⚪ BƯỚC 2: TẠO VŨ TRỤ 2 (GUEST)
// ===============================================
console.log('🕵️ [Setup] Tạo Context cho Guest (Ẩn danh)...');
// 👉 MAGIC HERE: newContext() tạo ra môi trường hoàn toàn mới
// - Không dính dáng gì đến adminContext ở trên
// - Không có cookies, session của Admin
// - Giống như mở trình duyệt ẩn danh mới
const guestContext = await browser.newContext();
const guestPage = await guestContext.newPage();
// Guest vào thẳng link (Không cần login vì là guest)
await guestPage.goto(CONTRACT_URL);
await guestPage.waitForLoadState('networkidle');
// ===============================================
// ⚡ BƯỚC 3: TƯƠNG TÁC QUA LẠI
// ===============================================
const adminMsg = `Admin (Manual) says: ${Date.now()}`;
const guestMsg = `Guest (Manual) replies: ${Date.now()}`;
await test.step('Admin nhắn tin', async () => {
// ============================================
// BƯỚC 1: MỞ TAB "THẢO LUẬN"
// ============================================
// Trang Contract có 2 tab: "Tóm tắt" và "Thảo luận"
// Form comment nằm trong tab "Thảo luận", nên phải click vào tab này trước
await adminPage.getByText('Thảo luận').click();
// Đợi tab "Thảo luận" active (hiển thị form comment)
await adminPage.waitForSelector('#discussion.tab-pane.active', { state: 'visible' });
// ============================================
// BƯỚC 2: TÌM VÀ ĐỢI TEXTAREA
// ============================================
// Textarea để nhập comment nằm trong tab #discussion
const textarea = adminPage.locator('#discussion textarea[name="content"]');
// Đợi textarea visible (có thể bị ẩn ban đầu)
await textarea.waitFor({ state: 'visible' });
// ============================================
// BƯỚC 3: NHẬP NỘI DUNG COMMENT
// ============================================
await textarea.fill(adminMsg);
// ============================================
// BƯỚC 4: SUBMIT COMMENT
// ============================================
// Click nút "Thêm bình luận" để gửi comment
await adminPage.locator('#discussion button[type="submit"]').click();
// ============================================
// BƯỚC 5: KIỂM TRA COMMENT ĐÃ XUẤT HIỆN
// ============================================
// Comments được hiển thị trong các div.contract_comment
// Mỗi comment có class .media-body chứa nội dung
// .last() lấy comment cuối cùng (comment mới nhất)
await expect(adminPage.locator('.contract_comment .media-body').last()).toContainText(adminMsg, { timeout: 10000 });
});
await test.step('Guest nhận và trả lời', async () => {
// ============================================
// BƯỚC 1: GUEST KIỂM TRA TIN NHẮN CỦA ADMIN
// ============================================
// Guest kiểm tra xem đã nhận được tin nhắn của Admin chưa
// Lưu ý: Dùng guestPage (không phải adminPage) vì Guest đang xem trên guestPage
await expect(guestPage.locator('.contract_comment .media-body').last()).toContainText(adminMsg);
// ============================================
// BƯỚC 2: MỞ TAB "THẢO LUẬN" ĐỂ TRẢ LỜI
// ============================================
// Guest cũng cần click vào tab "Thảo luận" để mở form comment
await guestPage.getByText('Thảo luận').click();
await guestPage.waitForSelector('#discussion.tab-pane.active', { state: 'visible' });
// ============================================
// BƯỚC 3: TÌM VÀ ĐỢI TEXTAREA
// ============================================
const textarea = guestPage.locator('#discussion textarea[name="content"]');
await textarea.waitFor({ state: 'visible' });
// ============================================
// BƯỚC 4: NHẬP NỘI DUNG TRẢ LỜI
// ============================================
await textarea.fill(guestMsg);
// ============================================
// BƯỚC 5: SUBMIT COMMENT
// ============================================
await guestPage.locator('#discussion button[type="submit"]').click();
// ============================================
// BƯỚC 6: KIỂM TRA COMMENT ĐÃ XUẤT HIỆN
// ============================================
// Kiểm tra comment của Guest đã xuất hiện trong danh sách
await expect(guestPage.locator('.contract_comment .media-body').last()).toContainText(guestMsg, { timeout: 10000 });
});
await test.step('Admin kiểm tra lại', async () => {
// ============================================
// BƯỚC 1: RELOAD TRANG ĐỂ THẤY TIN MỚI
// ============================================
// Admin reload trang để cập nhật danh sách comment mới nhất
await adminPage.reload();
await adminPage.waitForLoadState('networkidle');
// ============================================
// BƯỚC 2: MỞ TAB "THẢO LUẬN" NẾU CHƯA ACTIVE
// ============================================
// Sau khi reload, có thể tab "Tóm tắt" đang active, nên cần click lại tab "Thảo luận"
await adminPage.getByText('Thảo luận').click();
await adminPage.waitForSelector('#discussion.tab-pane.active', { state: 'visible' });
// ============================================
// BƯỚC 3: KIỂM TRA COMMENT CỦA GUEST
// ============================================
// Kiểm tra comment cuối cùng (mới nhất) chứa tin nhắn của Guest
await expect(adminPage.locator('.contract_comment .media-body').last()).toContainText(guestMsg);
});
await adminContext.close();
await guestContext.close();
});
🧠 Deep Dive: Giải Mã Chuyên Sâu
Tại sao đoạn code trên lại chạy được?
1. Cơ chế "Bức Tường Lửa" (Isolation Wall)
- Hỏi: Tại sao Guest vào được link mà không tự động đăng nhập vào tài khoản Admin (trong khi Admin đang online)?
- Giải thích:
- Khi Admin đăng nhập, Server cấp một cái "Thẻ từ" (Session Cookie). Admin cất nó vào Túi A (adminContext).
- Khi gọi newContext() cho Guest, Playwright tạo ra một Túi B (guestContext) mới tinh, rỗng tuếch.
- Guest vào trang web -> Trình duyệt chìa Túi B ra -> Server thấy rỗng -> Coi như khách vãng lai.
- Kết luận: Hai context này hoàn toàn mù tịt về nhau.
2. Cơ chế "Đa Nhiệm" (Multitasking)
- Hỏi: Playwright có chạy 2 bài test riêng biệt không?
- Giải thích: Không. Nó chạy 1 luồng (thread) duy nhất nhưng điều khiển 2 cửa sổ.
- Dòng code click(...): Playwright focus vào cửa sổ Admin.
- Dòng code click(...): Playwright chuyển focus sang cửa sổ Guest.
- Tốc độ chuyển đổi tính bằng mili-giây, nhanh hơn người thật gấp trăm lần.
3. Kiểm thử "End-to-End" thực thụ
- Hỏi: Tại sao phải check expect(guestPage...) mà không check luôn trên adminPage cho nhanh?
- Giải thích:
- Nếu check trên adminPage: Chỉ chứng minh được Frontend của Admin hoạt động. (Có thể tin nhắn chưa hề gửi lên Server, chỉ hiện local).
- Khi check trên guestPage: Chứng minh được Toàn bộ hệ thống hoạt động:
- Admin gửi -> API Request -> Server.
- Server lưu Database.
- Server đẩy sự kiện -> Máy Guest.
- Máy Guest hiển thị.
- 👉 Đây mới là bản chất của kiểm thử tích hợp hệ thống.
