บทที่ 8 · Part 2 — Test Architecture
Page Objects and Test Interfaces
ออกแบบ page object ที่รวม elements, actions และ assertions โดยไม่ซ่อน behavior จน test อ่านไม่รู้เรื่อง
Page Objects and Test Interfaces
เมื่อปุ่ม Create Workshop ถูกใช้ใน 10 specs การแก้ accessible name ควรเปลี่ยนที่เดียว แต่ถ้า page object
มี method doEverything() ที่ซ่อน navigation, API seed และ 5 assertions คนอ่านจะไม่รู้ว่าแต่ละ test พิสูจน์อะไร
Page Object Model มีคุณค่าเมื่อสร้าง interface ที่ลึกและชัด ไม่ใช่เพียงย้าย selector ไปไฟล์ใหญ่
จบบทนี้คุณจะ
สร้าง page object แบบ function factory ที่มี elements, actions, assertions ตัดสินว่าอะไรควรย้าย
เข้า object และหลีกเลี่ยง abstraction ที่ผูกทุกหน้ากับ base class เดียว
เมื่อไรควรสร้าง Page Object
Course convention นี้ใช้กติกา:
- locator ใช้มากกว่า 1 ครั้ง ย้ายเข้า
elements - interaction หลายขั้นที่เป็นความหมายเดียว ย้ายเป็น
actionsmethod - assertion หลายข้อที่พิสูจน์ screen state เดียว ย้ายเป็น
assertionsmethod
test สั้นที่มี locator ใช้ครั้งเดียวไม่จำเป็นต้องมี object ตั้งแต่วันแรก เมื่อ pattern เกิดจริงค่อย extract เพื่อไม่สร้าง API จากการเดา
โครงไฟล์:
src/pom/workshop-catalog-pom.ts
src/pom/workshop-form-pom.ts
src/pom/reservation-pom.ts
แบ่งตาม cohesive screen/capability ไม่ทำ app-pom.ts ที่รู้ทุกหน้า
โครงสร้าง Elements, Actions, Assertions
import { expect, type Page } from '@playwright/test'
type WorkshopCatalogParams = { page: Page }
export type WorkshopCatalogReturn = ReturnType<typeof createWorkshopCatalog>
const createWorkshopCatalog = ({ page }: WorkshopCatalogParams) => {
const elements = {
pageHeading: page.getByRole('heading', { name: 'Workshops' }),
searchField: page.getByRole('searchbox', { name: 'Search workshops' }),
resultList: page.getByRole('region', { name: 'Search results' }),
workshopCard: (title: string) =>
page.getByRole('article').filter({
has: page.getByRole('heading', { name: title, exact: true }),
}),
}
const navigate = async () => {
await page.goto('/workshops')
await expect(elements.pageHeading).toBeVisible()
}
const search = async (query: string) => {
await elements.searchField.fill(query)
}
const reserve = async (title: string) => {
await elements.workshopCard(title).getByRole('button', { name: 'Reserve seat' }).click()
}
const verifyWorkshopVisible = async (title: string) => {
await expect(elements.workshopCard(title)).toBeVisible()
}
const verifyNoResults = async () => {
await expect(elements.resultList).toContainText('No workshops found')
}
return {
elements,
actions: { navigate, search, reserve },
assertions: { verifyWorkshopVisible, verifyNoResults },
}
}
export { createWorkshopCatalog }
Top-level shape คงที่ช่วยให้คนอ่านเดา interface ได้ Action ใช้ imperative verb เช่น navigate, search,
reserve Assertion ใช้ชื่อที่บอก state เช่น verifyNoResults Dynamic locator เป็น function ใน elements
ไม่ export page ซ้ำเพราะ fixture มีอยู่แล้ว
Test ยังต้องเล่า Behavior
test('SEARCH-001 - finds a workshop by topic', async ({ page }) => {
const catalog = createWorkshopCatalog({ page })
await test.step('Open the workshop catalog', () => catalog.actions.navigate())
await test.step('Search by topic', () => catalog.actions.search('browser testing'))
await test.step('Show the matching workshop', () =>
catalog.assertions.verifyWorkshopVisible('Reliable Browser Tests'),
)
})
POM ลดรายละเอียด interaction แต่ชื่อ step และ behavior ยังอยู่ใน spec ถ้า test เหลือเพียง
await app.runScenario('SEARCH-001') report จะไม่อธิบาย arrange/act/assert และ reuse ยาก
Assertion อยู่ที่ไหน
Assertion ที่พิสูจน์ reusable screen state อยู่ใน object เช่น form success toast หรือ validation summary Assertion เฉพาะ behavior คงไว้ใน spec:
await catalog.assertions.verifyWorkshopVisible(workshop.title)
await expect(page).toHaveURL(`/workshops/${workshop.slug}`)
ไม่ให้ action assert ทุกอย่างอัตโนมัติ เช่น reserve() ไม่ควรยืนยัน confirmation เสมอ เพราะ negative test
อาจใช้ action เดียวกันแล้วคาด full/waitlist error แต่ navigation action ควรตรวจ page identity หลัง goto
เพื่อ fail ใกล้ต้นเหตุหาก route ผิด
Component Objects แทน Inheritance
Header, toast, date picker หรือ data table ที่ใช้หลายหน้าอาจเป็น component object:
const createToast = (page: Page) => ({
elements: {
status: page.getByRole('status'),
},
assertions: {
verifySuccess: async (message: string) => {
await expect(page.getByRole('status')).toContainText(message)
},
},
})
compose toast เข้า workshop form object ดีกว่า BasePage ที่มี navigation, toast, table, dialog และ auth
สำหรับทุกหน้า Inheritance ทำให้ subclass เห็น methods ที่ใช้ไม่ได้และเปลี่ยน base กระทบทั่ว suite
POM เป็น Test Interface ไม่ใช่ Product API
เก็บ route/API setup แยกใน API client หรือ fixture Page object รับผิดชอบ interaction กับ page ไม่ควรเรียก database โดยตรงหรืออ่าน environment ถ้าต้อง seed workshop ให้ fixture สร้าง แล้วส่ง title/slug เข้า action อย่างชัดเจน
ห้ามเก็บ mutable test data ใน object ระหว่าง tests Factory สร้าง object ต่อ Page; locators lazy resolve
เมื่อ action/assertion ใช้ จึงไม่ต้อง cache element handle
Review Smells
elementsมี CSS selectors หรือ.nth()จำนวนมาก- method ชื่อ
process,execute,handleไม่สื่อ intent - action มี branching ตาม test case จนเป็น test runner อีกชั้น
- assertion รับ boolean ว่าควร success หรือ fail แทน methods แยกที่อ่านรู้เรื่อง
- base class รู้ทุก component
- page object seed data, login และเปลี่ยน global config โดยซ่อนจาก test
- methods คืน
anyหรือ swallow errors
รายการตรวจสอบ
- Reused locators อยู่ใน
elements - Multi-step interactions เป็น imperative
actions - Reusable state checks เป็น descriptive
assertions - Dynamic locators เป็น functions ที่รับ domain identity
- POM แบ่งตาม cohesive screen/capability และ compose components
- Test/steps ยังบอก behavior โดยไม่ซ่อนใน scenario method
สรุปบทนี้
Page object ที่ดีซ่อน DOM mechanics แต่เปิดเผย domain interaction มันลด change surface โดยไม่ย้าย business story ออกจาก spec ใช้ abstraction เมื่อ reuse เกิดจริงและรักษา interface ให้เล็ก