บทที่ 7 · Part 2 — Test Architecture
Test Structure and Fixtures
จัด arrange-act-assert, steps, hooks และ fixtures ให้ dependency ชัด พร้อม isolation ต่อ test
Test Structure and Fixtures
เมื่อ suite เล็ก ทุก spec สามารถ login, seed data และสร้าง helper ของตัวเองได้ แต่ไม่นาน setup 3 แบบ จะให้ state ต่างกันและ failure report บอกเพียงว่า test timeout Fixture ที่ออกแบบดีทำ dependency ให้เห็นใน signature และรับประกัน teardown โดยไม่เปลี่ยน test ให้เป็น magic
จบบทนี้คุณจะ
จัด test เป็น arrange-act-assert กับ test.step() เลือก hooks หรือ fixtures ตาม lifecycle และสร้าง
typed fixtures ที่ isolated, composable และปิด resource เสมอ
1 Behavior ต่อ Test
ชื่อ test เป็นบรรทัดแรกของ report ให้เขียนเป็น behavior statement พร้อม ID ที่ค้นหาได้:
test('BOOK-RESERVE-001 - reserves an available seat for the learner', async ({ page }) => {
// arrange
// act
// assert
})
1 behavior อาจมีหลาย assertions ที่พิสูจน์ผลเดียวกัน เช่น confirmation title และ reservation id แต่ไม่ควรรวม validation error, permission error และ cancellation ไว้ test เดียว การแตก test ทำให้ชื่อ failure ชี้ defect และ runner parallelize ได้
Steps ทำ Report เป็น Narrative
ใช้ test.step() ต่อ phase หรือ logical assertion group ไม่ใช่ทุกบรรทัด:
test('BOOK-RESERVE-001 - reserves an available seat for the learner', async ({
page,
workshop,
}) => {
await test.step('Open the available workshop', async () => {
await page.goto(`/workshops/${workshop.slug}`)
await expect(page.getByRole('heading', { name: workshop.title })).toBeVisible()
})
await test.step('Reserve one seat', async () => {
await page.getByRole('button', { name: 'Reserve seat' }).click()
})
await test.step('Show the confirmed reservation', async () => {
await expect(page.getByRole('heading', { name: 'Reservation confirmed' })).toBeVisible()
await expect(page.getByText(workshop.title, { exact: true })).toBeVisible()
})
})
ชื่อ steps บอกสิ่งที่เกิด ไม่ใช่ Step 1 หรือชื่อ method ภายใน หาก phase ยาวมากจนต้อง nested หลายระดับ
นั่นอาจเป็นสัญญาณว่า action/page object หรือ test ควรถูกแบ่ง
Hook เหมาะกับ Shared Navigation ไม่ใช่ Hidden Data
beforeEach เหมาะกับการนำทุก test ใน describe ไป state เริ่มเดียวกัน:
test.describe('Workshop search', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/workshops')
await expect(page.getByRole('heading', { name: 'Workshops' })).toBeVisible()
})
test('finds a workshop by topic', async ({ page }) => {
// ...
})
})
อย่าให้ beforeEach สร้างข้อมูลจำนวนมากที่บาง test ไม่ใช้ หรือซ่อนตัวแปร mutable ไว้นอก test
ถ้า setup เป็น capability ที่หลายไฟล์ต้องใช้ ให้ fixture ระบุ dependency ชัดกว่า
Typed Fixture พร้อม Teardown
import { test as base } from '@playwright/test'
type Workshop = { id: string; slug: string; title: string }
type Fixtures = {
workshop: Workshop
}
export const test = base.extend<Fixtures>({
workshop: async ({ request }, use) => {
const title = `Fixture workshop ${crypto.randomUUID()}`
const created = await request.post('/api/test-support/workshops', {
data: { title, capacity: 10, status: 'OPEN' },
})
if (!created.ok()) throw new Error(`Workshop setup failed: ${created.status()}`)
const workshop = (await created.json()) as Workshop
await use(workshop)
const removed = await request.delete(`/api/test-support/workshops/${workshop.id}`)
if (!removed.ok() && removed.status() !== 404) {
throw new Error(`Workshop teardown failed: ${removed.status()}`)
}
},
})
export { expect } from '@playwright/test'
code ก่อน use คือ setup หลัง use คือ teardown ซึ่งทำงานแม้ test fail Fixture ควรสร้าง resource
ที่ test ขอจริงและคืน typed value หลีกเลี่ยง fixture ใหญ่ชื่อ everything ที่ login, seed 20 rows,
mock network และเปลี่ยน clock เพราะ dependency ของแต่ละ test จะมองไม่เห็น
Fixture Scope และ Worker State
default fixture เป็น test-scoped ทำใหม่ต่อ test เหมาะกับ mutable state Worker-scoped fixture ถูกสร้างครั้งเดียว ต่อ worker และเหมาะกับ resource ราคาแพงที่ปลอดภัยต่อการแชร์ เช่น read-only API client หรือ account pool slot ไม่ควรแชร์ workshop ที่ tests แก้ไข เพราะ parallel execution จะทำให้ state แข่งกัน
ถ้าต้องสร้าง account ต่อ worker ใช้ workerInfo.workerIndex เป็น namespace แต่ account แต่ละตัวต้องมี data
isolation และ cleanup policy ชัด Worker index ไม่แทน unique id ต่อ resource
Built-in Fixtures คือ Dependency Injection
Playwright ให้ page, context, browser, request และ testInfo ผ่าน fixture system อยู่แล้ว
test ระบุเท่าที่ใช้:
test('API health contract', async ({ request }) => {
const response = await request.get('/api/health')
expect(response.status()).toBe(200)
})
page ใหม่อยู่ใน isolated BrowserContext ต่อ test โดย default Cookie/local storage ของ test หนึ่งจึงไม่ไหล
ไปอีก test อย่าประกาศ page/global context เองนอก fixture จนทำลาย isolation นี้
Serial Mode เป็นข้อยกเว้น
test.describe.serial() ทำให้ tests ในกลุ่มรันตามลำดับและ skip หลังรายการแรก fail ใช้เมื่อ flow เองต้องต่อเนื่อง
และแยกไม่ได้จริง เช่น migration rehearsal หลาย phase แต่ create → edit → delete ธรรมดาควรสร้าง resource
ต่อ test ผ่าน API แล้วรันอิสระ การพึ่ง order ทำให้ rerun test เดี่ยวไม่ได้และ failure cascade
Tags และ Annotations
test.describe('Reservations', { tag: ['@ui', '@reservations'] }, () => {
test('reserves one seat', { tag: ['@smoke', '@positive'] }, async ({ page }) => {
// ...
})
})
tag เป็น suite selection contract เช่น --grep @smoke ให้ใช้ vocabulary เล็ก มี owner และความหมายคงที่
test.skip ใช้เมื่อ environment/capability ไม่พร้อมโดยมีเหตุผล test.fixme ใช้ defect ที่ยืนยันแล้วพร้อม issue
ไม่ใช้ทั้ง 2เพื่อซ่อน flaky test
รายการตรวจสอบ
- Test name อ่านเป็น behavior และ 1 test fail ด้วยเหตุผลหลัก 1 เรื่อง
- Steps แบ่ง phase ที่มีความหมายใน report
- Hooks ไม่ซ่อน data ที่ test ไม่ได้ใช้
- Fixture dependency เห็นใน signature และ teardown หลัง
use - Mutable data เป็น test-scoped และ unique
- Serial, skip, fixme และ retries มีเหตุผลตรวจสอบได้
สรุปบทนี้
Fixture เป็น lifecycle-managed dependency ไม่ใช่ถุง helper ส่วน steps เปลี่ยน execution log ให้เป็นเรื่องราว เมื่อ test ระบุ data/page/API ที่ต้องใช้ชัด suite จะ parallelize และ debug ได้ง่ายขึ้น