Mục lục
- Mục Tiêu Bài Học
- Vấn Đề: Custom Fixture Không Override Được
- Flag
option: trueLà Gì - Cú Pháp Tuple
[defaultValue, { option: true }] - Combine Với Async Function Fixture
- Override Qua
use:— Project, File, Describe - Pattern Project Matrix Theo Env
- Pattern A/B Variant Test
- Use Cases Thực Tế
- Phân Biệt
option: trueVớiauto: true - Limitations
- 4 Pitfall Hay Gặp
- Quiz
Mục Tiêu Bài Học
- Hiểu tại sao custom fixture thông thường không override được từ
use: { ... }. - Nắm cú pháp tuple
[defaultValue, { option: true }]trongtest.extend(). - Combine
option: truevới async function fixture để tạo fixture phụ thuộc vào option. - Override option fixture qua
use:ở project config, file level và describe block. - Áp dụng pattern project matrix — cùng test chạy với nhiều
apiBaseURLkhác nhau. - Viết A/B variant test dùng option fixture
variant. - Phân biệt
option: truevớiauto: truevà với built-in Option Fixtures. - Tránh 4 pitfall hay gặp khi dùng
option: true.
Vấn Đề: Custom Fixture Không Override Được
Khi định nghĩa custom fixture bằng test.extend(), giá trị fixture được "hard-code" trong body hàm setup. Ví dụ:
// fixtures.ts
export const test = base.extend<{ apiBaseURL: string }>({
apiBaseURL: async ({}, use) => {
await use('https://api.dev.com'); // cố định
},
});
Fixture này không thể thay đổi từ bên ngoài. Khi cần chạy cùng test trên staging, cách duy nhất là sửa file fixtures.ts hoặc tạo fixture mới. Điều này phá vỡ tính tái sử dụng.
Một cách workaround là đọc process.env ngay trong fixture:
apiBaseURL: async ({}, use) => {
await use(process.env.API_URL ?? 'https://api.dev.com');
},
Workaround này hoạt động nhưng có nhược điểm: không thể có nhiều project với các URL khác nhau trong cùng một lần chạy, vì process.env là global và không thể per-project.
Flag option: true giải quyết đúng bài toán này: fixture có default value, và override được từ use: { ... } ở bất kỳ scope nào — project, file hoặc describe — mà không đụng vào code định nghĩa fixture.
Flag option: true Là Gì
Trong test.extend(), mỗi fixture có thể nhận một fixture options object để điều chỉnh hành vi. Hai flag quan trọng nhất là auto và option.
Flag option: true đánh dấu fixture là "configurable option" — tương tự cơ chế của built-in Option Fixtures (baseURL, viewport, ...) mà Playwright cung cấp sẵn, nhưng đây là phiên bản tự định nghĩa. Sự khác biệt quan trọng:
| Loại | Ví dụ | Ai định nghĩa | Override qua |
|---|---|---|---|
| Built-in Option Fixture | baseURL, viewport, locale |
Playwright | use: { ... } |
| Custom Option Fixture | apiBaseURL, variant, testUser |
Dev tự viết | use: { ... } |
Cả hai đều dùng chung cơ chế use: { ... } để override. Khác nhau là built-in fixtures Playwright đã định nghĩa type và behavior sẵn, còn custom option fixture hoàn toàn do dev kiểm soát.
Khi fixture được đánh dấu option: true, Playwright biết rằng giá trị của fixture này có thể đến từ use: { ... } ở scope phù hợp, thay vì luôn chạy hàm setup của fixture. Scope hierarchy (project → file → describe) vẫn áp dụng như với built-in option fixtures.
Cú Pháp Tuple [defaultValue, { option: true }]
Cú pháp dùng tuple hai phần tử: phần tử đầu là default value, phần tử thứ hai là fixture options object.
// fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{
apiBaseURL: string;
}>({
apiBaseURL: ['https://api.dev.com', { option: true }],
// ^--- default value ^--- fixture options
});
Default value phải là giá trị sync — không thể là Promise hay async expression. Playwright đọc default value khi không có override nào từ use: { ... }.
Dùng trong test file:
// api.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
// Override cho toàn bộ file này
test.use({ apiBaseURL: 'https://api.staging.com' });
test('GET /users returns list', async ({ apiBaseURL, request }) => {
const res = await request.get(`${apiBaseURL}/users`);
expect(res.ok()).toBeTruthy();
const users = await res.json();
expect(Array.isArray(users)).toBe(true);
});
Khi không có test.use({ apiBaseURL }), fixture trả về default value 'https://api.dev.com'. Khi có override, Playwright dùng giá trị override — không gọi hàm setup nào (vì đây là giá trị nguyên thủy, không phải function fixture).
Type Inference
TypeScript suy ra type của apiBaseURL từ generic <{ apiBaseURL: string }>. Default value trong tuple phải khớp type đó — nếu truyền số nguyên làm default cho fixture kiểu string, TypeScript báo lỗi tại compile time.
// Ví dụ với union type
export const test = base.extend<{
variant: 'A' | 'B';
}>({
variant: ['A', { option: true }],
// ^--- phải là 'A' hoặc 'B', không được là 'C'
});
Combine Với Async Function Fixture
Tuple [defaultValue, { option: true }] chỉ phù hợp với fixture có giá trị nguyên thủy (string, number, boolean). Khi cần fixture là object phức tạp (như API client instance), cần combine: một option fixture cho URL, một function fixture tạo client từ URL đó.
// fixtures.ts
import { test as base } from '@playwright/test';
import { APIClient } from './api-client';
export type MyFixtures = {
apiBaseURL: string;
apiClient: APIClient;
};
export const test = base.extend<MyFixtures>({
// Option fixture — có default, override được
apiBaseURL: ['https://api.dev.com', { option: true }],
// Function fixture — phụ thuộc vào apiBaseURL
apiClient: [
async ({ apiBaseURL }, use) => {
// apiBaseURL ở đây là giá trị đã được resolve
// (default hoặc giá trị từ use:)
const client = new APIClient(apiBaseURL);
await client.init();
await use(client);
await client.dispose(); // teardown
},
{ option: true },
// ^ option: true cho apiClient — user có thể inject client tuỳ ý
// Bỏ option: true nếu không muốn override toàn bộ apiClient
],
});
Ở đây apiClient cũng được đánh dấu option: true. Điều này có nghĩa:
- Nếu user chỉ override
apiBaseURL,apiClientsẽ được tạo lại từ URL mới. - Nếu user override cả
apiClient(inject instance custom), hàm async setup củaapiClientkhông chạy — user kiểm soát hoàn toàn.
Trường hợp phổ biến hơn: chỉ cần apiBaseURL là option fixture, apiClient là function fixture thông thường phụ thuộc vào nó:
export const test = base.extend<MyFixtures>({
apiBaseURL: ['https://api.dev.com', { option: true }],
// Không có option: true — không override được từ ngoài
apiClient: async ({ apiBaseURL }, use) => {
const client = new APIClient(apiBaseURL);
await client.init();
await use(client);
await client.dispose();
},
});
Khi user override apiBaseURL qua use:, Playwright tự động resolve apiClient với URL mới vì apiClient khai báo apiBaseURL là dependency của nó.
Override Qua use: — Project, File, Describe
Custom option fixture nhận override từ use: { ... } theo đúng scope hierarchy như built-in option fixtures. Priority từ thấp đến cao: project config → file level → describe block.
Override Ở Project Config
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'dev',
use: { apiBaseURL: 'https://api.dev.com' },
},
{
name: 'staging',
use: { apiBaseURL: 'https://api.staging.com' },
},
],
});
Tất cả test trong project dev nhận apiBaseURL = 'https://api.dev.com'. Tất cả test trong project staging nhận 'https://api.staging.com'. Không cần sửa bất kỳ file test nào.
Override Ở File Level
// special-api.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
// Override cho toàn bộ file — ưu tiên hơn project config
test.use({ apiBaseURL: 'https://api.special.com' });
test('special endpoint', async ({ apiBaseURL, request }) => {
const res = await request.get(`${apiBaseURL}/special`);
expect(res.status()).toBe(200);
});
Override Ở Describe Block
// mixed-api.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
test.describe('Internal API', () => {
test.use({ apiBaseURL: 'https://api-internal.dev.com' });
test('internal endpoint', async ({ apiBaseURL, request }) => {
// apiBaseURL = 'https://api-internal.dev.com'
const res = await request.get(`${apiBaseURL}/internal/status`);
expect(res.ok()).toBeTruthy();
});
});
test.describe('Public API', () => {
test.use({ apiBaseURL: 'https://api.dev.com' });
test('public endpoint', async ({ apiBaseURL, request }) => {
// apiBaseURL = 'https://api.dev.com'
const res = await request.get(`${apiBaseURL}/status`);
expect(res.ok()).toBeTruthy();
});
});
Hai describe block trong cùng file có thể dùng apiBaseURL khác nhau. Fixture được setup riêng cho từng test — không có state leak giữa các describe.
Pattern Project Matrix Theo Env
Option fixture kết hợp với projects tạo ra pattern "matrix": cùng bộ test chạy lặp lại với nhiều tham số khác nhau — mỗi project là một bộ tham số. Đây là cách parametrize mạnh hơn môi trường đơn.
Matrix Đơn Giản — Hai Env
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'api-dev',
use: { apiBaseURL: 'https://api.dev.com' },
},
{
name: 'api-staging',
use: { apiBaseURL: 'https://api.staging.com' },
},
],
});
// fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{ apiBaseURL: string }>({
apiBaseURL: ['https://api.dev.com', { option: true }],
});
// users.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('list users', async ({ apiBaseURL, request }) => {
const res = await request.get(`${apiBaseURL}/users`);
expect(res.ok()).toBeTruthy();
const data = await res.json();
expect(data.users.length).toBeGreaterThan(0);
});
test('create user', async ({ apiBaseURL, request }) => {
const res = await request.post(`${apiBaseURL}/users`, {
data: { name: 'Test User', email: '[email protected]' },
});
expect(res.status()).toBe(201);
});
Chạy npx playwright test sẽ thực thi cả hai test trên cả api-dev và api-staging — tổng 4 lần chạy. Report tách biệt per project.
Matrix Nhiều Tham Số
Khi cần parametrize nhiều chiều (env + region chẳng hạn):
// fixtures.ts
export const test = base.extend<{
apiBaseURL: string;
region: 'us' | 'eu' | 'ap';
}>({
apiBaseURL: ['https://api.dev.com', { option: true }],
region: ['us', { option: true }],
});
// playwright.config.ts
projects: [
{
name: 'staging-us',
use: { apiBaseURL: 'https://api.staging.com', region: 'us' },
},
{
name: 'staging-eu',
use: { apiBaseURL: 'https://api.staging-eu.com', region: 'eu' },
},
],
Test nhận cả apiBaseURL và region đã được resolve theo project.
Pattern A/B Variant Test
Option fixture với union type là cách tự nhiên để test A/B variant — cùng test flow nhưng expect khác nhau tùy variant.
// fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{ variant: 'A' | 'B' }>({
variant: ['A', { option: true }],
});
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'variant-A', use: { variant: 'A' } },
{ name: 'variant-B', use: { variant: 'B' } },
],
});
// homepage.spec.ts
import { test } from './fixtures';
import { expect } from '@playwright/test';
test('homepage heading', async ({ page, variant }) => {
await page.goto('/');
if (variant === 'A') {
await expect(page.getByRole('heading', { level: 1 }))
.toContainText('Welcome');
} else {
await expect(page.getByRole('heading', { level: 1 }))
.toContainText('Hello');
}
});
test('CTA button text', async ({ page, variant }) => {
await page.goto('/');
const ctaText = variant === 'A' ? 'Get started' : 'Try it free';
await expect(page.getByRole('button', { name: ctaText }))
.toBeVisible();
});
Playwright chạy mỗi test hai lần — một lần với project variant-A, một lần với variant-B. Report phân biệt rõ test nào fail ở variant nào.
Variant Ở File Level
Khi không cần project riêng mà chỉ muốn test một số file với variant cụ thể:
// feature-b.spec.ts — file chỉ test variant B
test.use({ variant: 'B' });
test('feature B specific behavior', async ({ page, variant }) => {
// variant luôn là 'B' trong file này
await page.goto('/feature');
await expect(page.getByTestId('new-ui')).toBeVisible();
});
Use Cases Thực Tế
Test Credentials Per Env
Mỗi môi trường có tài khoản test riêng — dùng option fixture để inject thay vì hard-code:
// fixtures.ts
export type Credentials = { email: string; password: string };
export const test = base.extend<{
testCredentials: Credentials;
}>({
testCredentials: [
{ email: '[email protected]', password: 'dev-secret' },
{ option: true },
],
});
// playwright.config.ts
projects: [
{
name: 'dev',
use: {
apiBaseURL: 'https://api.dev.com',
testCredentials: { email: '[email protected]', password: 'dev-secret' },
},
},
{
name: 'staging',
use: {
apiBaseURL: 'https://api.staging.com',
testCredentials: { email: '[email protected]', password: 'stg-secret' },
},
},
],
Feature Flag
Khi cần test tính năng chưa roll out toàn bộ, dùng feature flag fixture để bật/tắt per project:
// fixtures.ts
export const test = base.extend<{
featureNewCheckout: boolean;
}>({
featureNewCheckout: [false, { option: true }],
});
// playwright.config.ts
projects: [
{ name: 'checkout-legacy', use: { featureNewCheckout: false } },
{ name: 'checkout-new', use: { featureNewCheckout: true } },
],
// checkout.spec.ts
test('complete checkout', async ({ page, featureNewCheckout }) => {
await page.goto('/cart');
if (featureNewCheckout) {
await page.getByTestId('new-checkout-btn').click();
} else {
await page.getByRole('button', { name: 'Proceed to checkout' }).click();
}
// ... tiếp tục flow
});
Test Data Set Per Env
Một số env có seed data khác nhau — inject ID hay slug cụ thể để test không bị phụ thuộc vào data hard-code:
// fixtures.ts
export const test = base.extend<{
sampleProductId: string;
}>({
sampleProductId: ['prod_dev_001', { option: true }],
});
// playwright.config.ts
projects: [
{ name: 'dev', use: { sampleProductId: 'prod_dev_001' } },
{ name: 'staging', use: { sampleProductId: 'prod_stg_042' } },
],
Phân Biệt option: true Với auto: true
Hai flag này đều nằm trong fixture options object nhưng có mục đích hoàn toàn khác nhau:
| Flag | Hành vi | Inject vào test | Override qua use: |
|---|---|---|---|
auto: true |
Chạy tự động cho mọi test, không cần khai báo trong test signature | Có (nếu muốn đọc giá trị) | Không áp dụng |
option: true |
Có default value, chờ được override từ use: { ... } |
Có (để đọc giá trị đã resolve) | Có — project/file/describe |
Ví dụ so sánh:
// auto: true — chạy mọi test, dùng để side effect
setupDatabase: [
async ({}, use) => {
await db.seed();
await use(); // không có giá trị inject
await db.clean();
},
{ auto: true }, // không cần inject vào test
],
// option: true — có giá trị, override được
apiBaseURL: ['https://api.dev.com', { option: true }],
// Inject vào test để đọc: async ({ apiBaseURL }) => { ... }
Không thể combine auto: true và option: true trên cùng một fixture — chúng phục vụ mục đích khác nhau và hành vi combine không được Playwright hỗ trợ rõ ràng.
Limitations
-
Override chỉ ở project/file/describe level — không thể override per-test bằng
test.use({ ... })bên trong hàmtest().test.use()là compile-time, không phải runtime. -
Default value phải là giá trị sync — không thể dùng
asyncexpression hoặcPromiselàm default trong tuple. Nếu cần async initialization, cần dùng function fixture riêng phụ thuộc vào option fixture (như pattern ở bài 5 —apiClientphụ thuộcapiBaseURL). -
Type generics phức tạp khi nhiều option phụ thuộc nhau — khi
apiClientphụ thuộcapiBaseURLmà cả hai đều là option fixture, TypeScript inference đôi khi cần annotation tường minh để không bị lỗi type. -
Object default value bị share reference — khi default là object (như
Credentials), tất cả test không override đều dùng cùng object đó. Playwright không deep-clone default. Nếu fixture function mutate object này, có thể gây bug không ngờ. Khuyến nghị dùng object literal mới mỗi lần hoặc dùng function fixture thay vì tuple.
4 Pitfall Hay Gặp
Pitfall 1 — Quên Flag option: true
Khai báo fixture dưới dạng function fixture thông thường (không có option: true) nhưng lại cố override qua use: { ... } — không có tác dụng, Playwright bỏ qua giá trị trong use: với fixture không phải option.
// SAI — không có option: true
export const test = base.extend<{ apiBaseURL: string }>({
apiBaseURL: async ({}, use) => {
await use('https://api.dev.com'); // cố định
},
});
// playwright.config.ts
use: { apiBaseURL: 'https://api.staging.com' }, // BỊ BỎ QUA
// ĐÚNG
export const test = base.extend<{ apiBaseURL: string }>({
apiBaseURL: ['https://api.dev.com', { option: true }],
});
Playwright không báo lỗi hay warning khi có key trong use: không match option fixture — key đó bị bỏ qua lặng lẽ. Debug bằng cách log apiBaseURL trong test để xác nhận giá trị thực.
Pitfall 2 — Default Value undefined Hoặc null
Default undefined khiến consumer fixture không biết có hay không có giá trị, dễ gây bug:
// Không tốt — user có thể không nhận ra cần set apiBaseURL
apiBaseURL: [undefined as unknown as string, { option: true }],
// Trong test:
const res = await request.get(`${apiBaseURL}/users`);
// → TypeError: Cannot read properties of undefined (reading 'users')
// Hoặc: request.get('undefined/users') — URL sai không rõ ràng
Nếu fixture buộc phải được set bởi người dùng (không có default hợp lý), dùng function fixture và throw lỗi rõ ràng khi không được cung cấp, thay vì dùng undefined làm default.
Pitfall 3 — Gọi test.use() Bên Trong Hàm Test
test.use() không phải runtime API — Playwright parse nó ở load time, không execute time:
test('wrong override', async ({ apiBaseURL, request }) => {
test.use({ apiBaseURL: 'https://api.staging.com' }); // KHÔNG CÓ TÁC DỤNG
// apiBaseURL vẫn là giá trị từ scope trên, không phải staging
const res = await request.get(`${apiBaseURL}/users`);
});
Để override per-test, cần đặt test.use() trong test.describe() bao quanh test đó, hoặc xây URL thủ công trong test body (không dùng fixture).
Pitfall 4 — Nhầm Custom Option Fixture Với Built-in Option Fixture
Built-in option fixtures (baseURL, viewport, ...) và custom option fixtures đều override qua use:, nhưng scope behavior có khác biệt quan trọng: built-in fixtures ảnh hưởng đến cách Playwright khởi tạo page và context, còn custom option fixtures là giá trị thuần túy inject vào test.
// playwright.config.ts
use: {
baseURL: 'https://staging.app.com', // built-in → ảnh hưởng page.goto
apiBaseURL: 'https://api.staging.com', // custom → chỉ là string inject
},
Đặt tên custom option fixture dễ nhầm với built-in: tránh dùng tên trùng với các built-in fixtures (baseURL, viewport, v.v.) — TypeScript không báo lỗi nhưng behavior có thể không như mong muốn khi override.
Quiz
Câu 1
Định nghĩa sau sử dụng flag gì và cho phép gì?
export const test = base.extend<{ env: string }>({
env: ['dev', { option: true }],
});
- Fixture tự chạy mọi test không cần inject
- Fixture có default
'dev', override được quause: { env }ở project/file/describe - Fixture chỉ chạy khi test có
@optiontag - Fixture không thể override, luôn trả về
'dev'
Đáp án
B. Tuple [defaultValue, { option: true }] khai báo custom option fixture: default là 'dev', override được qua use: { env: 'staging' } ở mọi scope. A là behavior của auto: true. C không tồn tại. D sai — không có flag nào làm fixture cố định mà vẫn dùng tuple syntax này.
Câu 2
Có hai project trong config: dev với use: { apiBaseURL: 'https://api.dev.com' } và staging với use: { apiBaseURL: 'https://api.staging.com' }. Một file test có test.use({ apiBaseURL: 'https://api.special.com' }) ở top-level. Khi chạy project staging, apiBaseURL trong test là gì?
'https://api.dev.com'— default value của fixture'https://api.staging.com'— project config'https://api.special.com'— file-level override có priority cao hơn project- Throw lỗi vì conflict
Đáp án
C. File-level test.use() có priority cao hơn project config. Scope hierarchy: project (thấp nhất) → file → describe (cao nhất). Khi chạy project staging, file-level override 'https://api.special.com' thắng. A sai (default bị overridden). B sai (project config bị file override). D sai — không có conflict, chỉ có priority.
Câu 3
Điều gì xảy ra khi đặt test.use({ apiBaseURL: 'https://api.other.com' }) bên trong hàm test?
- Override
apiBaseURLcho test đó - Override
apiBaseURLcho tất cả test sau đó trong file - Không có tác dụng —
test.use()chỉ hoạt động ở describe/file scope - Throw
Error: test.use() cannot be called inside test
Đáp án
C. Playwright parse test.use() khi load file (static analysis), không phải khi test chạy. Gọi bên trong hàm test không báo lỗi nhưng hoàn toàn không có tác dụng. Fixture nhận giá trị từ scope ngoài đã được resolve trước khi test chạy.
Câu 4
Muốn fixture apiClient (là API client object) tự động tạo lại khi apiBaseURL bị override. Cấu trúc nào đúng?
- Khai báo
apiClientvớioption: truevà default value là instance client - Khai báo
apiBaseURLvớioption: true(tuple), khai báoapiClientlà function fixture nhậnapiBaseURLlàm dependency - Khai báo cả
apiBaseURLvàapiClientlà function fixture, không dùngoption: true - Dùng
auto: truetrênapiClient
Đáp án
B. apiBaseURL là option fixture (override được từ ngoài), apiClient là function fixture phụ thuộc vào apiBaseURL. Khi apiBaseURL thay đổi per-project/file, Playwright tự resolve apiClient với URL mới. A sai vì default value của option tuple không thể là object có phương thức phức tạp. C sai vì không override được từ use:. D sai vì auto: true không liên quan.
Câu 5
Điểm khác nhau cốt lõi giữa custom option fixture và built-in option fixture (baseURL, viewport) là gì?
- Built-in option fixture không override được qua
use:, custom thì được - Custom option fixture chỉ override được ở project level, built-in ở mọi level
- Built-in option fixture ảnh hưởng đến cách Playwright khởi tạo
page/context; custom option fixture là giá trị thuần túy inject vào test - Custom option fixture cần thêm
{ option: true }để hoạt động; built-in không cần
Đáp án
C và D đều đúng — nhưng D là điểm kỹ thuật cú pháp, C là điểm khác biệt về bản chất hành vi. C mô tả đúng nhất sự khác nhau quan trọng: baseURL ảnh hưởng đến BrowserContext và APIRequestContext mà Playwright tạo; custom option fixture như apiBaseURL chỉ là string bạn tự dùng trong test logic — Playwright không đọc nó để khởi tạo gì cả. A sai — built-in cũng override được qua use:. B sai — cả hai đều override được ở mọi level.
