ก่อนอ่านบทนี้ ลองตอบ:¶
- คุณมี test ที่พังใน CI แต่ผ่านใน local เสมอ — ก่อนจะดู error message คุณควรทำอะไรเป็นอันดับแรก เพื่อให้ได้ข้อมูลเพียงพอสำหรับ debug โดยไม่ต้องรัน CI รอบใหม่?
trace: 'on-first-retry'ทำงานได้อย่างไรในทางปฏิบัติ — และถ้าไม่ตั้งretriesใน config เลย จะเกิดอะไรขึ้นกับ trace?
เฉลย:
- ตรวจสอบก่อนว่าได้ตั้ง
trace: 'on-first-retry'และretries: 1ใน config หรือยัง ถ้าตั้งแล้วให้ download artifact จาก CI แล้วรันnpx playwright show-trace path/to/trace.zipเพื่อดู timeline ทั้งหมด — network request, DOM snapshot, console log, screenshot ทุก action — โดยไม่ต้องแก้ code หรือ re-run ใหม่ ถ้ายังไม่ได้ตั้งต้องเพิ่ม config ก่อน แล้ว push ไป CI อีกรอบ on-first-retryหมายความว่า Playwright จะสร้าง trace เฉพาะ "รอบ retry แรก" ของ test ที่ fail เท่านั้น — ถ้าไม่ตั้งretriesไว้เลย (หรือretries: 0) จะไม่มีรอบ retry เกิดขึ้น trace จึงไม่ถูกสร้างเลยแม้ test จะ fail ดังนั้นต้องตั้งretries: process.env.CI ? 1 : 0คู่กันเสมอ
บทที่ 7: Fixtures — Dependency Injection สำหรับ Tests¶
1. วัตถุประสงค์¶
หลังอ่านบทนี้คุณจะ:
- อธิบายได้ว่า Playwright fixtures แก้ปัญหาอะไรที่
beforeEach/afterEachแก้ไม่ได้ - ใช้ built-in fixtures (
page,browser,context,request,browserName,baseURL) และรู้ว่า scope ของแต่ละตัวคืออะไร - สร้าง custom fixture ด้วย
test.extend<T>()พร้อม setup และ teardown ที่รันแม้ test fail - เลือก fixture scope ระหว่าง test-scoped และ worker-scoped ได้ถูกต้องตามสถานการณ์
- ประกาศ fixture dependency เพื่อให้ Playwright จัดการลำดับ setup/teardown อัตโนมัติ
- ใช้ automatic fixture (
auto: true) สำหรับ cross-cutting concerns เช่น cleanup - รวม fixtures จากหลายไฟล์ด้วย
mergeTests()โดยไม่ conflict กัน - เปรียบเทียบ pattern นี้กับ Robot Framework Suite Setup / Test Setup ได้ชัดเจน
2. ทำไมต้องรู้? (Why)¶
สมมติคุณมี test suite สำหรับระบบ todo ที่ต้องการ:
- ล้าง database ก่อน test แต่ละตัว
- เปิดหน้า
/todosให้พร้อม - หลัง test เสร็จไม่ว่า pass หรือ fail — ต้องล้าง database อีกครั้ง
ด้วย beforeEach/afterEach คุณจะเขียนแบบนี้:
let todoPage: TodoPage;
beforeEach(async ({ page, request }) => {
await request.post('http://localhost:3000/api/reset');
todoPage = new TodoPage(page);
await todoPage.goto();
});
afterEach(async ({ request }) => {
await request.post('http://localhost:3000/api/reset');
});
มีปัญหาอยู่สามอย่าง:
ปัญหาที่ 1 — Coupling: setup และ teardown อยู่คนละที่ ถ้ามีหลาย test file ที่ต้องการ pattern เดียวกัน ต้อง copy-paste ไปทุกไฟล์
ปัญหาที่ 2 — ไม่มี lazy initialization: beforeEach รันทุก test โดยไม่สนว่า test ต้องการ todoPage หรือเปล่า ถ้ามี test ที่ไม่ได้ใช้ todoPage เลย setup ก็ยังรันอยู่
ปัญหาที่ 3 — ไม่มี type-safe dependency injection: todoPage เป็น variable ระดับ module ที่ tests share กัน ถ้า test fail กลางทาง teardown ใน afterEach อาจทำงานกับ state ที่ไม่ถูกต้อง
Fixtures แก้ปัญหาทั้งสามนี้: เขียนครั้งเดียว ใช้ทุกไฟล์ สร้างเฉพาะเมื่อต้องการ และ teardown รันเสมอ
3. เนื้อหาหลัก¶
3.1 Built-in Fixtures¶
Playwright Test มาพร้อม fixtures ที่ใช้ได้ทันทีโดยไม่ต้องสร้างเอง — เหมือน "ของที่แถมมาในกล่อง" (source: https://playwright.dev/docs/test-fixtures)
| Fixture | Type | Scope | คำอธิบาย |
|---|---|---|---|
page |
Page | test | browser tab ที่แยกต่างหากสำหรับ test นี้โดยเฉพาะ (ไม่ share กับ test อื่น) |
context |
BrowserContext | test | browser session ที่แยกต่างหาก — cookie, localStorage ของ test นี้ไม่ปนกับ test อื่น |
browser |
Browser | worker | browser process ทั้งหมด (Chrome/Firefox/WebKit) — share ระหว่าง tests เพื่อประหยัด RAM |
request |
APIRequestContext | test | HTTP client สำหรับเรียก API โดยตรงโดยไม่ต้องเปิด browser |
browserName |
string | worker | ชื่อ browser ที่รันอยู่: chromium, firefox, webkit |
baseURL |
string | worker | URL จาก use.baseURL ใน playwright.config.ts |
สังเกตว่า browser เป็น worker-scoped เพราะการเปิด browser instance ใหม่แต่ละครั้งกิน resource มาก แต่ page และ context เป็น test-scoped เพราะต้องการ isolation ระหว่าง tests
3.2 สร้าง Custom Fixture ด้วย test.extend<T>()¶
Custom fixture คือการขยาย test object ด้วย fixtures ใหม่ที่คุณกำหนดเอง — เหมือน "เพิ่มของแถมเข้าไปในกล่อง" เพื่อให้ tests ของคุณใช้ได้ (source: https://playwright.dev/docs/test-fixtures)
// fixtures/todo.fixtures.ts
// tested: Playwright v1.50+, Node.js 20+
import { test as base, expect } from '@playwright/test';
// ประกาศ type ของ fixtures ที่จะสร้าง
type MyFixtures = {
todoPage: TodoPage;
};
// TodoPage class — Page Object ที่ wrap การทำงานกับ /todos
class TodoPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('http://localhost:3000/todos');
}
async addTodo(text: string) {
await this.page.getByTestId('input-new-todo').fill(text);
await this.page.getByTestId('btn-add-todo').click();
}
}
// ขยาย base test ด้วย fixture ใหม่
export const test = base.extend<MyFixtures>({
todoPage: async ({ page }, use) => {
// ── Setup: รันก่อน test เริ่ม ──
const tp = new TodoPage(page);
await tp.goto();
// ── ส่งค่าให้ test ──
await use(tp); // ← test รันอยู่ตรงนี้
// ── Teardown: รันหลัง test จบ (รันเสมอ แม้ test fail) ──
// ไม่ต้องทำอะไรเพราะ page ถูก reset โดย test-scoped context อยู่แล้ว
},
});
export { expect };
Pattern หลักที่ต้องจำ: setup อยู่ก่อน await use() และ teardown อยู่หลัง await use() — await use(tp) คือจุดที่ test จริงๆ รัน ทุกอย่างก่อน = setup, ทุกอย่างหลัง = teardown Playwright รับประกันว่า teardown จะรันเสมอ ไม่ว่า test จะ pass หรือ fail (source: https://playwright.dev/docs/test-fixtures)
3.3 Fixture Scope¶
Scope หมายถึง "fixture นี้มีชีวิตอยู่นานแค่ไหน" — ถ้าใน Robot Framework คิดว่า test-scoped = Test Setup/Teardown ส่วน worker-scoped = Suite Setup/Teardown
Test-scoped (default): สร้างและ destroy ต่อ test หนึ่ง เหมาะกับ:
- State ที่ต้องการ fresh ทุก test (page, context)
- Data ที่อาจถูกแก้ไขโดย test
Worker-scoped: สร้างครั้งเดียวต่อ worker process (process ที่ Playwright ใช้รัน tests แบบ parallel) และ share กับทุก tests ใน worker นั้น เหมาะกับ: - Expensive setup ที่ไม่เปลี่ยนระหว่าง tests เช่น database connection - Static data ที่ tests ไม่แก้ไข
// fixtures/worker.fixtures.ts
// tested: Playwright v1.50+, Node.js 20+
import { test as base } from '@playwright/test';
type WorkerFixtures = {
dbConnection: DatabaseConnection;
};
export const test = base.extend<{}, WorkerFixtures>({
// Worker-scoped: ใช้ tuple syntax และระบุ { scope: 'worker' }
dbConnection: [async ({}, use) => {
// สร้าง connection ครั้งเดียวต่อ worker
const conn = await DatabaseConnection.connect('postgresql://localhost/testdb');
await use(conn);
// teardown เมื่อ worker หยุดทำงาน
await conn.close();
}, { scope: 'worker' }],
});
"Note the tuple-like syntax for the worker fixture — we have to pass {scope: 'worker'} so that test runner sets this fixture up once per worker." (source: https://playwright.dev/docs/test-fixtures)
3.4 Automatic Fixture (auto: true)¶
Automatic fixture รันทุก test โดยไม่ต้องประกาศใน test function เหมาะกับ cross-cutting concerns เช่น cleanup หรือ logging (source: https://playwright.dev/docs/test-fixtures)
"Automatic fixtures are set up for each test/worker, even when the test does not list them directly."
// fixtures/auto-cleanup.fixtures.ts
// tested: Playwright v1.50+, Node.js 20+
import { test as base } from '@playwright/test';
export const test = base.extend<{ autoCleanDb: void }>({
// auto: true — รันทุก test โดยไม่ต้อง declare ใน test()
autoCleanDb: [async ({ request }, use) => {
// cleanup ก่อน test เสมอ
await request.post('http://localhost:3000/api/reset');
await use();
// cleanup หลัง test เสมอ
await request.post('http://localhost:3000/api/reset');
}, { auto: true }],
});
3.5 Fixture Composition — Dependency Injection¶
Fixtures สามารถ depend on fixtures อื่นได้ Playwright จัดการลำดับ setup/teardown อัตโนมัติ: "When fixture A depends on fixture B: B is always set up before A and torn down after A." (source: https://playwright.dev/docs/test-fixtures)
// fixtures/composed.fixtures.ts
// tested: Playwright v1.50+, Node.js 20+
import { test as base } from '@playwright/test';
import { TodoPage } from '../page-objects/TodoPage';
type ComposedFixtures = {
cleanDb: void;
todoPage: TodoPage;
};
export const test = base.extend<ComposedFixtures>({
// fixture แรก: cleanDb ไม่ depend on fixture อื่น (นอกจาก request ซึ่งเป็น built-in)
cleanDb: async ({ request }, use) => {
await request.post('http://localhost:3000/api/reset');
await use();
await request.post('http://localhost:3000/api/reset');
},
// fixture ที่สอง: todoPage depend on cleanDb
// → Playwright จะ setup cleanDb ก่อน todoPage เสมอ
// → Playwright จะ teardown todoPage ก่อน cleanDb เสมอ
todoPage: async ({ page, cleanDb }, use) => {
// cleanDb รันแล้ว — database สะอาดแน่นอน
const tp = new TodoPage(page);
await tp.goto();
await use(tp);
},
});
สังเกตว่า todoPage ประกาศ cleanDb ใน parameter — แค่นั้นพอ Playwright รู้ว่าต้อง setup cleanDb ก่อน
3.6 mergeTests() — รวม Fixtures จากหลายไฟล์¶
เมื่อ test suite ใหญ่ขึ้น คุณจะแยก fixtures ออกเป็นหลายไฟล์ mergeTests() รวมพวกมันเข้าด้วยกันโดยไม่ conflict (source: https://playwright.dev/docs/test-fixtures)
// fixtures/index.ts
// tested: Playwright v1.50+, Node.js 20+
import { mergeTests } from '@playwright/test';
import { test as authTest } from './auth.fixtures';
import { test as dbTest } from './db.fixtures';
// รวม fixtures ทั้งสอง — tests ที่ import จากไฟล์นี้ใช้ได้ทุก fixture
export const test = mergeTests(authTest, dbTest);
ใช้ใน test file:
// tests/todo.spec.ts
import { test } from '../fixtures';
import { expect } from '@playwright/test';
// ใช้ fixture จากทั้ง auth และ db ได้ในที่เดียวกัน
test('create todo when authenticated', async ({ todoPage, authToken }) => {
// todoPage มาจาก db.fixtures, authToken มาจาก auth.fixtures
await todoPage.addTodo('Buy coffee');
await expect(todoPage.todoCount).toContainText('1');
});
Note: mergeTests() รวม test objects โดยที่ custom matchers ที่สร้างจาก expect.extend() ทำงานอยู่ในแต่ละไฟล์ ถ้าต้องการรวม custom matchers ข้ามหลาย fixture files ให้ใช้ mergeExpects():
// fixtures/matchers.ts
// tested: Playwright v1.50+, Node.js 20+
import { expect as authExpect } from './auth.fixtures'; // มี toBeLoggedIn()
import { expect as todoExpect } from './todo.fixtures'; // มี toHaveTodoCount()
import { mergeExpects } from '@playwright/test';
// รวม custom matchers จากหลาย fixture files
export const expect = mergeExpects(authExpect, todoExpect);
ใช้ใน test:
// tests/combined.spec.ts
import { test } from '../fixtures';
import { expect } from '../fixtures/matchers';
test('verify auth status and todo count', async ({ page }) => {
await expect(page).toBeLoggedIn(); // จาก auth.fixtures
await expect(page).toHaveTodoCount(5); // จาก todo.fixtures
});
mergeExpects() ต้องใช้คู่กับ mergeTests() เสมอ เพื่อให้ test objects และ matchers อยู่ในการควบคุมเดียวกัน
3.7 เปรียบเทียบกับ Robot Framework + Selenium¶
| สิ่งที่ต้องการ | Robot Framework + Selenium | Playwright Fixtures |
|---|---|---|
| Setup ก่อนแต่ละ test | Test Setup keyword ใน Settings |
test-scoped fixture |
| Setup ก่อน test suite | Suite Setup keyword |
worker-scoped fixture |
| Teardown รับประกัน | [Teardown] keyword — อาจ skip ถ้า setup error |
teardown หลัง await use() รันเสมอ |
| Dependency ระหว่าง setup | เขียนเองใน keyword — ไม่มี DI จริงๆ | ประกาศ parameter ใน fixture — Playwright จัดการให้ |
| Reuse ข้าม test files | Resource file + Import | export จากไฟล์เดียวกัน + import ในทุก test file |
| Shared state ที่ safe | Global variable ใน Resource — race condition ถ้า parallel | worker-scoped fixture — isolated ต่อ worker |
4. ตัวอย่าง 3 ระดับ¶
Beginner: Built-in Fixtures พื้นฐาน¶
สถานการณ์: ทำความเข้าใจ request fixture โดยทดสอบ API ของ demo app โดยตรง
// tests/beginner-fixtures.spec.ts
// tested: Playwright v1.50+, Node.js 20+
import { test, expect } from '@playwright/test';
// request fixture คือ APIRequestContext ที่ใช้ HTTP request ได้โดยตรง
// ไม่ต้องเปิด browser — เหมาะสำหรับ test data setup และ API verification
test('built-in request fixture — สร้าง todo ผ่าน API', async ({ request }) => {
// ล้าง state ก่อน test
await request.post('http://localhost:3000/api/reset');
// สร้าง todo ผ่าน API โดยตรง
const createRes = await request.post('http://localhost:3000/api/todos', {
data: { text: 'Buy coffee beans' }
});
expect(createRes.ok()).toBe(true);
const todo = await createRes.json();
expect(todo.text).toBe('Buy coffee beans');
expect(todo.id).toBeDefined();
expect(todo.completed).toBe(false);
});
// page fixture คือ browser tab ที่ isolated ต่อ test นี้โดยเฉพาะ
// Playwright สร้าง page ใหม่ทุก test — ไม่แชร์ state กับ test อื่น
test('built-in page fixture — navigate และ verify', async ({ page }) => {
await page.goto('http://localhost:3000');
// session badge แสดง "Not logged in" เมื่อยังไม่ login
await expect(page.getByTestId('session-badge')).toContainText('Not logged in');
// title ควรถูกต้อง
await expect(page).toHaveTitle(/Playwright Course App/);
});
// browserName fixture — รู้ว่ากำลัง test บน browser อะไร
test('built-in browserName fixture', async ({ page, browserName }) => {
await page.goto('http://localhost:3000');
// log browser name ให้เห็นใน test output
console.log(`Running on: ${browserName}`);
// ใช้ filter test ตาม browser ได้
test.skip(browserName === 'webkit', 'ข้าม Safari เพราะ environment นี้ไม่รองรับ');
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});
Output ที่คาดหวัง:
Running 3 tests using 1 worker
✓ built-in request fixture — สร้าง todo ผ่าน API (312ms)
✓ built-in page fixture — navigate และ verify (445ms)
✓ built-in browserName fixture (389ms)
- built-in browserName fixture (skipped on webkit)
3 passed (1.2s)
Intermediate: Fixture Composition กับ E-commerce Checkout¶
สถานการณ์: ทีม QA ต้องการ test ระบบ checkout ของ e-commerce site ที่ต้องการ (1) database สะอาด (2) user login แล้ว (3) สินค้าใน cart ก่อนแต่ละ test — ต้องเขียน fixtures ที่ compose กันได้
// fixtures/checkout.fixtures.ts
// tested: Playwright v1.50+, Node.js 20+
import { test as base } from '@playwright/test';
// สมมติว่า demo app มี /api/cart endpoint (ใช้เป็น conceptual example)
// ในบทนี้ใช้ /api/todos แทนเพื่อให้ทดสอบกับ demo app จริงได้
type CheckoutFixtures = {
cleanState: void; // ล้าง state ทั้งหมด
loggedInPage: Page; // page ที่ login แล้ว
preparedTodos: string[]; // todos ที่ถูกสร้างไว้แล้ว
};
export const test = base.extend<CheckoutFixtures>({
// Fixture 1: cleanState — ล้างก่อนและหลัง test
cleanState: async ({ request }, use) => {
await request.post('http://localhost:3000/api/reset');
await use();
await request.post('http://localhost:3000/api/reset');
},
// Fixture 2: loggedInPage — depend on cleanState
// Playwright setup cleanState ก่อน loggedInPage อัตโนมัติ
loggedInPage: async ({ page, cleanState }, use) => {
// login ผ่าน UI
await page.goto('http://localhost:3000/login');
await page.getByTestId('input-username').fill('admin');
await page.getByTestId('input-password').fill('admin123');
await page.getByTestId('btn-login').click();
// รอให้ login เสร็จ — badge จะเปลี่ยนจาก "Not logged in" เป็น "Logged in as: admin"
await expect(page.getByTestId('session-badge')).toContainText('Logged in as: admin');
await use(page);
},
// Fixture 3: preparedTodos — depend on cleanState เพื่อสร้าง test data
preparedTodos: async ({ request, cleanState }, use) => {
// สร้าง todos ผ่าน API (เร็วกว่า UI)
const items = ['Review requirements', 'Write test cases', 'Run regression'];
for (const text of items) {
await request.post('http://localhost:3000/api/todos', {
data: { text }
});
}
await use(items);
// teardown จัดการโดย cleanState fixture ที่ depend on อยู่แล้ว
},
});
// tests/intermediate-fixtures.spec.ts
// tested: Playwright v1.50+, Node.js 20+
import { test } from '../fixtures/checkout.fixtures';
import { expect } from '@playwright/test';
test('todo list แสดง prepared items ถูกต้อง', async ({ loggedInPage, preparedTodos }) => {
const page = loggedInPage;
await page.goto('http://localhost:3000/todos');
// verify ว่า todos ที่สร้างไว้ผ่าน API ปรากฏบน UI
for (const text of preparedTodos) {
await expect(page.getByTestId('todo-list')).toContainText(text);
}
// verify count ถูกต้อง
await expect(page.getByTestId('todo-count')).toContainText(`${preparedTodos.length}`);
});
test('admin สามารถ add todo ได้เมื่อ login แล้ว', async ({ loggedInPage }) => {
const page = loggedInPage;
await page.goto('http://localhost:3000/todos');
await page.getByTestId('input-new-todo').fill('New task from fixture test');
await page.getByTestId('btn-add-todo').click();
await expect(page.getByTestId('todo-list')).toContainText('New task from fixture test');
});
จุดสำคัญของตัวอย่างนี้:
- loggedInPage และ preparedTodos ต่าง depend on cleanState — Playwright setup cleanState แค่ครั้งเดียวต่อ test ไม่ใช่สองครั้ง
- teardown ลำดับ: loggedInPage teardown → preparedTodos teardown → cleanState teardown (reverse ของ setup)
- test แรกใช้ทั้งสอง fixtures, test ที่สองใช้แค่ loggedInPage — Playwright สร้างเฉพาะที่ต้องการ
Advanced: Worker-Scoped Fixture เทียบกับ Test-Scoped — Performance Tradeoffs¶
สถานการณ์: ทีม senior ต้องออกแบบ test infrastructure สำหรับ test suite 200 tests ที่ต้องการ database seed data เหมือนกันทุก test แต่มีบาง test ที่แก้ไข state ด้วย ต้องเลือก scope ให้ถูกต้องเพื่อ balance ระหว่าง performance และ isolation
// fixtures/performance-comparison.fixtures.ts
// tested: Playwright v1.50+, Node.js 20+
import { test as base } from '@playwright/test';
// ── Approach A: Test-scoped (Safe แต่ช้า) ──
// สร้าง database seed ใหม่ทุก test = 200 × 300ms = 60 วินาที overhead
export const testScopedTest = base.extend<{ seedData: SeedData }>({
seedData: async ({ request }, use) => {
// สร้าง seed data ทุก test — isolate อย่างสมบูรณ์
await request.post('http://localhost:3000/api/reset');
await request.post('http://localhost:3000/api/todos', {
data: { text: 'Seed Todo 1' }
});
await request.post('http://localhost:3000/api/todos', {
data: { text: 'Seed Todo 2' }
});
await use({ count: 2 });
await request.post('http://localhost:3000/api/reset');
},
});
// ── Approach B: Worker-scoped (เร็ว แต่ต้องระวัง mutation) ──
// สร้าง seed data ครั้งเดียวต่อ worker = ประหยัด setup time
export const workerScopedTest = base.extend<{}, { sharedSeedData: SeedData }>({
sharedSeedData: [async ({ browser }, use, workerInfo) => {
// Worker index บอกว่านี่คือ worker ที่เท่าไหร่ใน parallel run
const workerIndex = workerInfo.workerIndex;
console.log(`Worker ${workerIndex}: setting up shared seed data`);
// สร้าง context แยกสำหรับ setup (ไม่กระทบ test contexts)
const context = await browser.newContext();
const page = await context.newPage();
// ใช้ API request เพื่อ setup data — เร็วกว่า UI
const req = context.request;
await req.post('http://localhost:3000/api/reset');
await req.post('http://localhost:3000/api/todos', {
data: { text: 'Shared Seed 1' }
});
await context.close();
// share ค่านี้กับทุก tests ใน worker นี้
await use({ count: 1, workerIndex });
}, { scope: 'worker' }],
});
// tests/advanced-fixtures.spec.ts
// tested: Playwright v1.50+, Node.js 20+
import { workerScopedTest as test } from '../fixtures/performance-comparison.fixtures';
import { expect } from '@playwright/test';
// ทุก test ใน worker เดียวกันได้รับ sharedSeedData object เดียวกัน
test('read-only test — ใช้ shared data ได้ปลอดภัย', async ({ page, sharedSeedData }) => {
await page.goto('http://localhost:3000/todos');
// แค่อ่าน — ไม่แก้ไข state
const count = await page.getByTestId('todo-count').textContent();
expect(parseInt(count || '0')).toBeGreaterThanOrEqual(sharedSeedData.count);
});
// ⚠️ Anti-pattern: test ที่แก้ไข worker-scoped state ทำให้ tests อื่น interfere
// ถ้าจำเป็นต้องแก้ไข state ให้ใช้ test-scoped fixture แทน
test('mutation test — ต้องใช้ test-scoped ถ้าต้องการ isolation', async ({
page,
sharedSeedData
}, testInfo) => {
// Diagnosis: ถ้า test นี้รันก่อน "read-only test" ใน worker เดียวกัน
// แล้ว add todo ไป — read-only test จะเห็น count มากกว่า expected
// Solution: ใช้ test-scoped fixture สำหรับ tests ที่ mutate state
test.skip(
true,
'Example: ถ้า test นี้ต้องแก้ state — ให้ใช้ testScopedTest แทน workerScopedTest'
);
});
เมื่อใช้แต่ละ scope:
| สถานการณ์ | Scope | เหตุผล |
|---|---|---|
| Test data ที่ tests แก้ไข | test-scoped | ต้องการ isolation สมบูรณ์ |
| Static reference data (dropdown options, config) | worker-scoped | ไม่เปลี่ยน — share ได้ปลอดภัย |
| Browser / Database connection | worker-scoped | Expensive setup — ประหยัด resource |
| Authentication token ที่ expire ได้ | test-scoped | Token อาจ expire ระหว่าง test run |
| User session ที่แต่ละ test ต้องการต่างกัน | test-scoped | State แตกต่างกัน |
5. Common Mistakes¶
1. ใช้ beforeEach แทน fixture เมื่อต้องการ reuse ข้ามไฟล์
❌
// tests/todo.spec.ts
let todoPage: TodoPage;
beforeEach(async ({ page }) => {
todoPage = new TodoPage(page);
await todoPage.goto();
});
// tests/admin.spec.ts — copy-paste setup เดิม ทุกครั้ง
let todoPage: TodoPage;
beforeEach(async ({ page }) => {
todoPage = new TodoPage(page);
await todoPage.goto();
});
✅
// fixtures/todo.fixtures.ts — เขียนครั้งเดียว
export const test = base.extend<{ todoPage: TodoPage }>({
todoPage: async ({ page }, use) => {
const tp = new TodoPage(page);
await tp.goto();
await use(tp);
},
});
// tests/todo.spec.ts AND tests/admin.spec.ts — import เดียวกัน
import { test } from '../fixtures/todo.fixtures';
เหตุผล: beforeEach ไม่มี lazy initialization — รันทุก test แม้ test นั้นไม่ต้องการ todoPage ส่วน fixture สร้างเฉพาะเมื่อ test ประกาศว่าต้องการ (source: https://playwright.dev/docs/test-fixtures)
2. ลืม await use() ใน fixture
❌
export const test = base.extend<{ myFixture: string }>({
myFixture: async ({}, use) => {
const data = 'some setup';
// ลืม await use(data) — test จะรอจนหมด timeout
},
});
✅
export const test = base.extend<{ myFixture: string }>({
myFixture: async ({}, use) => {
const data = 'some setup';
await use(data); // ← ต้องมีเสมอ
},
});
เหตุผล: use() คือ "จุดที่ test รัน" ถ้าไม่ call use() test จะไม่เริ่มและ hang จนหมด timeout โดยไม่มี error message ที่อธิบายสาเหตุ (source: https://playwright.dev/docs/test-fixtures)
3. ใช้ test-scoped fixture กับ expensive setup ที่รันซ้ำเปล่า
❌
// สร้าง database schema ใหม่ทุก test — ช้ามาก (ถ้า 100 tests = 100 × 2 วินาที)
export const test = base.extend<{ dbSchema: void }>({
dbSchema: async ({}, use) => {
await runMigrations(); // 2 วินาที per test
await use();
await dropAllTables();
},
});
✅
// Worker-scoped: run migrations ครั้งเดียวต่อ worker
export const test = base.extend<{}, { dbSchema: void }>({
dbSchema: [async ({}, use) => {
await runMigrations(); // 2 วินาที per worker (ไม่ใช่ per test)
await use();
await dropAllTables();
}, { scope: 'worker' }],
});
เหตุผล: Schema ไม่เปลี่ยนระหว่าง tests — worker-scoped ประหยัด setup time ได้มากโดยไม่กระทบ isolation (source: https://playwright.dev/docs/test-fixtures)
4. แก้ไข shared state ใน worker-scoped fixture
❌
// worker-scoped fixture ที่ tests ทุกตัวใช้ร่วมกัน
sharedCart: [async ({ browser }, use) => {
const cart = new Cart();
await use(cart);
}, { scope: 'worker' }]
// test A แก้ไข cart — test B ที่รันหลังใน worker เดียวกันได้รับ state ที่เปลี่ยนแล้ว
test('add item to cart', async ({ sharedCart }) => {
sharedCart.add('item1'); // ← แก้ไข shared state
});
test('cart starts empty', async ({ sharedCart }) => {
// FAIL: cart มี 'item1' อยู่แล้วจาก test ก่อน
expect(sharedCart.items).toHaveLength(0);
});
✅
// ใช้ test-scoped fixture สำหรับ state ที่ tests แก้ไข
cart: async ({}, use) => {
const cart = new Cart();
await use(cart);
// cart ใหม่ทุก test — ไม่ interfere กัน
},
เหตุผล: worker-scoped fixture เหมาะกับ "read-only shared resources" เท่านั้น ถ้า test แก้ไข state — ใช้ test-scoped เสมอ (source: https://playwright.dev/docs/test-fixtures)
6. สรุปบท + Retrieval Questions¶
ในบทนี้คุณได้เรียน:
- Built-in fixtures —
page/context/requestเป็น test-scoped (fresh ทุก test),browserเป็น worker-scoped (share เพื่อประหยัด resource) test.extend<T>()— สร้าง custom fixture ด้วย setup ก่อนawait use()และ teardown หลังawait use()ที่รันเสมอ- Test-scoped vs Worker-scoped — เลือกตาม "mutation risk" ของ state นั้น — ถ้า test แก้ไข state ให้ใช้ test-scoped เสมอ
- Automatic fixture (
auto: true) — รันทุก test โดยไม่ต้องประกาศใน test function เหมาะกับ cleanup และ logging - Fixture composition — ประกาศ dependency ใน parameter → Playwright จัดการลำดับ setup/teardown อัตโนมัติ
mergeTests()— รวม fixtures จากหลายไฟล์โดยไม่ conflict
ก่อนอ่านบทถัดไป ลองตอบคำถามเหล่านี้ด้วยตัวเองก่อน (ไม่ต้อง scroll ขึ้นไปดูเฉลย):
คำถามที่ 1: คุณมี fixture loginAsAdmin ที่ต้องการให้รันก่อน todoPage fixture เสมอ และ todoPage ต้องการ user ที่ login แล้ว — จะเขียน dependency ระหว่าง fixtures สองตัวนี้อย่างไร? เขียน type declaration และ fixture body ให้ถูกต้อง
คำถามที่ 2: test suite มี 50 tests ที่ทุกตัวใช้ dbConnection fixture แต่ค่าเฉลี่ย setup time คือ 800ms ต่อครั้ง ทำให้ test suite ช้ามาก — จะแก้อย่างไรโดยใช้ fixture scope ที่เหมาะสม? มีข้อควรระวังอะไรบ้าง?
คำถามที่ 3: code นี้มีปัญหาอะไร และ test จะเกิดอะไรขึ้นเมื่อรัน?
const test = base.extend<{ prepared: void }>({
prepared: async ({ request }, use) => {
await request.post('http://localhost:3000/api/reset');
// --- หยุดตรงนี้ ---
},
});
test('add todo', async ({ page, prepared }) => {
await page.goto('http://localhost:3000/todos');
await page.getByTestId('input-new-todo').fill('Test todo');
});
ดูเฉลย
**คำถามที่ 1:**type Fixtures = {
loginAsAdmin: void;
todoPage: TodoPage;
};
const test = base.extend<Fixtures>({
loginAsAdmin: async ({ page }, use) => {
await page.goto('http://localhost:3000/login');
await page.getByTestId('input-username').fill('admin');
await page.getByTestId('input-password').fill('admin123');
await page.getByTestId('btn-login').click();
await expect(page.getByTestId('session-badge')).toBeVisible();
await use();
},
// ประกาศ loginAsAdmin ใน parameter = "ต้อง setup loginAsAdmin ก่อนฉัน"
todoPage: async ({ page, loginAsAdmin }, use) => {
const tp = new TodoPage(page);
await tp.goto();
await use(tp);
},
});