Danh sách bài viết

Bài 23: Fixture option: true — Parametrize Qua Project

Custom fixture thông thường có giá trị cố định — không thể override từ ngoài mà không sửa code định nghĩa fixture. Flag option: true trong tuple syntax thay đổi điều đó: nó biến fixture thành "Option Fixture" tự định nghĩa, có default value nhưng override được qua use: { ... } ở project config, file, hoặc describe block. Bài này cover cú pháp tuple [defaultValue, { option: true }], combine với async function fixture, pattern project matrix để parametrize theo env, pattern A/B variant test, use cases thực tế (apiBaseURL, testCredentials, feature flag), 4 pitfall hay gặp và quiz 5 câu.

27/05/2026
14 phút đọc
0 lượt xem
1

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 }] trong test.extend().
  • Combine option: true vớ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 apiBaseURL khác nhau.
  • Viết A/B variant test dùng option fixture variant.
  • Phân biệt option: true với auto: true và với built-in Option Fixtures.
  • Tránh 4 pitfall hay gặp khi dùng option: true.
2

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.

3

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à autooption.

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.

4

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'
});
5

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, apiClient sẽ được tạo lại từ URL mới.
  • Nếu user override cả apiClient (inject instance custom), hàm async setup của apiClient khô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ó.

6

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.

7

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-devapi-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ả apiBaseURLregion đã được resolve theo project.

8

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();
});
9

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' } },
],
10

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: trueoption: 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.

11

Limitations

  • Override chỉ ở project/file/describe level — không thể override per-test bằng test.use({ ... }) bên trong hàm test(). test.use() là compile-time, không phải runtime.
  • Default value phải là giá trị sync — không thể dùng async expression hoặc Promise là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 — apiClient phụ thuộc apiBaseURL).
  • Type generics phức tạp khi nhiều option phụ thuộc nhau — khi apiClient phụ thuộc apiBaseURL mà 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.
12

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 pagecontext, 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.

13

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 }],
});
  1. Fixture tự chạy mọi test không cần inject
  2. Fixture có default 'dev', override được qua use: { env } ở project/file/describe
  3. Fixture chỉ chạy khi test có @option tag
  4. 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' }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ì?

  1. 'https://api.dev.com' — default value của fixture
  2. 'https://api.staging.com' — project config
  3. 'https://api.special.com' — file-level override có priority cao hơn project
  4. 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?

  1. Override apiBaseURL cho test đó
  2. Override apiBaseURL cho tất cả test sau đó trong file
  3. Không có tác dụng — test.use() chỉ hoạt động ở describe/file scope
  4. 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?

  1. Khai báo apiClient với option: true và default value là instance client
  2. Khai báo apiBaseURL với option: true (tuple), khai báo apiClient là function fixture nhận apiBaseURL làm dependency
  3. Khai báo cả apiBaseURLapiClient là function fixture, không dùng option: true
  4. Dùng auto: true trên apiClient
Đá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ì?

  1. Built-in option fixture không override được qua use:, custom thì được
  2. Custom option fixture chỉ override được ở project level, built-in ở mọi level
  3. 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
  4. Custom option fixture cần thêm { option: true } để hoạt động; built-in không cần
Đáp án

CD đề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 BrowserContextAPIRequestContext 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.