Mục lục
- Mục Tiêu Bài Học
- Tại Sao Cần Per-File / Per-Describe Override
- Cú Pháp Per-File
- Cú Pháp Per-Describe
- Multi-Role Trong Cùng Một File
- Guest Pattern — Empty State Object
- Inline Object — Inject State Không Qua File
- Khác Biệt Với Project-Level Config
- Combine Project + test.use
- Per-Test Override — Dùng Manual Context
- 4 Pitfalls Thực Tế
- Tổng Kết
- Quiz Củng Cố
- Bài Tiếp Theo
Mục Tiêu Bài Học
Sau khi hoàn thành bài này, bạn sẽ:
- Dùng
test.use({ storageState })để override auth state cho toàn bộ một file spec. - Dùng
test.use()bên trongtest.describe()để áp dụng state khác nhau cho từng nhóm test. - Viết spec có 3 role (admin / user / guest) trong cùng một file mà không cần project riêng.
- Biết cách reset về guest bằng empty object — và tại sao
undefinedkhông hoạt động. - Phân biệt rõ khi nào dùng project-level config, khi nào dùng
test.use(). - Xử lý trường hợp cần override per-test (không phải per-describe) bằng manual context.
- Tránh 4 pitfall phổ biến khi dùng
test.use({ storageState }).
Bài 101 đã cover multi-role project config với testMatch filter. Bài 102 đã cover cách tạo và quản lý các file storageState per role. Bài này không lặp lại hai nội dung đó — focus vào cơ chế test.use() override ở file/describe level.
Tại Sao Cần Per-File / Per-Describe Override
Project-level config (bài 101) phù hợp khi mỗi role có một tập file test riêng biệt — admin test nằm trong admin.*.spec.ts, user test trong user.*.spec.ts. Nhưng có những tình huống mà cách phân chia theo project không giải quyết gọn:
- Cross-role verification trong cùng file: Cùng một spec muốn verify rằng admin thấy nút "Delete" còn user không thấy — hai describe block, hai role, một file.
- Tránh project explosion: Với ứng dụng có 5-6 role khác nhau, tạo 5-6 project chỉ để phân chia auth sẽ làm
playwright.config.tscồng kềnh và khó bảo trì. - Một vài file ngoại lệ: Phần lớn test dùng role
user(cấu hình ở project level), nhưng vài file cần chạy nhưadmin— không đáng tạo thêm project chỉ cho vài file đó.
test.use({ storageState }) giải quyết cả ba trường hợp trên mà không cần sửa playwright.config.ts.
Precedence rule
Override theo layer, layer càng gần test body càng thắng:
Global use.storageState (defineConfig)
↓ bị ghi đè bởi
Project use.storageState (projects[i].use)
↓ bị ghi đè bởi
File-level test.use({ storageState })
↓ bị ghi đè bởi
Describe-level test.use({ storageState })
Describe-level thắng tất cả. File-level thắng project và global. Không có "per-test" trong fixture pipeline — sẽ giải thích ở mục 10.
Cú Pháp Per-File
Đặt test.use() ở file-scope — ngoài mọi test() và describe(). Toàn bộ test trong file sẽ dùng storageState được chỉ định:
// admin-users.spec.ts
import { test, expect } from '@playwright/test';
// File-scope: mọi test trong file này dùng admin.json
test.use({ storageState: 'playwright/.auth/admin.json' });
test('admin can delete user', async ({ page }) => {
await page.goto('/admin/users');
await page.getByRole('button', { name: 'Delete' }).first().click();
await expect(page.getByText('User deleted')).toBeVisible();
});
test('admin can view audit log', async ({ page }) => {
await page.goto('/admin/audit-log');
await expect(page.getByRole('table')).toBeVisible();
});
Nếu project config đang set storageState: 'playwright/.auth/user.json', file-level call này ghi đè hoàn toàn — hai test trên sẽ chạy với context của admin, không phải user.
Path được resolve từ thư mục chứa playwright.config.ts, không phải từ vị trí file spec. Dùng path tương đối từ root project là an toàn nhất.
Cú Pháp Per-Describe
Đặt test.use() bên trong test.describe(), trước các test(). Override chỉ áp dụng cho các test trong describe block đó:
// dashboard-access.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Admin features', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });
test('manage users', async ({ page }) => {
await page.goto('/admin/users');
await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible();
});
test('view analytics', async ({ page }) => {
await page.goto('/admin/analytics');
await expect(page.getByRole('table')).toBeVisible();
});
});
test.describe('User features', () => {
test.use({ storageState: 'playwright/.auth/user.json' });
test('edit profile', async ({ page }) => {
await page.goto('/profile');
await expect(page.getByRole('button', { name: 'Edit' })).toBeVisible();
});
});
Hai describe block trên cùng một file — mỗi block chạy với context của role tương ứng. Không có sự rò rỉ state giữa chúng: mỗi test nhận một context mới được khởi tạo với storageState của describe block chứa nó.
Multi-Role Trong Cùng Một File
Pattern 3 role trong cùng file — hữu ích khi muốn verify access control của một feature từ nhiều góc nhìn trong cùng spec:
// delete-post.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Admin', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });
test('can delete any post', async ({ page }) => {
await page.goto('/posts/123');
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
});
});
test.describe('User', () => {
test.use({ storageState: 'playwright/.auth/user.json' });
test('can only delete own post', async ({ page }) => {
await page.goto('/posts/123'); // post của người khác
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeVisible();
await page.goto('/posts/456'); // post của chính mình
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
});
});
test.describe('Guest', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('is redirected to login', async ({ page }) => {
await page.goto('/posts/123');
await expect(page).toHaveURL('/login');
});
});
Mỗi describe block resolve storageState độc lập tại thời điểm fixture được tạo — không có thứ tự phụ thuộc giữa ba block. Playwright có thể chạy ba test song song trên ba worker khác nhau nếu workers > 1.
Use case phù hợp
- Access control spec: verify một endpoint hoặc UI element theo từng role.
- Permission boundary test: confirm rằng user không thể làm gì admin có thể làm và ngược lại.
- Regression test cho authorization bug: giữ test của nhiều role trong cùng file để dễ review diff khi có thay đổi permission logic.
Guest Pattern — Empty State Object
Khi project config đã set storageState (ví dụ user.json), một file test cần chạy như guest (không có auth) phải dùng empty object — không phải undefined:
// guest-flows.spec.ts
import { test, expect } from '@playwright/test';
// Reset về trạng thái không có auth — không cookies, không localStorage
test.use({ storageState: { cookies: [], origins: [] } });
test('guest signup flow', async ({ page }) => {
await page.goto('/signup');
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
// Context bắt đầu hoàn toàn sạch
});
test('guest cannot access dashboard', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
Tại sao không dùng undefined
// SAI — undefined không ghi đè layer trên
test.use({ storageState: undefined });
// Nếu project config set user.json, test vẫn load user.json
// ĐÚNG — empty object là giá trị hợp lệ, ghi đè rõ ràng
test.use({ storageState: { cookies: [], origins: [] } });
storageState: undefined nghĩa là "không cung cấp giá trị" — fixture pipeline sẽ fallback lên layer trên (project config). { cookies: [], origins: [] } là giá trị hợp lệ được truyền vào browser.newContext(), tạo context với state hoàn toàn rỗng.
Pattern này cũng dùng được cho per-describe:
test.describe('Public pages', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('login page accessible', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
test('register page accessible', async ({ page }) => {
await page.goto('/register');
await expect(page.getByRole('form')).toBeVisible();
});
});
Inline Object — Inject State Không Qua File
Ngoài path file JSON, storageState nhận object inline đầy đủ. Dùng khi cần inject state nhỏ trực tiếp vào spec mà không tạo file:
test.use({
storageState: {
cookies: [
{
name: 'session',
value: 'tok_abc123',
domain: '.app.com',
path: '/',
expires: -1, // session cookie — không expire
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
],
origins: [],
},
});
test('session cookie sets authenticated state', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
Dùng inline object hợp lý khi:
- State đơn giản, chỉ cần vài cookie.
- Không cần chia sẻ state giữa nhiều file — không muốn tạo thêm file
.json. - Test state cụ thể khó tái tạo qua login UI (ví dụ: cookie với giá trị cụ thể để test edge case).
Khi state lớn hoặc nhiều file cùng dùng, dùng file JSON (bài 102) để tránh lặp.
Khác Biệt Với Project-Level Config
Hai approach giải quyết bài toán khác nhau — không phải thay thế nhau:
Project-level config (bài 101)
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'admin',
use: { storageState: 'playwright/.auth/admin.json' },
testMatch: /admin\..*\.spec\.ts/,
},
{
name: 'user',
use: { storageState: 'playwright/.auth/user.json' },
testMatch: /user\..*\.spec\.ts/,
},
],
});
Mỗi file test thuộc về một project — không có cross-role trong cùng file. Report tách rõ theo project name. Phù hợp khi số lượng file lớn và test không cần mix role.
test.use per file/describe
// roles.spec.ts — không cần project riêng
test.describe('Admin', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });
// ...
});
test.describe('User', () => {
test.use({ storageState: 'playwright/.auth/user.json' });
// ...
});
Mix nhiều role trong 1 file, không cần testMatch, không cần project riêng. Phù hợp khi số file ít hoặc cần cross-role test trong cùng spec.
Khi nào chọn cái nào
| Tình huống | Dùng |
|---|---|
| Nhiều file test, mỗi file thuộc một role cố định | Project-level config |
| Cần chạy trên nhiều browser × nhiều role | Project-level config (matrix) |
| Mix nhiều role trong 1 spec file | test.use per-describe |
| Vài file ngoại lệ cần role khác project default | test.use per-file |
| Guest / unauthenticated flow trong suite có auth | test.use + empty object |
Combine Project + test.use
Hai cơ chế có thể dùng cùng nhau — project config đặt default, test.use() override ngoại lệ. Pattern phổ biến:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'e2e',
use: { storageState: 'playwright/.auth/user.json' }, // default: user role
},
],
});
// Phần lớn file test không khai báo gì — dùng user.json từ project config
// user-profile.spec.ts
test('user can update bio', async ({ page }) => {
await page.goto('/profile');
// context đã load user.json
});
// File ngoại lệ: cần admin role
// admin-settings.spec.ts
import { test, expect } from '@playwright/test';
test.use({ storageState: 'playwright/.auth/admin.json' }); // override
test('admin can manage roles', async ({ page }) => {
await page.goto('/admin/roles');
await expect(page.getByRole('table')).toBeVisible();
});
// File khác: cross-role check trong 1 spec
// permission-check.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Admin can', () => {
test.use({ storageState: 'playwright/.auth/admin.json' }); // override local
test('access billing page', async ({ page }) => {
await page.goto('/admin/billing');
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
});
});
test.describe('User cannot', () => {
// Không khai báo test.use — dùng user.json từ project config
test('access billing page', async ({ page }) => {
await page.goto('/admin/billing');
await expect(page).toHaveURL('/403');
});
});
Describe block không có test.use() sẽ kế thừa storageState từ file-level (nếu có) hoặc project config. Không cần khai báo lại nếu muốn giữ nguyên default.
Per-Test Override — Dùng Manual Context
test.use() chỉ hoạt động ở file-scope hoặc describe-scope — không phải bên trong test body. Khi cần từng test riêng lẻ dùng storageState khác nhau trong cùng một describe, không có cách dùng fixture pipeline trực tiếp — phải tạo context thủ công:
// Muốn per-test → tách thành describe riêng (cách sạch nhất)
test.describe('As admin', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });
test('case A', async ({ page }) => { /* ... */ });
});
test.describe('As user', () => {
test.use({ storageState: 'playwright/.auth/user.json' });
test('case B', async ({ page }) => { /* ... */ });
});
Khi cần 2 role tương tác trong cùng một test — ví dụ: admin gửi invitation, user nhận — dùng browser fixture để tạo context thủ công:
test('admin invites user — user receives notification', async ({ browser }) => {
// Tạo 2 context với state khác nhau
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
// Admin gửi invitation
await adminPage.goto('/admin/invite');
await adminPage.getByLabel('Email').fill('[email protected]');
await adminPage.getByRole('button', { name: 'Send Invite' }).click();
await expect(adminPage.getByText('Invitation sent')).toBeVisible();
// User kiểm tra notification
await userPage.goto('/notifications');
await expect(userPage.getByText('You have a new invitation')).toBeVisible();
// Cleanup — bắt buộc khi tạo context thủ công
await adminContext.close();
await userContext.close();
});
Context tạo thủ công cần close() tường minh — không có fixture cleanup tự động. Nên đặt close() trong try/finally hoặc dùng test.afterEach nếu pattern lặp lại nhiều.
4 Pitfalls Thực Tế
1. test.use() bên trong test body — bị bỏ qua hoàn toàn
// SAI — không có tác dụng gì
test('admin feature', async ({ page }) => {
test.use({ storageState: 'playwright/.auth/admin.json' }); // bị ignore
await page.goto('/admin');
// context vẫn dùng storageState từ config cũ
// test có thể pass hoặc fail vì lý do sai
});
Option Fixture được resolve trước khi test body chạy — gọi test.use() trong body là quá muộn. Playwright không throw error, chỉ âm thầm bỏ qua. Đây là pitfall nguy hiểm vì test có thể cho kết quả không đáng tin cậy.
2. File path storageState không tồn tại — test fail với ENOENT
// SAI nếu file chưa được tạo (fresh CI checkout, chưa chạy setup)
test.use({ storageState: 'playwright/.auth/admin.json' });
// Error: ENOENT: no such file or directory, open 'playwright/.auth/admin.json'
File storageState cần tồn tại khi fixture được khởi tạo. Trên CI, dùng setup project (bài 102) để sinh file trước. Nếu muốn skip graceful khi file chưa có, kiểm tra existsSync trước khi truyền path vào test.use() — hoặc dùng object inline thay vì path.
3. Nhầm test.use() với manual context — leak context
// Lỗi: tạo context thủ công nhưng quên close()
test('cross-role test', async ({ browser }) => {
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const adminPage = await adminContext.newPage();
// ... test logic ...
// Quên: await adminContext.close();
});
// Context leak — worker giữ context mở, có thể gây race condition với test tiếp theo
Context tạo qua browser.newContext() thủ công không được fixture system quản lý — phải close() tường minh. Dùng try/finally để đảm bảo cleanup kể cả khi test fail.
4. Quên empty object cho guest trong suite có auth
// playwright.config.ts
use: { storageState: 'playwright/.auth/user.json' }
// signup.spec.ts — dev muốn test flow đăng ký của guest
// SAI: không khai báo gì → kế thừa user.json từ project config
test('signup form', async ({ page }) => {
await page.goto('/signup');
// page.goto('/signup') redirect về /dashboard vì đã login!
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
// FAIL — đang ở /dashboard, không phải /signup
});
// ĐÚNG:
test.use({ storageState: { cookies: [], origins: [] } });
test('signup form', async ({ page }) => {
await page.goto('/signup');
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
});
Tổng Kết
test.use({ storageState })đặt ở file-scope override toàn bộ file — không cần project riêng.- Đặt bên trong
test.describe()chỉ áp dụng cho tests trong describe block đó — các block khác không bị ảnh hưởng. - Precedence: describe-level > file-level > project-level > global.
- 3 role trong 1 file: mỗi describe block một
test.use()khác nhau — Playwright tạo context độc lập cho mỗi test. - Reset về guest: dùng
{ cookies: [], origins: [] }— không phảiundefined. - Project config phù hợp cho nhiều file cùng role;
test.use()phù hợp cho ngoại lệ và cross-role spec. - Per-test override không có trong fixture pipeline — dùng
browser.newContext()thủ công và nhớclose(). - 4 pitfall:
test.use()trong test body (bị bỏ qua), file path không tồn tại (ENOENT), context leak khi manual, quên empty object cho guest.
Quiz Củng Cố
Câu 1
File spec dưới đây có vấn đề gì? Test sẽ chạy với storageState nào?
// project config: use: { storageState: 'playwright/.auth/user.json' }
test('check admin panel', async ({ page }) => {
test.use({ storageState: 'playwright/.auth/admin.json' });
await page.goto('/admin');
await expect(page.getByRole('heading', { name: 'Admin' })).toBeVisible();
});
Đáp án
test.use() bên trong test body bị bỏ qua hoàn toàn — Playwright không throw error mà âm thầm không apply. Test sẽ chạy với user.json từ project config. Nếu /admin yêu cầu quyền admin, test fail với redirect hoặc 403 — không phải vì code sai, mà vì storageState sai role. Fix: chuyển test.use() ra ngoài test body (file-scope hoặc describe-scope).
Câu 2
Spec file có 3 describe block. Describe đầu và cuối có test.use() riêng. Describe giữa không khai báo gì. Project config đặt storageState: 'playwright/.auth/user.json'. Describe giữa sẽ dùng storageState nào?
test.describe('Admin', () => {
test.use({ storageState: 'playwright/.auth/admin.json' });
test('A', async ({ page }) => { /* ... */ });
});
test.describe('Middle', () => {
// Không có test.use()
test('B', async ({ page }) => { /* ... */ });
});
test.describe('Guest', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('C', async ({ page }) => { /* ... */ });
});
Đáp án
Describe "Middle" không có test.use() riêng — kế thừa từ layer cao hơn. Không có file-level test.use() trong ví dụ này, nên fallback về project config: user.json. Test B chạy với context của user. Describe block không "lây" storageState sang nhau — Admin và Guest là độc lập, không ảnh hưởng Middle.
Câu 3
Tại sao đoạn code sau không reset context về guest dù dev có ý định đó?
// project: use: { storageState: 'playwright/.auth/user.json' }
test.use({ storageState: undefined });
test('guest sees login button', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();
// FAIL — user đã login, không thấy Login link
});
Đáp án
storageState: undefined không phải giá trị override — nó chỉ nói "không có giá trị ở layer này", khiến fixture pipeline fallback lên project config (user.json). Context vẫn được tạo với state của user. Để reset thực sự, phải truyền giá trị hợp lệ: test.use({ storageState: { cookies: [], origins: [] } }) — empty object là giá trị hợp lệ được truyền vào browser.newContext(), tạo context không có state gì.
Câu 4
Khi nào nên dùng project-level config thay vì test.use() per-describe cho multi-role testing?
Đáp án
Project-level config phù hợp hơn khi: (1) Có nhiều file test và mỗi file thuộc về một role cố định — không cần mix role trong cùng file. (2) Cần chạy test trên browser × role matrix (ví dụ: admin trên Chrome + Firefox, user trên Chrome + WebKit). (3) Muốn report tách rõ theo project name để dễ xác định role nào fail. test.use() per-describe phù hợp hơn khi cần mix nhiều role trong 1 spec (cross-role verification) hoặc khi chỉ có vài file ngoại lệ cần role khác default.
Câu 5
Test dưới đây tạo manual context nhưng có vấn đề. Xác định vấn đề và cách fix:
test('admin approves, user receives', async ({ browser }) => {
const adminCtx = await browser.newContext({
storageState: 'playwright/.auth/admin.json',
});
const userCtx = await browser.newContext({
storageState: 'playwright/.auth/user.json',
});
const adminPage = await adminCtx.newPage();
const userPage = await userCtx.newPage();
await adminPage.goto('/admin/approvals');
await adminPage.getByRole('button', { name: 'Approve' }).first().click();
await expect(adminPage.getByText('Approved')).toBeVisible();
await userPage.goto('/notifications');
await expect(userPage.getByText('Your request was approved')).toBeVisible();
});
Đáp án
Vấn đề: không có close() cho adminCtx và userCtx. Vì context được tạo thủ công qua browser.newContext(), không nằm trong fixture system — Playwright không tự cleanup. Nếu test fail ở giữa, context vẫn bị leak, giữ tài nguyên browser cho đến khi worker kết thúc, có thể ảnh hưởng test tiếp theo. Fix: bọc phần body trong try/finally hoặc thêm await adminCtx.close(); await userCtx.close(); ở cuối — kể cả khi dùng try/finally để đảm bảo cleanup khi test throw.
Bài Tiếp Theo
Bài 104 cover per-worker auth pattern — cách dùng worker.storageState và workerStorageState fixture để mỗi worker có một auth identity riêng, giảm overhead login trong parallel test run.
