บทที่ 12 · Part 3 — UI and API Capabilities

API and Contract Testing

ใช้ APIRequestContext ตรวจ status, schema, business rule, error contract และ side effect อย่างเป็นชั้น

API and Contract Testing

Response 201 ไม่ได้แปลว่า API ถูกต้องเสมอ Body อาจขาด field ที่ frontend ใช้, error อาจคืน code ผิด หรือ request ซ้ำอาจสร้าง reservation 2 รายการ API test ที่ดีตรวจ contract เป็นชั้นและยืนยัน side effect ที่สำคัญโดยไม่ซ่อน response ใน helper

จบบทนี้คุณจะ

ใช้ APIRequestContext สร้าง API client, ตรวจ status → schema → fields → side effect ออกแบบ error tests และ data builders พร้อม failure attachments ที่ช่วย debug โดยไม่รั่ว secret

APIRequestContext แบบไหน

Built-in request fixture เป็น context แยกที่รับ baseURL/headers จาก config เหมาะกับ pure API tests และ setup ถ้าใช้ page.request หรือ context.request มันแชร์ cookie jar กับ browser เหมาะกับตรวจ API ใน session เดียวกัน ส่วน playwright.request.newContext() แยก cookies และใช้สร้าง client ต่อ role ได้

เลือกโดยตั้งใจ: test anonymous ไม่ควรเผลอแชร์ session จาก page และ post-condition ของ UI อาจต้องใช้ identity เดียวกัน

Route Module คืน Raw Response

import type { APIRequestContext } from '@playwright/test'

export type CreateWorkshopInput = {
  title?: string
  startsAt?: string
  capacity?: number
  reference?: string
  [key: string]: unknown
}

export const workshopRoutes = (request: APIRequestContext) => ({
  create: (data: CreateWorkshopInput) =>
    request.post('/api/workshops', { data }),
  get: (id: string) =>
    request.get(`/api/workshops/${encodeURIComponent(id)}`),
  close: (id: string) =>
    request.post(`/api/workshops/${encodeURIComponent(id)}/close`),
})

Route module เป็นเจ้าของ URL และ request shape แต่คืน APIResponse ดิบ Specs เป็นเจ้าของ expectations ถ้า client assert 201 เอง negative test จะ reuse method ไม่ได้และ contract ถูกซ่อน

Assertion Order

  1. exact HTTP status
  2. response schema/envelope
  3. fields ที่เป็นเรื่องของ test
  4. side effect/post-condition ที่ contract สัญญา
import { z } from 'zod'
import { expect, test } from '@playwright/test'

const workshopSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(1),
  capacity: z.number().int().positive(),
  seatsRemaining: z.number().int().nonnegative(),
  status: z.enum(['DRAFT', 'OPEN', 'CLOSED']),
  createdAt: z.string().datetime(),
})

test('WORKSHOP-API-001 - creates an open workshop from valid input', async ({ request }) => {
  const input = aWorkshop({ capacity: 24 })
  const response = await request.post('/api/workshops', { data: input })

  expect(response.status()).toBe(201)
  const body = await response.json()
  const parsed = workshopSchema.safeParse(body.data)
  expect(parsed.success, parsed.success ? '' : parsed.error.message).toBe(true)
  expect(body.data.title).toBe(input.title)
  expect(body.data.seatsRemaining).toBe(24)

  const inquiry = await request.get(`/api/workshops/${body.data.id}`)
  expect(inquiry.status()).toBe(200)
  expect((await inquiry.json()).data.title).toBe(input.title)
})

Schema ตรวจ shape/type ส่วน field assertion บอก behavior เฉพาะ test อย่า pin ทุก timestamp/id exact ถ้าไม่ได้เป็น contract แต่ตรวจ format และ relation ที่มีความหมาย

Error Contract ต้องตรวจเต็ม

Status อย่างเดียวไม่พิสูจน์ว่า fail ถูกเหตุ:

test('WORKSHOP-API-010 - rejects capacity below one', async ({ request }) => {
  const response = await request.post('/api/workshops', {
    data: aWorkshop({ capacity: 0 }),
  })

  expect(response.status()).toBe(422)
  const body = await response.json()
  expect(body).toMatchObject({
    success: false,
    data: null,
    error: {
      code: 'CAPACITY_TOO_SMALL',
      details: {
        capacity: { type: 'minimum' },
      },
    },
  })
  expect(body.error.message).toMatch(/capacity/i)
  expect(body).not.toHaveProperty('stack')
  expect(JSON.stringify(body)).not.toMatch(/SELECT\s+|at .*\.(ts|js):\d+/i)
})

Pin machine-readable code exact เพราะ client branch บนมัน Message ตรวจว่า present/on-topic โดยไม่ pin product copy ที่เปลี่ยนได้ Field detail ตรวจเมื่อ schema สัญญา Negative assertions ป้องกัน stack, SQL, host หรือ raw exception หลุดใน response

Authentication Matrix

ต่อ protected endpoint อย่างน้อยตรวจ:

IdentityExpected claim
no session/token401 AUTHENTICATION_REQUIRED
expired/revoked401 SESSION_EXPIRED ตาม contract
authenticated wrong role403 ROLE_FORBIDDEN
correct role, other owner's id404 หรือ policy ที่ตกลงเพื่อไม่เปิดเผย existence
correct ownersuccess shape และ exact side effect

ค่าตัวอย่างเป็น course contract ระบบจริงต้องตกลง status/code กับ API/security owner

Idempotency และ Duplicate Submit

ระบบจองอาจรับ requestId เพื่อกัน retry/double click สร้าง 2 รายการ:

test('RESERVE-API-020 - replays one reservation for the same request id', async ({ request }) => {
  const requestId = crypto.randomUUID()
  const data = { workshopId: seededWorkshop.id }

  const first = await request.post('/api/reservations', {
    data,
    headers: { 'Idempotency-Key': requestId },
  })
  const second = await request.post('/api/reservations', {
    data,
    headers: { 'Idempotency-Key': requestId },
  })

  expect(first.status()).toBe(201)
  expect([200, 201]).toContain(second.status()) // ใช้ exact contract ของระบบจริง
  expect((await second.json()).data.id).toBe((await first.json()).data.id)

  const list = await request.get(`/api/reservations?workshopId=${seededWorkshop.id}`)
  expect((await list.json()).data).toHaveLength(1)
})

Course example แสดงตำแหน่ง assertions แต่ production contract ต้องเลือก replay status exact แล้ว pin ให้ชัด Concurrency claim ว่า last seat ไม่ถูกจองเกินต้องมี service/database test เพิ่ม Playwright API test ยิงพร้อมกันได้ แต่ environment/network ไม่ให้ control transaction schedule แม่นเท่า integration test ของ service

Named Steps ใน API Report

let response: APIResponse

await test.step('POST one valid reservation', async () => {
  response = await request.post('/api/reservations', { data: aReservation() })
})

await test.step('Return the reservation contract', async () => {
  expect(response.status()).toBe(201)
  expect((await response.json()).data.status).toBe('CONFIRMED')
})

Step ต่อ request/assertion group ทำ report บอก phase ไม่ห่อทุก field expectation เป็น step

Request/Response Attachments

Pure API failure ที่บอกเพียง expected 201 got 422 บังคับ rerun Fixture สามารถบันทึก method, URL, status, request body และ response body แล้ว testInfo.attach() เฉพาะเมื่อ fail ต้อง redact Authorization, cookie, password, token และข้อมูลผู้ใช้ก่อน serialize

APIResponse ของ Playwright ไม่ใช่ browser Fetch Response; อย่าเขียน helper ที่สมมติว่ามี clone() ให้อ่าน body()/json() ตาม API ของ Playwright ซึ่งเก็บ response body ไว้ และทดสอบ wrapper ของตนเอง ตัวอย่างเต็มอยู่ในบท Debugging and Reporting

รายการตรวจสอบ

  • API context เลือก cookie sharing โดยตั้งใจ
  • Route modules คืน raw response; specs เป็นเจ้าของ assertion
  • Assert status ก่อน schema, fields และ side effect
  • Error tests pin code/details และตรวจ no internal leakage
  • Auth, boundary, duplicate/idempotent behavior มี cases แยก
  • Attachments เกิดเมื่อ fail และ redact ก่อนบันทึก

สรุปบทนี้

API test ที่เชื่อถือได้ตรวจทั้ง transport, shape, behavior และ post-condition Client/helper ทำ request ให้สะดวก แต่ไม่ควรซ่อน raw response หรือ oracle จาก spec

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