บทที่ 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
- exact HTTP status
- response schema/envelope
- fields ที่เป็นเรื่องของ test
- 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 อย่างน้อยตรวจ:
| Identity | Expected claim |
|---|---|
| no session/token | 401 AUTHENTICATION_REQUIRED |
| expired/revoked | 401 SESSION_EXPIRED ตาม contract |
| authenticated wrong role | 403 ROLE_FORBIDDEN |
| correct role, other owner's id | 404 หรือ policy ที่ตกลงเพื่อไม่เปิดเผย existence |
| correct owner | success 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