บทที่ 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 หลายขั้นที่เป็นความหมายเดียว ย้ายเป็น actions method
  • assertion หลายข้อที่พิสูจน์ screen state เดียว ย้ายเป็น assertions method

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 ให้เล็ก

อ่านเพิ่มเติม