Danh sách bài viết

Bài 2: Built-in Fixture `context`

Fixture `context` trong Playwright Test Runner inject một `BrowserContext` mới vào mỗi test — tức là mỗi test nhận một incognito session hoàn toàn tách biệt. Bài này phân tích nguồn gốc (phụ thuộc `browser` fixture), vòng đời, 3 mức cấu hình, pattern override phổ biến, listener events qua context, và 4 pitfall hay gặp nhất khi custom fixture này.

27/05/2026
12 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ẽ:

  • Hiểu context fixture trả về BrowserContext mới per-test, phụ thuộc browser fixture.
  • Nắm vòng đời: tạo trước test, nhận config từ use, đóng sau test (auto-close tất cả page bên trong).
  • Phân biệt 3 mức cấu hình context: project level, file/describe level, test level.
  • Viết được pattern override context fixture với test.extend() cho các kịch bản: storageState, permissions, geolocation, locale, custom HTTP headers.
  • Sử dụng context.on() để listen popup, request, service worker.
  • Nhận biết và tránh 4 pitfall phổ biến khi override context.
2

context Fixture Là Gì

context là một built-in fixture của @playwright/test. Khi một test function khai báo tham số context, Playwright tự động tạo một BrowserContext mới — một incognito session hoàn toàn tách biệt với cookies, localStorage, sessionStorage, permissions và cache riêng.

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

test('kiểm tra đăng nhập', async ({ context, page }) => {
  // context: BrowserContext mới, chỉ sống trong test này
  // page: Page được tạo từ context này

  await context.addCookies([{
    name: 'session',
    value: 'abc123',
    domain: 'example.com',
    path: '/',
  }]);

  await page.goto('https://example.com/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Hai điểm cần ghi nhớ ngay:

  • page được tạo từ context — chúng thuộc về nhau.
  • Mỗi test có một context riêng — không có state chia sẻ giữa các test trừ khi cố ý (xem bài A.3 và worker-scope fixtures).
3

Source Code Bên Trong Fixture context

Playwright open-source — bạn có thể đọc trực tiếp implementation của fixture context trong packages/playwright/src/worker/testInfo.ts (đơn giản hóa):

// Simplified — implementation thực tế nằm trong Playwright source
context: async ({ browser }, use) => {
  const context = await browser.newContext({
    // áp dụng toàn bộ options từ playwright.config.ts > use: { ... }
    ...contextOptions,
  });
  await use(context);         // fixture "treo" ở đây trong suốt test
  await context.close();      // cleanup sau khi test xong
},

3 điểm quan trọng từ implementation này:

  • Phụ thuộc browser fixture: context cần một Browser instance — fixture browser được giải quyết trước, cùng worker-scope.
  • await use(context): Đây là điểm "treo" — code trước use là setup, code sau là teardown. Nếu quên await use(context) khi override, test sẽ hang vô hạn.
  • context.close() sau use: Tự động đóng tất cả Page bên trong. Mọi resource (video, HAR) được flush tại đây.
4

Vòng Đời Fixture context

Timeline đầy đủ cho một test điển hình (fullyParallel: true, default scope là test):

Worker khởi động
  └── browser fixture setup          ← worker-scope, tạo 1 lần per worker
        └── [TEST BẮT ĐẦU]
              ├── context fixture setup
              │     ├── browser.newContext({ ...configOptions })
              │     └── ← context sẵn sàng
              ├── page fixture setup
              │     └── context.newPage()
              ├── [TEST FUNCTION chạy]   ← bạn viết code ở đây
              ├── page fixture teardown
              │     └── (page được close khi context close)
              └── context fixture teardown
                    └── context.close()   ← đóng mọi page trong context
        └── [TEST KẾT THÚC]
  └── browser fixture teardown        ← worker-scope, đóng khi worker xong
Worker tắt

Vài điểm cần chú ý về thứ tự:

  • context luôn được tạo sau browsertrước page.
  • Khi context.close() được gọi, tất cả page bên trong tự động đóng theo — không cần gọi page.close() thủ công.
  • Mặc định mỗi test có 1 context riêng — state không bị ảnh hưởng bởi test trước dù chạy cùng worker.
5

context vs page — Ai Bao Ai

context là lớp bao ngoài, page là lớp bên trong. Fixture page phụ thuộc context:

BrowserContext (context)
  ├── Page (page fixture — page đầu tiên)
  ├── Page (popup, new tab — tạo thủ công hoặc qua context.on('page'))
  └── Page (có thể tạo thêm qua context.newPage() trong test)

Điều này có nghĩa là cookie, localStorage, network intercepts đặt ở context level sẽ ảnh hưởng đến tất cả page bên trong — kể cả popup:

test('cookie áp dụng cho tất cả page', async ({ context, page }) => {
  // Cookie đặt ở context level
  await context.addCookies([{ name: 'lang', value: 'vi', domain: '.example.com', path: '/' }]);

  await page.goto('https://example.com');
  // Popup mở từ page vẫn nhận cookie 'lang' vì cùng context
  const [popup] = await Promise.all([
    context.waitForEvent('page'),
    page.click('a[target=_blank]'),
  ]);
  // popup cũng có cookie 'lang'
});

Khi muốn mỗi test có nhiều context độc lập (ví dụ: test multi-tenant với 2 user đăng nhập cùng lúc), phải tạo thêm context thủ công bằng browser.newContext() trong test function — fixture context chỉ tạo sẵn 1 context mặc định.

6

3 Mức Configure context

Playwright cung cấp 3 mức để điều chỉnh context fixture, theo thứ tự ưu tiên tăng dần (mức sau override mức trước):

Mức 1 — Project level (playwright.config.ts)

Áp dụng cho mọi test trong project đó. Đây là nơi đặt config mặc định toàn series.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'https://app.example.com',
    locale: 'vi-VN',
    timezoneId: 'Asia/Ho_Chi_Minh',
    viewport: { width: 1280, height: 720 },
    // Tất cả test đều chạy với locale vi-VN và timezone HCM
  },
});

Mức 2 — File / describe level (test.use)

Áp dụng cho file spec hiện tại hoặc block describe. Override project-level config.

// tests/admin.spec.ts
import { test } from '@playwright/test';

// Áp dụng cho toàn file này
test.use({
  storageState: 'playwright/.auth/admin.json',
});

test('admin có thể xoá user', async ({ page }) => {
  // context đã load storageState của admin
  await page.goto('/admin/users');
});

// Hoặc chỉ trong 1 describe
test.describe('A11y checks', () => {
  test.use({
    colorScheme: 'dark',
    forcedColors: 'active',
  });

  test('component hiển thị đúng forced colors', async ({ page }) => {
    // context trong describe này dùng forced colors
  });
});

Mức 3 — Test level (test.extend hoặc override fixture)

Linh hoạt nhất — tạo custom fixture thay thế hoàn toàn context built-in. Dùng khi cần logic phức tạp hơn chỉ truyền options.

// fixtures/geo-context.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({
      geolocation: { latitude: 21.0285, longitude: 105.8542 },
      permissions: ['geolocation'],
      locale: 'vi-VN',
    });
    await use(context);
    await context.close();
  },
});

Quy tắc override: test level wins. Nếu project config set locale: 'en-US' nhưng test level override context fixture với locale: 'vi-VN', context cuối cùng sẽ dùng vi-VN.

7

Pattern Override Fixture context

Pattern chuẩn để override context với test.extend():

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

type MyFixtures = {
  // Không cần khai báo lại context — override built-in
};

export const test = base.extend<MyFixtures>({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({
      // --- emulation ---
      locale: 'vi-VN',
      timezoneId: 'Asia/Ho_Chi_Minh',
      // --- permissions ---
      permissions: ['camera', 'microphone', 'geolocation'],
      geolocation: { latitude: 10.8231, longitude: 106.6297 }, // TP.HCM
      // --- network ---
      extraHTTPHeaders: {
        'X-Internal-Token': process.env.INTERNAL_TOKEN ?? '',
      },
      // --- auth ---
      storageState: process.env.AUTH_STATE_PATH,
    });

    await use(context);
    await context.close(); // BẮT BUỘC — không được bỏ qua
  },
});

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

Sau đó import test từ fixtures thay vì từ @playwright/test:

// tests/my-feature.spec.ts
import { test, expect } from '../fixtures';

test('test với context đã cấu hình sẵn', async ({ page }) => {
  // context đã có locale vi-VN, permissions camera/mic/geo, headers nội bộ
  await page.goto('/feature-needs-camera');
});

Khi override context, fixture page tự động dùng context mới này — không cần override page riêng.

8

Use Case Override Thực Tế

1. Multi-tenant test — mỗi describe = 1 tenant

// tests/multi-tenant.spec.ts
import { test } from '@playwright/test';

test.describe('Tenant A — Premium plan', () => {
  test.use({ storageState: 'playwright/.auth/tenant-a.json' });

  test('premium feature visible', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByTestId('premium-banner')).toBeVisible();
  });
});

test.describe('Tenant B — Free plan', () => {
  test.use({ storageState: 'playwright/.auth/tenant-b.json' });

  test('premium feature hidden', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByTestId('premium-banner')).toBeHidden();
  });
});

Mỗi describe dùng storageState khác nhau — fixture context được tạo với đúng auth state tương ứng.

2. A11y test — emulate accessibility preferences

// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Reduced motion', () => {
  test.use({ reducedMotion: 'reduce' });

  test('animation bị tắt khi reduced motion', async ({ page }) => {
    await page.goto('/landing');
    // CSS media query prefers-reduced-motion: reduce có hiệu lực
    const animClass = await page.locator('.hero-animation').getAttribute('class');
    expect(animClass).not.toContain('animate');
  });
});

test.describe('Forced colors', () => {
  test.use({ forcedColors: 'active' });

  test('text contrast đủ trong forced colors mode', async ({ page }) => {
    await page.goto('/');
    // Kiểm tra không có text bị invisible
  });
});

3. Geo test — emulate nhiều vị trí địa lý

// tests/geo-content.spec.ts
import { test, expect } from '@playwright/test';

const cities = [
  { name: 'Hanoi', lat: 21.0285, lng: 105.8542, locale: 'vi-VN' },
  { name: 'Tokyo', lat: 35.6762, lng: 139.6503, locale: 'ja-JP' },
  { name: 'New York', lat: 40.7128, lng: -74.0060, locale: 'en-US' },
];

for (const city of cities) {
  test.describe(`Geo: ${city.name}`, () => {
    test.use({
      geolocation: { latitude: city.lat, longitude: city.lng },
      permissions: ['geolocation'],
      locale: city.locale,
    });

    test(`site hiển thị nội dung cho ${city.name}`, async ({ page }) => {
      await page.goto('/');
      // site detect geo và render nội dung tương ứng
    });
  });
}
9

Listener Events Qua context

Fixture context expose event API cho phép listen ở tầng context (bao phủ mọi page bên trong):

context.on('page') — bắt popup và new tab

test('bắt popup mở bởi click link ngoài', async ({ context, page }) => {
  await page.goto('https://example.com');

  // Đăng ký listener TRƯỚC khi trigger popup
  const popupPromise = context.waitForEvent('page');
  await page.click('a[target="_blank"]');

  const popup = await popupPromise;
  await popup.waitForLoadState();
  expect(popup.url()).toContain('/external-page');
});

Lưu ý: context.on('page') không phải await được trực tiếp — dùng context.waitForEvent('page') khi cần đợi một popup cụ thể.

context.on('request') — listen tất cả request

test('ghi nhận tất cả API call trong test', async ({ context, page }) => {
  const apiCalls: string[] = [];

  context.on('request', (request) => {
    if (request.url().includes('/api/')) {
      apiCalls.push(`${request.method()} ${request.url()}`);
    }
  });

  await page.goto('/dashboard');
  await page.click('[data-testid="load-more"]');

  // Kiểm tra pagination API được gọi
  expect(apiCalls.some((c) => c.includes('/api/items?page=2'))).toBe(true);
});

Khác với page.on('request'), listener ở context level bắt request từ mọi page trong context — bao gồm cả popup và service worker request.

context.on('serviceworker') — bắt service worker registration

test('service worker được register sau khi app load', async ({ context, page }) => {
  const swPromise = context.waitForEvent('serviceworker');
  await page.goto('/');
  const worker = await swPromise;
  expect(worker.url()).toContain('sw.js');
});

Lưu ý race condition với event listener

Đăng ký listener context.on('page') sau khi action đã xảy ra thường không bắt được event. Luôn đăng ký trước:

// SAI: đăng ký listener sau click — popup có thể đã mở rồi
await page.click('button#open-popup');
const popup = await context.waitForEvent('page'); // có thể timeout

// ĐÚNG: dùng Promise.all để đảm bảo thứ tự
const [popup] = await Promise.all([
  context.waitForEvent('page'),  // đăng ký trước
  page.click('button#open-popup'),  // rồi mới trigger
]);
10

Test Runner vs Library Mode: Ai Tạo context?

Điểm phân biệt cốt lõi giữa Test Runner và Library mode trong việc quản lý BrowserContext:

                     | Test Runner mode            | Library mode
---------------------|-----------------------------|---------------------------------
Ai tạo context       | Fixture tự động             | Dev tự gọi browser.newContext()
Lifecycle            | Managed by fixture          | Dev tự tạo / dùng / đóng
Config               | playwright.config.ts use: { }| Truyền vào newContext(options)
Auto close           | Sau mỗi test                | Dev tự gọi context.close()
Mặc định             | 1 context per test          | Dev quyết định số lượng
Override config      | test.use / test.extend      | Sửa trực tiếp options object

Trong Test Runner, bạn không cần và không nên tạo context thủ công trừ khi test yêu cầu nhiều hơn 1 context (ví dụ: test multi-user đồng thời). Khi đó dùng browser.newContext() trực tiếp trong test và tự đóng:

test('test 2 user đồng thời', async ({ browser, page }) => {
  // page đã là page của user 1 (từ context mặc định)
  await page.goto('/chat');

  // Tạo thêm context riêng cho user 2
  const user2Context = await browser.newContext({
    storageState: 'playwright/.auth/user2.json',
  });
  const user2Page = await user2Context.newPage();
  await user2Page.goto('/chat');

  // Test giao tiếp giữa 2 user...

  await user2Context.close(); // tự close context thêm
  // context mặc định (của user 1) được fixture tự close
});
11

Worker-Scope context (Nâng Cao)

Mặc định context fixture có scope 'test' — tạo và đóng mỗi test. Playwright cho phép đổi scope thành 'worker' để chia sẻ context giữa nhiều test trong cùng worker:

// Chỉ nên dùng khi test không làm thay đổi state (read-only test)
export const test = base.extend({
  context: [
    async ({ browser }, use) => {
      const context = await browser.newContext({
        storageState: 'playwright/.auth/readonly-user.json',
      });
      await use(context);
      await context.close();
    },
    { scope: 'worker' }, // tạo 1 lần per worker, dùng chung nhiều test
  ],
});

Cảnh báo về worker-scope context:

  • Các test chạy tuần tự trong worker sẽ chia sẻ cookies và localStorage. Nếu một test thêm cookie, test sau vẫn thấy cookie đó.
  • Chỉ phù hợp cho test read-only (GET requests, assertions) hoặc khi state được reset thủ công ở beforeEach.
  • Pattern này chi tiết hơn ở bài A.3 (Custom Fixtures với test.extend).
12

Giới Hạn Của Fixture context

  • 1 context per test mặc định: Fixture chỉ tạo sẵn 1 context. Cần nhiều hơn → phải tạo thủ công trong test.
  • Override phải re-implement cleanup: Khi override fixture context, bạn chịu trách nhiệm gọi context.close(). Playwright không tự thêm cleanup vào fixture bạn tự viết.
  • Quên await use(context) → test hang: Fixture không có timeout riêng cho phần setup — nếu thiếu await use(), test chờ mãi.
  • Config từ test.use() không tự động merge vào custom fixture: Khi bạn override hoàn toàn context bằng test.extend(), options từ playwright.config.ts use: {} không được áp dụng nữa — bạn phải đọc và merge thủ công nếu cần.
13

Pitfalls Thường Gặp

1. Override context quên gọi context.close() → memory leak

// SAI: thiếu context.close()
export const test = base.extend({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({ locale: 'vi-VN' });
    await use(context);
    // Quên await context.close() ← context không bao giờ được đóng
  },
});

// ĐÚNG
export const test = base.extend({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({ locale: 'vi-VN' });
    await use(context);
    await context.close(); // bắt buộc
  },
});

Với test suite chạy hàng trăm test, mỗi context bị leak giữ ~50-100 MB RAM. Sau vài chục test, CI runner có thể OOM.

2. Tạo thêm context thủ công trong test, quên close → leak tương tự

// SAI
test('multi-user', async ({ browser, page }) => {
  const ctx2 = await browser.newContext({ storageState: 'user2.json' });
  const page2 = await ctx2.newPage();
  // ...
  // quên await ctx2.close()
});

// ĐÚNG
test('multi-user', async ({ browser, page }) => {
  const ctx2 = await browser.newContext({ storageState: 'user2.json' });
  try {
    const page2 = await ctx2.newPage();
    // ...
  } finally {
    await ctx2.close();
  }
});

3. Override conflict giữa project-level và test-level

Khi override context bằng test.extend(), options trong playwright.config.ts use: {} bị bỏ qua hoàn toàn — browser.newContext() trong fixture của bạn không nhận context options từ config. Test-level fixture luôn wins, nhưng cũng phải tự lo toàn bộ options.

// playwright.config.ts
export default defineConfig({
  use: { locale: 'vi-VN', baseURL: 'https://app.dev' }, // bị bỏ qua nếu bạn override context
});

// Trong fixture override — phải merge thủ công nếu vẫn muốn dùng config
import { test as base } from '@playwright/test';
export const test = base.extend({
  context: async ({ browser, locale, baseURL }, use) => {
    // Nhận locale và baseURL từ config thông qua destructure fixture options
    const context = await browser.newContext({ locale });
    await use(context);
    await context.close();
  },
});

4. context.on('page') không await → race condition

// SAI: listener đăng ký sau khi popup có thể đã mở
await page.click('button#popup');
const popup = await context.waitForEvent('page'); // có thể miss event

// ĐÚNG: dùng Promise.all
const [popup] = await Promise.all([
  context.waitForEvent('page'),  // đăng ký listener trước
  page.click('button#popup'),    // rồi mới trigger
]);
14

Tổng Kết

  • Fixture context inject BrowserContext mới per-test — phụ thuộc browser (worker-scope), được đóng tự động sau mỗi test.
  • Fixture page phụ thuộc context — khi context đóng, tất cả page bên trong cũng đóng theo.
  • 3 mức configure: project-level (use: {} trong config), file/describe-level (test.use()), test-level (test.extend()).
  • Override context với test.extend(): bắt buộc gọi await context.close() sau await use(context).
  • Listener context.on('page' | 'request' | 'serviceworker') bao phủ toàn bộ page trong context — rộng hơn page.on().
  • Worker-scope context cho test read-only: tiết kiệm overhead tạo context, nhưng phải đảm bảo test không làm thay đổi state.
  • 4 pitfall: quên close trong override, quên close context tạo thủ công, config bị bỏ qua khi override, race condition với event listener.
15

Bài Tập Củng Cố

Câu 1. Fixture context phụ thuộc fixture nào? Scope của fixture đó là gì?

Đáp án

Phụ thuộc fixture browser. Fixture browser có scope 'worker' — được tạo một lần khi worker khởi động và đóng khi worker tắt. Nhiều test trong cùng worker dùng chung browser instance nhưng mỗi test có context riêng.

Câu 2. Khi override fixture context bằng test.extend(), điều gì xảy ra nếu quên gọi await use(context)?

Đáp án

Test sẽ hang (chờ vô hạn). Playwright fixture system dựa trên generator pattern — fixture "treo" tại await use() để test có thể chạy. Thiếu await use() nghĩa là fixture kết thúc ngay lập tức mà không cho test chạy, hoặc tùy implementation cụ thể: test có thể hang đến timeout.

Câu 3. Viết đoạn code dùng test.use() để áp dụng storageState khác nhau cho 2 describe trong cùng 1 file spec.

Đáp án
import { test, expect } from '@playwright/test';

test.describe('Admin view', () => {
  test.use({ storageState: 'playwright/.auth/admin.json' });

  test('admin thấy user management', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByRole('link', { name: 'Users' })).toBeVisible();
  });
});

test.describe('Regular user view', () => {
  test.use({ storageState: 'playwright/.auth/user.json' });

  test('user không thấy user management', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByRole('link', { name: 'Users' })).toBeHidden();
  });
});

Câu 4. Nêu sự khác biệt giữa context.on('request')page.on('request').

Đáp án

context.on('request') bắt request từ mọi page bên trong context đó — bao gồm page chính, popup, new tab, và service worker request. page.on('request') chỉ bắt request của page đó mà thôi. Khi cần monitor toàn bộ network activity của test (kể cả popup), dùng context-level listener.

Câu 5. Khi dùng test.extend() để override context, options đặt trong playwright.config.ts use: {} có còn được áp dụng không?

Đáp án

Không được áp dụng tự động. Khi bạn override hoàn toàn context fixture, bạn tự gọi browser.newContext(options) với options mà bạn tự cung cấp — không có merge tự động từ config. Để vẫn nhận config từ project level, phải destructure các fixture option tương ứng (locale, viewport, v.v.) từ tham số fixture và truyền thủ công vào newContext().

16

Bài Tiếp Theo

Bài 3: Built-in Fixture browser — phân tích fixture worker-scope duy nhất trong bộ built-in, cách nó quản lý Browser instance chia sẻ giữa các test trong cùng worker, và khi nào cần truy cập browser fixture trực tiếp.