Danh sách bài viết

Bài 1: Built-in Fixture page

Series 2 đào sâu Test Framework Playwright. Bài này mở Chương A.1 — Built-in Fixtures. Fixture page là built-in fixture cốt lõi nhất Playwright Test: mỗi test nhận một Page mới hoàn toàn, được tạo tự động từ context riêng và bị đóng sau khi test kết thúc. Bài này deep dive vòng đời, cơ chế isolation, default behaviors, cách dùng nhiều page trong 1 test, pattern override fixture và 4 pitfall hay gặp.

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

Mục Tiêu Bài Học

Sau khi hoàn thành bài này, bạn sẽ:

  • Mô tả được vòng đời của page fixture: tạo lúc nào, đóng lúc nào, ai quản lý.
  • Giải thích cơ chế isolation: tại sao 2 test không thể share state qua page.
  • Biết default behaviors của page: viewport, user agent, storageState mặc định.
  • Biết cách tạo page thứ hai trong cùng 1 test khi cần.
  • Hiểu khi nào nên override page fixture và cách làm đúng.
  • Tránh được 4 pitfall hay gặp với page fixture.

Page methods (page.goto(), page.locator(), page.screenshot()...) đã được cover kỹ ở Series 1. Bài này không lặp lại — focus vào cơ chế bên dưới.

2

page Fixture Là Gì

page là built-in fixture có scope test — Playwright Test tự động tạo một Page instance cho mỗi test và inject vào test function qua destructuring:

import { test, expect } from '@playwright/test';

test('homepage title', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle(/Example/);
});

Ở tầng implementation, page fixture phụ thuộc vào context fixture. Source code Playwright Test (gói @playwright/test) implement tương đương:

// Simplified — actual source tại packages/playwright/src/worker/testInfo.ts
page: async ({ context }, use) => {
  const page = await context.newPage();
  await use(page);
  // cleanup được context.close() handle — page.close() không cần gọi thủ công
},

Ba điểm cần nhớ từ snippet trên:

  • page được tạo từ context — không phải từ browser trực tiếp.
  • use(page) là điểm "yield" — test body chạy ở đây. Sau khi test body hoàn thành (hoặc fail), code sau use mới chạy tiếp.
  • Cleanup page được delegate cho context.close(): khi context đóng, mọi page trong context đó cũng bị đóng theo.
3

Vòng Đời (Lifecycle)

Thứ tự khởi tạo và teardown của page fixture trong một test:

1. browser fixture khởi tạo (worker-scope, dùng chung)
2. context fixture khởi tạo → context.newContext() → BrowserContext mới
3. page fixture khởi tạo → context.newPage() → Page mới
   └── URL ban đầu: about:blank
4. TEST BODY chạy (code trong async ({ page }) => { ... })
5. page fixture teardown (page.close() implicit qua context.close())
6. context fixture teardown → context.close()
7. browser fixture giữ nguyên (worker-scope, không đóng giữa test)

Hai điểm quan trọng về thứ tự này:

  • URL ban đầu luôn là about:blank. Nếu quên await page.goto(), mọi action chạy trên trang trống — không có DOM nào để tương tác. Pitfall này được trình bày ở mục 10.
  • Page không bị đóng cho đến sau khi test body hoàn thành. Điều này có nghĩa là test.afterEach() vẫn có thể access page:
test.afterEach(async ({ page }, testInfo) => {
  // page vẫn còn open ở đây — hữu ích để chụp screenshot khi fail
  if (testInfo.status !== testInfo.expectedStatus) {
    const screenshot = await page.screenshot();
    await testInfo.attach('failure-screenshot', {
      body: screenshot,
      contentType: 'image/png',
    });
  }
});

Tuy nhiên sau khi afterEach hoàn thành, context và page bị đóng — không thể truy cập page trong bất kỳ callback nào chạy sau đó (vd afterAll không có page fixture).

4

Cơ Chế Isolation

Mỗi test nhận một BrowserContext mới → một Page mới. Context ở đây tương đương một profile trình duyệt hoàn toàn riêng biệt — cookies, localStorage, sessionStorage, IndexedDB, service workers đều không share giữa các test.

// test A và test B KHÔNG share bất kỳ state nào
test('test A — user logs in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('secret');
  await page.getByRole('button', { name: 'Login' }).click();
  // cookie session được set tại đây
  await expect(page).toHaveURL('/dashboard');
});

test('test B — same page, fresh state', async ({ page }) => {
  await page.goto('/dashboard');
  // Page này thuộc context mới — không có cookie từ test A
  // → redirect về /login
  await expect(page).toHaveURL('/login');
});

Đây là isolation by default. Playwright Test không có cơ chế "shared page" giữa các test — nếu cần share authentication state, cần dùng storageState từ project config hoặc custom fixture (bài A.2 và A.3).

Anti-pattern: global state qua biến ngoài test

// SAI — biến ngoài test bị share, nhưng page instance khác nhau
let sharedPage: Page;

test.beforeAll(async ({ browser }) => {
  sharedPage = await browser.newPage(); // page thủ công
});

test('test 1', async () => {
  await sharedPage.goto('/');   // dùng sharedPage thủ công
  await sharedPage.getByRole('button', { name: 'Login' }).click();
});

test('test 2', async () => {
  // test 2 inherit state từ test 1 qua sharedPage
  // → test không còn độc lập, thứ tự chạy ảnh hưởng đến kết quả
});

Pattern trên không tận dụng được fixture lifecycle và làm test phụ thuộc lẫn nhau. Ngoại lệ duy nhất hợp lý: fixture scope worker với intent rõ ràng là share state (bài A.3).

5

Default Behaviors

Khi không có config ghi đè, page fixture tạo page với các default sau:

Thuộc tính Giá trị mặc định Override bằng
Viewport { width: 1280, height: 720 } Option fixture viewport (bài A.2) hoặc page.setViewportSize()
User agent Default browser engine (Chromium / Firefox / WebKit) Option fixture userAgent hoặc contextOptions
Storage state Trống (no cookies, no localStorage) Option fixture storageState — load file JSON
Base URL Không có (phải truyền URL đầy đủ vào goto()) Option fixture baseURL
Locale OS locale của máy chạy test Option fixture locale
Timezone OS timezone của máy chạy test Option fixture timezoneId

Default behaviors của page thực chất là kết quả của context fixture — page thừa hưởng toàn bộ config từ context. Vì vậy pattern phổ biến để áp dụng setting chung là override context (không phải page), sẽ trình bày ở bài 2.

Kiểm tra runtime defaults

test('verify page defaults', async ({ page }) => {
  // Viewport
  const viewport = page.viewportSize();
  console.log(viewport); // { width: 1280, height: 720 }

  // URL ban đầu
  console.log(page.url()); // about:blank
});
6

Library Mode vs Test Runner

Sự khác biệt cốt lõi giữa hai cách dùng Playwright:

Library mode (playwright) Test Runner (@playwright/test)
Tạo page Thủ công: await context.newPage() Tự động qua fixture
Cleanup Dev tự gọi browser.close() Fixture lifecycle tự dọn
Isolation Không tự động — dev tự quản lý Mặc định: context mới mỗi test
Config Truyền trực tiếp vào newContext() Option fixtures hoặc project config
// Library mode — quản lý thủ công
import { chromium } from 'playwright';

const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await context.newPage();

await page.goto('https://example.com');
// ... actions

await context.close();
await browser.close();
// Test Runner — fixture lo hết
import { test } from '@playwright/test';

test('example', async ({ page }) => {
  // page đã ready, đã có context, viewport theo config
  await page.goto('https://example.com');
  // cleanup tự động sau test
});

Trong Series 2, hầu hết bài đều dùng Test Runner. Library mode được đào sâu ở Chương I (Scraping Patterns).

7

Nhiều Page Trong 1 Test

Fixture page chỉ inject một page duy nhất. Khi test cần tương tác với nhiều tab hoặc nhiều cửa sổ trong cùng một context, cần tạo page thứ hai thủ công từ context:

test('admin and user on same app', async ({ page, context }) => {
  // page thứ nhất — user thường
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('password');
  await page.getByRole('button', { name: 'Login' }).click();

  // page thứ hai — cùng context → share cookies
  const adminPage = await context.newPage();
  await adminPage.goto('/admin/login');
  await adminPage.getByLabel('Email').fill('[email protected]');
  await adminPage.getByLabel('Password').fill('admin-password');
  await adminPage.getByRole('button', { name: 'Login' }).click();

  // Tương tác song song
  await page.goto('/messages');
  await adminPage.goto('/admin/messages');

  await page.getByRole('button', { name: 'Send message' }).click();
  await expect(adminPage.getByText('New message from user')).toBeVisible();
});

Lưu ý: adminPage được tạo từ context — cùng context với page đầu tiên. Điều này có nghĩa chúng share cookies. Nếu cần 2 user hoàn toàn độc lập (không share cookie), dùng browser fixture để tạo context thứ hai:

test('two independent users', async ({ page, browser }) => {
  // page thuộc context A (từ fixture)
  await page.goto('/login');
  // ...login user A

  // context B hoàn toàn mới — không share cookie với context A
  const contextB = await browser.newContext();
  const pageB = await contextB.newPage();
  await pageB.goto('/login');
  // ...login user B

  // Cleanup contextB thủ công — fixture chỉ quản lý context của page đầu
  await contextB.close();
});

Mô hình "hai user" này thường áp dụng để test real-time feature (chat, notification, collaborative editing).

8

Override page Fixture

Override page fixture trực tiếp hiếm khi cần. Use case hợp lý: auto-inject script vào mọi page trước khi test body chạy — ví dụ disable animations, inject test helpers, hoặc set global JS variable.

// fixtures.ts
import { test as base, Page } from '@playwright/test';

export const test = base.extend<{ page: Page }>({
  page: async ({ page }, use) => {
    // Inject script trước khi test body chạy
    await page.addInitScript(() => {
      // Disable CSS animations cho stable screenshots
      const style = document.createElement('style');
      style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }';
      document.head.appendChild(style);
    });

    // QUAN TRỌNG: phải gọi use(page) — thiếu dòng này → test hang vô hạn
    await use(page);

    // Teardown tùy chọn (hầu hết không cần vì context.close() lo)
  },
});

Một lưu ý khi override page: addInitScript chỉ có hiệu lực trên page navigation tiếp theo — nếu page.goto() đã được gọi trước khi script được inject, nó không chạy trên navigation đó. Override page fixture đảm bảo script được đăng ký trước khi test body bắt đầu.

Pattern phổ biến hơn: override context

Trong đa số trường hợp, override context thay vì page là đủ và ít side-effect hơn — vì page được tạo từ context, mọi config trên context đều ảnh hưởng page. Pattern này được trình bày kỹ ở bài 2 (fixture context).

9

Page Object Model Overlay

Page Object Model (POM) thường wrap page fixture thành class để đóng gói locators và actions:

// pages/LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
  constructor(private page: Page) {}

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Login' }).click();
  }
}
// test sử dụng POM
test('user can login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'secret');
  await expect(page).toHaveURL('/dashboard');
});

page fixture vẫn là nguồn gốc — class chỉ nhận page làm dependency thay vì trực tiếp tạo ra nó. Điều này giữ nguyên lifecycle và isolation do fixture quản lý.

Nâng cao hơn: tạo custom fixture cho từng page object để inject trực tiếp vào test (không cần new thủ công trong test body). Pattern đó được đào sâu ở bài A.3 — Custom Fixtures với test.extend().

10

Common Pitfalls

1. Quên await page.goto() — action chạy trên about:blank

// SAI
test('check header', async ({ page }) => {
  // page.goto() bị quên → page vẫn là about:blank
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
  // → TimeoutError sau 30s vì DOM không tồn tại
});

// ĐÚNG
test('check header', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

2. Nhầm fixture page với class Page từ library

// SAI — import Page class từ 'playwright' raw để dùng như type, nhưng nhầm
import { Page } from 'playwright'; // OK nếu chỉ dùng làm type
// nhưng nếu dùng:
import { chromium, Page } from 'playwright';
const page: Page = await chromium.launch().then(...); // Library mode
// ...rồi mix với test() từ @playwright/test → lẫn lộn context quản lý

// RÕ RÀNG hơn:
import { test, expect, type Page } from '@playwright/test';
// type Page import từ @playwright/test là re-export từ playwright — same type

Tóm gọn: trong file spec, luôn import từ '@playwright/test'. Nếu cần type Page cho annotation, import type Page từ '@playwright/test' — không cần import từ package raw.

3. Cố truy cập page sau khi test đã kết thúc

// SAI — lưu page vào biến ngoài rồi truy cập trong afterAll
let capturedPage: Page;

test('some test', async ({ page }) => {
  capturedPage = page;
  await page.goto('/');
});

test.afterAll(async () => {
  // page đã bị đóng sau test → mọi action throw TargetClosedError
  await capturedPage.screenshot(); // Error: Target page, context or browser has been closed
});

Nếu cần chụp screenshot sau test fail, dùng test.afterEach() với fixture page — lúc đó page vẫn còn mở (xem mục 3).

4. Override fixture nhưng quên gọi await use(...)

// SAI — test sẽ hang vô hạn
export const test = base.extend<{ page: Page }>({
  page: async ({ page }, use) => {
    await page.addInitScript(() => { window.__TEST_MODE__ = true; });
    // Quên await use(page) → fixture không bao giờ "yield" → test không chạy
  },
});

// ĐÚNG
export const test = base.extend<{ page: Page }>({
  page: async ({ page }, use) => {
    await page.addInitScript(() => { window.__TEST_MODE__ = true; });
    await use(page); // bắt buộc
  },
});

Khi test hang không rõ lý do, kiểm tra mọi custom fixture có await use(...) không — đây là nguyên nhân phổ biến nhất.

11

Tổng Kết

  • page fixture có scope test: tạo trước mỗi test, đóng sau khi test (và afterEach) hoàn thành.
  • Phụ thuộc context fixture: implementation là context.newPage()use(page).
  • URL ban đầu luôn là about:blank — phải await page.goto() trước khi tương tác DOM.
  • Isolation hoàn toàn: mỗi test nhận context mới — no cookies, no localStorage carry-over từ test trước.
  • Default viewport: 1280x720. Có thể override qua option fixture viewport hoặc project config.
  • Fixture chỉ inject 1 page. Cần page thứ hai: dùng context.newPage() (cùng context) hoặc browser.newContext().newPage() (context tách biệt).
  • Override page fixture: cần thiết khi muốn auto-inject script. Luôn phải gọi await use(page) — thiếu → test hang.
  • POM thường nhận page fixture làm constructor dependency — không tự tạo page, để fixture quản lý lifecycle.
12

Bài Tập Củng Cố

Câu 1

Đoạn code sau có vấn đề gì? Giải thích và sửa:

let savedPage: Page;

test.beforeAll(async ({ browser }) => {
  const context = await browser.newContext();
  savedPage = await context.newPage();
  await savedPage.goto('/app');
});

test('test 1', async () => {
  await expect(savedPage.getByText('Welcome')).toBeVisible();
});

test('test 2', async () => {
  await savedPage.getByRole('button', { name: 'Logout' }).click();
  await expect(savedPage).toHaveURL('/login');
});
Đáp án

Có 3 vấn đề:

  1. Test 2 phụ thuộc state từ test 1. Test 1 phải chạy trước thì test 2 mới có trạng thái logged-in để logout. Nếu thứ tự thay đổi hoặc test 1 fail, test 2 không thể chạy đúng.
  2. Không cleanup context. Context tạo thủ công trong beforeAll không được đóng trong afterAll — memory leak.
  3. Không tận dụng fixture isolation. Pattern đúng: mỗi test nên nhận page fixture và setup trạng thái của mình (dùng storageState hoặc API login nếu cần).
// Cách đúng — mỗi test độc lập
test('user sees welcome message after login', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('secret');
  await page.getByRole('button', { name: 'Login' }).click();
  await expect(page.getByText('Welcome')).toBeVisible();
});

test('user is redirected to login after logout', async ({ page }) => {
  // Setup trạng thái logged-in độc lập (ví dụ qua storageState)
  await page.goto('/login');
  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('secret');
  await page.getByRole('button', { name: 'Login' }).click();
  await page.getByRole('button', { name: 'Logout' }).click();
  await expect(page).toHaveURL('/login');
});

Câu 2

Khi nào nên dùng context.newPage() trong test, khi nào nên dùng browser.newContext().then(c => c.newPage())? Cho ví dụ use case.

Đáp án
  • context.newPage(): khi cần page thứ hai nhưng muốn share cookie/session với page đầu. Use case: kiểm tra rằng khi mở tab mới, user vẫn logged in (vì cùng session cookie).
  • browser.newContext().then(c => c.newPage()): khi cần 2 người dùng hoàn toàn tách biệt — không share cookie, không share session. Use case: test chat giữa 2 user khác nhau, hoặc test admin và regular user trên cùng app.

Câu 3

Override page fixture để tất cả test trong project tự động có window.__ENV__ = 'test' được set trước khi navigation xảy ra. Viết fixture đó.

Đáp án
// fixtures/index.ts
import { test as base, type Page } from '@playwright/test';

export const test = base.extend<{ page: Page }>({
  page: async ({ page }, use) => {
    await page.addInitScript(() => {
      (window as any).__ENV__ = 'test';
    });
    await use(page);
  },
});

export { expect } from '@playwright/test';

addInitScript được đăng ký trước khi use(page) — mọi navigation trong test body đều chạy script này. Import test từ file fixtures này thay vì từ '@playwright/test'.

Câu 4

Tại sao URL ban đầu của page fixture là about:blank chứ không phải baseURL? Điều này có nghĩa gì trong thực tế?

Đáp án

Playwright tạo page với about:blank để không tốn thời gian network load trước khi test quyết định navigate về đâu. Một số test có thể không cần goto() mà thao tác trực tiếp qua page.setContent() (inject HTML thủ công để test component isolated).

Trong thực tế: nếu test quên gọi await page.goto(), tất cả action sau đó chạy trên trang trống. baseURL không tự động navigate — nó chỉ là prefix khi bạn truyền path tương đối vào goto(): await page.goto('/login') với baseURL = 'https://example.com' sẽ navigate tới https://example.com/login.

Câu 5

test.afterEach() có thể dùng page fixture không? Khi nào page bị đóng thực sự?

Đáp án

Có. test.afterEach() nhận đầy đủ fixtures, bao gồm page. Page vẫn còn mở trong afterEach — đây là lý do hook này phù hợp để chụp screenshot khi test fail.

Page bị đóng sau khi tất cả afterEach hooks hoàn thành — lúc đó fixture teardown (context.close()) mới được gọi. test.afterAll() không nhận page fixture (page không tồn tại ở scope đó).

13

Bài Tiếp Theo

Bài tiếp theo trong nhóm A.1: built-in fixture context — deep dive BrowserContext là gì, tại sao isolation hoạt động ở tầng context, khi nào cần override context thay vì page, và cách áp dụng storageState cho authentication.

Bài 2: Built-in Fixture context