Mục lục
- Mục Tiêu Bài Học
- Programmatic Generation Là Gì Và Khi Nào Dùng
- Pattern Cơ Bản — flatMap Hai Dimension
- Matrix 3 Dimension: Browser × Role × Env
- Combine Với Options Fixture
- Naming Convention Wildcard-Friendly
- Filter Subset Bằng --project Wildcard
- Conditional Matrix — CI vs Local
- Dependency Setup Per Project (Auth Per Role)
- Trade-off Khi Scale Matrix
- Chiến Lược CI Theo Tầng
- 4 Pitfall Hay Gặp
- Quiz
Mục Tiêu Bài Học
- Hiểu sự khác biệt giữa khai báo project tay và programmatic generation.
- Viết pattern
flatMaplồng nhau để tạo toàn bộ project array từ N×M×K dimension. - Combine programmatic matrix với options fixture để test đọc được role và env từ project config.
- Áp dụng naming convention
{browser}-{role}-{env}để filter wildcard hoạt động đúng. - Cấu hình conditional matrix: full matrix trên CI nightly, subset trên PR.
- Xử lý dependency setup riêng per-project khi mỗi role cần auth state khác nhau.
- Đánh giá trade-off khi matrix phình to và tránh 4 pitfall phổ biến.
Programmatic Generation Là Gì Và Khi Nào Dùng
Khai báo project tay phù hợp khi matrix nhỏ — dưới 6-8 project và mỗi project có customization riêng biệt. Khi thêm browser mới hay env mới, phải copy-paste từng entry và chỉnh sửa thủ công, dễ sót hoặc nhầm.
Programmatic generation giải quyết bài toán này: thay vì liệt kê từng project, bạn khai báo các dimension (browser, role, env) rồi dùng code để tạo ra toàn bộ combination. Thêm một browser hay một env chỉ cần thêm 1 giá trị vào array — code tự sinh ra đầy đủ project tương ứng.
Khi nào dùng programmatic generation
- Matrix từ 8 project trở lên.
- Các project có cùng structure — chỉ khác nhau ở dimension value.
- Cần thêm/bỏ dimension thường xuyên (ví dụ thêm env mới khi deploy thêm region).
- Muốn đảm bảo naming convention nhất quán — tên được sinh từ template, không phụ thuộc người viết.
Khác biệt so với forEach (bài 90)
forEach (bài 90) parametrize data trong test file — data nằm gần test, mỗi data row sinh một test case riêng trong cùng project. Programmatic matrix parametrize ở config level — mỗi combination dimension trở thành một project riêng, cùng bộ test chạy lặp lại với ngữ cảnh khác nhau.
| Kỹ thuật | Nơi khai báo | Đơn vị lặp | Dùng khi |
|---|---|---|---|
forEach data-driven |
Test file | Test case | Cùng logic, nhiều input data |
| Options fixture đơn (bài 91) | Project config | Project | 1 dimension, vài giá trị |
| Programmatic matrix (bài này) | Project config | Project (N×M×K) | Multi-dimension, nhiều combination |
Pattern Cơ Bản — flatMap Hai Dimension
Ví dụ đơn giản nhất: 2 browsers × 2 envs = 4 project.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const browsers = ['Desktop Chrome', 'Desktop Firefox'] as const;
const envs = {
staging: 'https://staging.app.com',
prod: 'https://app.com',
} as const;
export default defineConfig({
projects: browsers.flatMap(browser =>
Object.entries(envs).map(([envName, baseURL]) => ({
name: `${browser.replace(/\s/g, '-').toLowerCase()}-${envName}`,
use: {
...devices[browser],
baseURL,
},
}))
),
});
Output — 4 project được sinh tự động:
desktop-chrome-staging
desktop-chrome-prod
desktop-firefox-staging
desktop-firefox-prod
Cơ chế hoạt động của flatMap: với mỗi browser, map tạo ra một mảng project (1 per env). flatMap flatten kết quả thành mảng phẳng. Nếu dùng map thuần, kết quả sẽ là mảng lồng nhau — projects cần mảng phẳng nên phải flatMap.
Thêm browser mới chỉ cần thêm vào array browsers:
const browsers = ['Desktop Chrome', 'Desktop Firefox', 'Desktop Safari'] as const;
// → Tự sinh thêm desktop-safari-staging và desktop-safari-prod
Matrix 3 Dimension: Browser × Role × Env
Khi cần thêm dimension role (admin vs user), lồng thêm một flatMap:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const browsers = ['Desktop Chrome', 'Desktop Firefox'] as const;
const roles = ['admin', 'user'] as const;
const envs = {
staging: 'https://staging.app.com',
prod: 'https://app.com',
} as const;
export default defineConfig({
projects: browsers.flatMap(browser =>
roles.flatMap(role =>
Object.entries(envs).map(([envName, baseURL]) => ({
name: `${browser.replace(/\s/g, '-').toLowerCase()}-${role}-${envName}`,
use: {
...devices[browser],
baseURL,
userRole: role, // options fixture — bài 91
},
}))
)
),
});
2 browsers × 2 roles × 2 envs = 8 project:
desktop-chrome-admin-staging
desktop-chrome-admin-prod
desktop-chrome-user-staging
desktop-chrome-user-prod
desktop-firefox-admin-staging
desktop-firefox-admin-prod
desktop-firefox-user-staging
desktop-firefox-user-prod
Thứ tự flatMap ảnh hưởng đến thứ tự project trong danh sách — không ảnh hưởng behavior. Convention: dimension tổng quát nhất ở ngoài cùng (browser), dimension cụ thể nhất ở trong cùng (env). Thứ tự này cũng khớp với naming convention {browser}-{role}-{env}.
Type safety
as const trên array giúp TypeScript suy ra tuple type hẹp, tránh inference quá rộng về string. Khi devices[browser] cần key khớp với preset name, as const đảm bảo TypeScript báo lỗi nếu bạn gõ sai tên preset.
// Với as const: TypeScript biết browsers là readonly tuple
// ['Desktop Chrome', 'Desktop Firefox']
// Không cần assertion thêm khi truyền vào devices[browser]
// Không có as const: browsers suy ra là string[]
// devices[browser] sẽ báo lỗi vì string không là valid key của devices
Combine Với Options Fixture
Truyền userRole vào use: chỉ có tác dụng khi userRole được khai báo là options fixture trong test.extend(). Nếu không, Playwright bỏ qua key không nhận ra.
Khai báo options fixture
// fixtures.ts
import { test as base } from '@playwright/test';
type UserRole = 'admin' | 'user';
export type MyOptions = {
userRole: UserRole;
};
export const test = base.extend<MyOptions>({
userRole: ['user', { option: true }],
// default = 'user'; override được từ use: { userRole: 'admin' }
});
Test đọc userRole để branch logic
// dashboard.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('sidebar menu items', async ({ page, userRole }) => {
// Login đã có qua storageState per project (xem bài 9)
await page.goto('/dashboard');
if (userRole === 'admin') {
// Admin thấy menu quản lý user
await expect(page.getByRole('link', { name: 'User Management' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Audit Log' })).toBeVisible();
} else {
// User thường không thấy menu admin
await expect(page.getByRole('link', { name: 'User Management' })).not.toBeVisible();
}
// Cả hai role đều thấy menu chung
await expect(page.getByRole('link', { name: 'Profile' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible();
});
test('export button visibility', async ({ page, userRole }) => {
await page.goto('/reports');
const exportBtn = page.getByRole('button', { name: 'Export CSV' });
if (userRole === 'admin') {
await expect(exportBtn).toBeEnabled();
} else {
await expect(exportBtn).toBeDisabled();
}
});
Với 8 project đã sinh ở bài trước, mỗi test chạy 8 lần — mỗi lần với browser, role và env khác nhau. Không cần viết test riêng cho admin hay user. Logic branch trong test dựa trên fixture value Playwright inject.
Use case điển hình của role dimension
- RBAC validation: đảm bảo admin thấy feature mà user thường không thấy, và ngược lại.
- Permission-based navigation: test redirect khi user không có quyền truy cập một route.
- Data isolation: admin thấy tất cả record; user thường chỉ thấy record của mình.
- Cross-browser × multi-role: đảm bảo permission UI không vỡ trên Firefox hay WebKit.
Naming Convention Wildcard-Friendly
Tên project được sinh từ template quyết định wildcard filter có hoạt động không. Một tên sai format làm cho --project="*-staging" bỏ sót project đó.
Convention chuẩn
- Format:
{browser}-{role}-{env} - Lowercase toàn bộ — không có uppercase hay space.
- Hyphen (
-) làm separator — không dùng underscore hay slash. - Thứ tự dimension ổn định, không xáo trộn.
Cách sinh tên đúng từ preset browser
Preset name trong devices chứa space và capital: 'Desktop Chrome', 'Desktop Firefox'. Cần normalize trước khi dùng làm tên project:
// Normalize browser name: 'Desktop Chrome' → 'desktop-chrome'
const browserSlug = (name: string) =>
name.replace(/\s+/g, '-').toLowerCase();
// Ví dụ
browserSlug('Desktop Chrome') // → 'desktop-chrome'
browserSlug('Desktop Firefox') // → 'desktop-firefox'
browserSlug('Desktop Safari') // → 'desktop-safari'
browserSlug('iPhone 15 Pro') // → 'iphone-15-pro'
// Dùng trong generation
name: `${browserSlug(browser)}-${role}-${envName}`,
Ví dụ tên đúng và sai
-- ĐÚNG (wildcard filter hoạt động) --
desktop-chrome-admin-staging
desktop-chrome-user-staging
desktop-firefox-admin-prod
-- SAI (wildcard sẽ sót) --
Desktop Chrome Admin Staging ← space + capitalize
desktop_chrome_admin_staging ← underscore
desktopChromeAdminStaging ← camelCase
desktop-chrome-ADMIN-staging ← uppercase
Với programmatic generation, naming inconsistency gần như không xảy ra vì tất cả tên đều sinh từ cùng một template function. Rủi ro chỉ xuất hiện khi thêm project tay vào cùng config với các project generated.
Filter Subset Bằng --project Wildcard
Với naming convention chuẩn, --project wildcard cho phép chọn bất kỳ subset nào của matrix mà không cần sửa config:
# Chỉ chạy project staging (cả 4 project staging)
npx playwright test --project="*-staging"
# Chỉ chạy project admin
npx playwright test --project="*-admin-*"
# Chỉ chạy Chrome, mọi role, mọi env
npx playwright test --project="desktop-chrome-*"
# Admin trên staging (debug specific combination)
npx playwright test --project="desktop-chrome-admin-staging"
# Kết hợp: 2 browser × admin × staging
npx playwright test \
--project="desktop-chrome-admin-staging" \
--project="desktop-firefox-admin-staging"
Wildcard * là glob-style, không phải regex. Pattern *-admin-* khớp bất kỳ chuỗi nào có -admin- ở giữa. Pattern *-staging khớp chuỗi kết thúc bằng -staging.
Lưu ý: --project filter theo tên project, --grep filter theo tên test/tag. Hai flag độc lập — có thể dùng cùng lúc:
# Test @smoke, chỉ trên admin projects staging
npx playwright test --project="*-admin-staging" --grep "@smoke"
Conditional Matrix — CI vs Local
Chạy full matrix khi debug local tốn thời gian không cần thiết. Dùng env var để thu hẹp matrix khi làm việc local:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const isFullMatrix = process.env.FULL_MATRIX === 'true';
// Local: chỉ Chrome để phát triển nhanh
// CI full matrix: cả 3 browser
const browsers = isFullMatrix
? (['Desktop Chrome', 'Desktop Firefox', 'Desktop Safari'] as const)
: (['Desktop Chrome'] as const);
const roles = ['admin', 'user'] as const;
const envs = {
staging: 'https://staging.app.com',
prod: 'https://app.com',
} as const;
const browserSlug = (name: string) => name.replace(/\s+/g, '-').toLowerCase();
export default defineConfig({
projects: browsers.flatMap(browser =>
roles.flatMap(role =>
Object.entries(envs).map(([envName, baseURL]) => ({
name: `${browserSlug(browser)}-${role}-${envName}`,
use: {
...devices[browser],
baseURL,
userRole: role,
},
}))
)
),
});
Khi chạy local:
# 1 browser × 2 roles × 2 envs = 4 project (nhanh)
npx playwright test
# Bật full matrix khi cần verify cross-browser
FULL_MATRIX=true npx playwright test
CI nightly bật full matrix qua env var:
jobs:
e2e-nightly:
if: github.event_name == 'schedule'
env:
FULL_MATRIX: 'true'
steps:
- run: npx playwright test
e2e-pr:
if: github.event_name == 'pull_request'
# FULL_MATRIX không set → chỉ Chrome (mặc định)
steps:
- run: npx playwright test --project="desktop-chrome-*-staging" --grep "@smoke"
Pattern này giữ một file config duy nhất — không phải sync hai file riêng cho PR và nightly. Khi thêm dimension mới, sửa ở một chỗ.
Dependency Setup Per Project (Auth Per Role)
Khi role dimension có mặt, mỗi project cần auth state riêng — admin login token khác với user login token. Dùng dependencies array để khai báo project setup chạy trước:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const roles = ['admin', 'user'] as const;
const envs = {
staging: 'https://staging.app.com',
prod: 'https://app.com',
} as const;
const browserSlug = (name: string) => name.replace(/\s+/g, '-').toLowerCase();
const browsers = ['Desktop Chrome', 'Desktop Firefox'] as const;
// Setup projects — chạy trước, sinh storageState file
const setupProjects = roles.flatMap(role =>
Object.entries(envs).map(([envName, baseURL]) => ({
name: `setup-${role}-${envName}`,
use: { baseURL },
testMatch: `**/setup/${role}.setup.ts`,
}))
);
// Test projects — phụ thuộc setup tương ứng
const testProjects = browsers.flatMap(browser =>
roles.flatMap(role =>
Object.entries(envs).map(([envName, baseURL]) => ({
name: `${browserSlug(browser)}-${role}-${envName}`,
use: {
...devices[browser],
baseURL,
userRole: role,
storageState: `playwright/.auth/${role}-${envName}.json`,
},
dependencies: [`setup-${role}-${envName}`],
}))
)
);
export default defineConfig({
projects: [...setupProjects, ...testProjects],
});
File setup tương ứng:
// tests/setup/admin.setup.ts
import { test as setup } from '@playwright/test';
import path from 'path';
// baseURL được inject từ project use.baseURL
setup('authenticate as admin', async ({ page, baseURL }) => {
await page.goto(`${baseURL}/login`);
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', process.env.ADMIN_PASS!);
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard');
// Lưu storageState — tên file phân biệt theo env
const envName = baseURL?.includes('staging') ? 'staging' : 'prod';
await page.context().storageState({
path: `playwright/.auth/admin-${envName}.json`,
});
});
// tests/setup/user.setup.ts — tương tự với user credentials
Playwright chạy setup-admin-staging và setup-user-staging trước khi chạy bất kỳ test project nào phụ thuộc vào chúng. Setup chỉ chạy một lần per project, không phải per test — nhờ testMatch giới hạn setup file riêng.
Số lượng setup project = số roles × số envs (không nhân browser vì auth state không phụ thuộc browser engine). Test project mới là browser × role × env và nhận storageState từ file đã sinh bởi setup tương ứng.
Trade-off Khi Scale Matrix
Số test instances tăng nhân với mỗi dimension thêm vào. Với 100 test:
| Matrix | Projects | Instances (100 test) | Ghi chú |
|---|---|---|---|
| 2×2×2 | 8 | 800 | Vừa phải |
| 3×2×2 | 12 | 1200 | Thêm Safari |
| 3×3×2 | 18 | 1800 | Thêm role viewer |
| 3×3×3 | 27 | 2700 | Thêm env prod-eu |
| 5×3×3 | 45 | 4500 | Thêm mobile devices |
Ví dụ thực tế với CI 4 worker và mỗi test trung bình 8 giây: 2700 instances mất khoảng 90 phút. 4500 instances mất 150 phút. Nếu chạy nightly và chấp nhận 2-3 tiếng thì ổn — nếu chạy trên PR thì không thực tế.
Không phải mọi test cần đầy đủ matrix. Test kiểm tra quyền (RBAC) cần role dimension nhưng thường không cần cross-browser. Test kiểm tra responsive layout cần device/browser dimension nhưng thường không cần multi-role. Suy nghĩ về matrix trên từng nhóm test, không phải áp dụng full matrix cho toàn bộ suite.
Cách giảm tải mà không mất coverage
- Gắn tag
@cross-browsercho test thực sự cần nhiều browser — chỉ những test đó mới chạy full browser dimension. - Tạo project
role-onlychỉ gồm Chrome × role × env để test RBAC — không cần nhân với Firefox và Safari. - Dùng conditional matrix để smoke PR dùng tập nhỏ nhất, full matrix chỉ nightly.
Chiến Lược CI Theo Tầng
Cấu trúc CI theo 3 tầng giúp giữ PR feedback nhanh trong khi vẫn có full coverage định kỳ:
Tầng 1 — Smoke (mọi PR)
1 combination: Chrome × admin × staging. Mục tiêu: xác nhận app không bị break sau mỗi commit. Nên hoàn thành dưới 5 phút.
npx playwright test \
--project="desktop-chrome-admin-staging" \
--grep "@smoke"
Tầng 2 — Critical path (mỗi merge vào main)
Subset matrix: Chrome × all roles × staging. Đảm bảo RBAC critical path không bị regression sau merge. Chấp nhận 10-20 phút.
npx playwright test \
--project="desktop-chrome-*-staging" \
--grep "@critical"
Tầng 3 — Full matrix (nightly)
Toàn bộ matrix — mọi browser, mọi role, mọi env. Phát hiện cross-browser regression và cross-env inconsistency. Chạy lúc 2AM, kết quả report sáng hôm sau.
FULL_MATRIX=true npx playwright test
Khi full matrix fail, debug bắt đầu bằng cách thu hẹp về combination cụ thể fail — đây là lúc wildcard filter phát huy giá trị.
4 Pitfall Hay Gặp
Pitfall 1 — Combinatorial explosion
Thêm dimension vô tội vạ mà không tính toán tổng. 5 browsers × 5 roles × 5 envs = 125 project, 100 test → 12500 instances. CI nightly mất 8-10 tiếng — team bắt đầu bỏ qua kết quả.
// Trước khi thêm dimension, tính toán:
const totalProjects = browsers.length * roles.length * Object.keys(envs).length;
console.log(`Matrix: ${browsers.length}×${roles.length}×${Object.keys(envs).length} = ${totalProjects} projects`);
// → Nhìn thấy con số trước khi commit
Không phải mọi dimension đều cần áp dụng cho toàn bộ suite. Cân nhắc tạo nhiều config riêng cho từng nhóm test thay vì một config siêu lớn.
Pitfall 2 — Naming inconsistent làm wildcard filter fail
Tên project không theo convention sẽ bị bỏ sót bởi wildcard filter — bug âm thầm vì Playwright không báo lỗi khi pattern không khớp với project nào:
// Sai: tên không nhất quán vì normalize không đúng
projects: browsers.flatMap(browser => ({
name: browser + '-' + role, // 'Desktop Chrome-admin' — có space
// --project="*-admin-staging" KHÔNG match 'Desktop Chrome-admin-staging'
}))
// Đúng: normalize trước
const browserSlug = (name: string) => name.replace(/\s+/g, '-').toLowerCase();
name: `${browserSlug(browser)}-${role}-${envName}`,
// → 'desktop-chrome-admin-staging' — wildcard match đúng
Pitfall 3 — Quên dependency setup per project
Khi thêm role dimension nhưng quên khai báo setup project và dependencies, các test project chạy mà không có storageState — test fail do chưa authenticate, hoặc dùng nhầm auth state của role khác.
// Sai: không có dependencies → storageState file chưa tồn tại khi test chạy
{
name: 'desktop-chrome-admin-staging',
use: {
storageState: 'playwright/.auth/admin-staging.json',
// File này chưa được tạo!
},
}
// Đúng: khai báo dependencies rõ ràng
{
name: 'desktop-chrome-admin-staging',
use: {
storageState: 'playwright/.auth/admin-staging.json',
},
dependencies: ['setup-admin-staging'],
}
Pitfall 4 — flatMap lồng sai dẫn đến project structure bị vỡ
Khi lồng flatMap nhiều tầng, sai vị trí flatMap vs map tạo ra mảng lồng nhau thay vì mảng phẳng:
// Sai: flatMap ngoài, map trong, nhưng map trong trả về mảng → mảng lồng nhau
projects: browsers.map(browser => // ← SAI: dùng map thay vì flatMap
roles.flatMap(role =>
Object.entries(envs).map(([envName, baseURL]) => ({
name: `${browser}-${role}-${envName}`,
}))
)
),
// Kết quả: [[project, project], [project, project]]
// Playwright cần: [project, project, project, project]
// Đúng: flatMap ở tất cả các tầng trừ tầng trong cùng
projects: browsers.flatMap(browser => // ← flatMap
roles.flatMap(role => // ← flatMap
Object.entries(envs).map(([envName, baseURL]) => ({ // ← map (trong cùng)
name: `${browser}-${role}-${envName}`,
}))
)
),
Quy tắc: chỉ tầng trong cùng (tầng sinh ra object project) dùng map. Mọi tầng còn lại dùng flatMap để flatten một cấp.
Quiz
Câu 1
Config có 3 browsers × 3 roles × 2 envs. Test suite gồm 50 test. Khi chạy không filter, tổng số test instances là bao nhiêu? Khi filter --project="*-admin-*", còn bao nhiêu instances?
Đáp án
Không filter: 3×3×2 = 18 project × 50 test = 900 instances. Filter *-admin-*: chỉ còn 3×1×2 = 6 project admin → 6×50 = 300 instances. Naming convention chuẩn {browser}-{role}-{env} đảm bảo *-admin-* chỉ match đúng project có -admin- ở giữa.
Câu 2
Đoạn code dưới đây có vấn đề gì?
const browsers = ['Desktop Chrome', 'Desktop Firefox'] as const;
const roles = ['admin', 'user'] as const;
projects: browsers.map(browser =>
roles.map(role => ({
name: `${browser}-${role}`,
use: { ...devices[browser] },
}))
),
- Thiếu
as consttrênroles - Dùng
mapthay vìflatMap— tạo ra mảng lồng nhau thay vì mảng phẳng - Tên project chứa space — naming convention sai
- Cả B và C đều đúng
Đáp án
D. Hai vấn đề: (B) browsers.map(...roles.map(...)) tạo ra [[p, p], [p, p]] — mảng lồng nhau. projects cần mảng phẳng, phải dùng browsers.flatMap(...roles.map(...)). (C) Tên 'Desktop Chrome-admin' chứa space vì browser preset name chưa được normalize — wildcard filter sẽ sót project này. Cần browserSlug(browser) trước khi ghép tên.
Câu 3
Config dùng isFullMatrix = process.env.FULL_MATRIX === 'true' để chọn giữa 1 browser và 3 browser. CI PR job quên set env var này. Hệ quả là gì, và đây là behavior tốt hay xấu?
Đáp án
process.env.FULL_MATRIX là undefined, undefined === 'true' là false → isFullMatrix = false → dùng 1 browser. PR check chạy ít project hơn — feedback nhanh. Đây là behavior tốt (fail-safe về phía nhỏ hơn): nếu quên set flag thì CI nhẹ hơn chứ không phải nặng hơn. Ngược lại, nếu mặc định là full matrix và cần flag để tắt, thì quên flag sẽ làm PR check mất 2 giờ — behavior nguy hiểm hơn.
Câu 4
Matrix có role dimension với 2 roles (admin, user) và 2 envs (staging, prod). Cần setup auth state riêng per-role per-env. Tối thiểu cần bao nhiêu setup project? Và setup project có cần nhân với số browser không?
Đáp án
Tối thiểu 4 setup project: admin-staging, admin-prod, user-staging, user-prod. Setup project không cần nhân với số browser vì auth state (storageState JSON gồm cookies và localStorage) không phụ thuộc browser engine — Chrome, Firefox, WebKit đều dùng chung được cùng một file JSON. Số setup project = roles × envs, không phải browsers × roles × envs.
