cy.intercept ทำให้ test ของ Cypress ดักจับ HTTP request ทุกตัวที่แอปส่งออกไปได้ จากนั้นจะแค่เฝ้าดู หรือจะตอบกลับด้วยข้อมูลที่เรากำหนดเองก็ได้ เมื่อใช้ร่วมกับ fixture และ factory สำหรับสร้าง mock data ด้วย Faker คุณจะได้ front-end test ที่ไม่ต้องพึ่ง backend ที่ใช้ร่วมกัน ไม่พังเมื่อมีคนแก้ข้อมูลใน staging และสร้าง error กับ edge case ได้ทุกเมื่อที่ต้องการ
บทความนี้สร้างระบบนั้นทีละขั้น ตั้งแต่รูปแบบหลักของ cy.intercept, helper interceptApi ตัวเล็ก ๆ, fixture, factory ด้วย @faker-js/faker, การ intercept request ที่มี query string, การรอ alias, helper visitAs สำหรับ login ตาม role ไปจนถึงการตรวจ form data และการอัปโหลดไฟล์ โดยถือว่าคุณรู้พื้นฐานจาก คู่มือ Cypress E2E testing แล้ว
พื้นฐานของ cy.intercept
cy.intercept รับ route matcher และ response (ถ้ามี) ถ้าไม่ใส่ response มันจะแค่ spy request นั้น:
// Spy: let the request through, but give it an alias to wait on
cy.intercept('GET', '/api/words').as('getWords');
// Stub with a static body
cy.intercept('GET', '/api/words', { body: { words: [] } });
// Stub with a status code, headers and a delay
cy.intercept('GET', '/api/words', {
statusCode: 503,
body: { message: 'Service unavailable' },
delay: 500,
});
// Stub with a fixture file from cypress/fixtures
cy.intercept('GET', '/api/words', { fixture: 'words.json' });
// Decide the response from the request
cy.intercept('POST', '/api/words', (req) => {
req.reply({ statusCode: 201, body: { id: 99, ...req.body } });
});กฎสามข้อที่ช่วยลดความสับสนได้เกือบหมด:
- Register intercept ก่อนที่ request จะถูกส่ง วางไว้ก่อน
cy.visitหรือก่อนการคลิกที่ทำให้เกิด request - Intercept ที่ประกาศทีหลังสุดและ match ได้จะชนะ ค่า default ใน
beforeEachจึงถูก override ใน test เดียวได้ - Intercept ถูกล้างระหว่างแต่ละ test ไม่ต้อง cleanup เอง
Helper interceptApi แบบมี type
stub ส่วนใหญ่ในโปรเจกต์หน้าตาเหมือนกัน คือมี method, path ของ API, JSON body และ status code การทำเป็น custom command ช่วยลดโค้ดซ้ำ และเก็บ base URL ของ API ไว้ที่เดียว:
// cypress/support/commands.ts
Cypress.Commands.add(
'interceptApi',
(method: string, path: string, body: unknown, statusCode = 200) => {
return cy.intercept(
{ method, url: `${Cypress.env('apiUrl')}${path}` },
{ statusCode, body },
);
},
);
declare global {
namespace Cypress {
interface Chainable {
interceptApi(
method: string,
path: string,
body: unknown,
statusCode?: number,
): Chainable<null>;
}
}
}
export {};ตั้งค่า apiUrl ไว้ใน cypress.config.ts ใต้ env หรือใช้ environment variable CYPRESS_apiUrl ใน CI จากนั้น test จะอ่านเหมือนคำอธิบาย scenario:
cy.interceptApi('GET', '/words', { words: [] }).as('getWords');
cy.interceptApi('POST', '/login', { message: 'Invalid credentials' }, 401);Fixture: ข้อมูลคงที่จากไฟล์
fixture คือไฟล์ JSON ใน cypress/fixtures เหมาะกับข้อมูลที่ต้องเหมือนเดิมทุกครั้งที่รัน เช่น list ที่เราจะตรวจทีละรายการ:
{
"words": [
{ "id": 1, "word": "Ipsum1" },
{ "id": 2, "word": "Ipsum2" },
{ "id": 3, "word": "Lorem1" },
{ "id": 4, "word": "Lorem2" }
]
}describe('Word list', () => {
beforeEach(() => {
cy.intercept('GET', '/api/words', { fixture: 'words.json' }).as('getWords');
cy.visit('/words');
cy.wait('@getWords');
});
it('renders every word in order', () => {
cy.fixture('words.json').then(({ words }) => {
cy.findAllByRole('listitem').should('have.length', words.length);
words.forEach(({ word }: { word: string }, index: number) => {
cy.findAllByRole('listitem').eq(index).should('have.text', word);
});
});
});
});การโหลด fixture ตัวเดียวกันด้วย cy.fixture มาใช้ใน assertion ทำให้ test กับ stub ไม่มีทางเพี้ยนออกจากกัน ส่วน query findAllByRole มาจาก Cypress Testing Library
Factory สำหรับ mock data ด้วย Faker
fixture เริ่มไม่สะดวกเมื่อต้องการ user 25 คนสำหรับ test pagination หรือต้องการ user หนึ่งคนที่มี role เฉพาะ factory แก้ปัญหานี้ได้ มันคือ function ที่สร้าง object ที่ถูกต้องพร้อมค่าสุ่มที่ดูสมจริง และให้แต่ละ test override เฉพาะ field ที่สนใจ
ติดตั้ง Faker ตัวที่ยังมีคนดูแล แพ็กเกจ faker เดิมบน npm ถูกทิ้งไปแล้ว @faker-js/faker คือ fork จาก community ที่มาแทน และ API ก็เปลี่ยนไปพอสมควร
npm install --save-dev @faker-js/faker// cypress/support/factories.ts
import { faker } from '@faker-js/faker';
export type Role = 'admin' | 'editor' | 'viewer';
export interface User {
id: string;
name: string;
email: string;
role: Role;
createdAt: string;
}
export const createUser = (overrides: Partial<User> = {}): User => ({
id: faker.string.uuid(),
name: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
role: faker.helpers.arrayElement<Role>(['admin', 'editor', 'viewer']),
createdAt: faker.date.past().toISOString(),
...overrides,
});
export interface Article {
id: number;
title: string;
body: string;
categoryId: number;
}
export const createArticle = (overrides: Partial<Article> = {}): Article => ({
id: faker.number.int({ min: 1, max: 100_000 }),
title: faker.lorem.sentence({ min: 3, max: 8 }),
body: faker.lorem.paragraphs(3),
categoryId: faker.number.int({ min: 1, max: 5 }),
...overrides,
});ถ้าต้องการสร้างเป็น list ใช้ faker.helpers.multiple หรือ times ของ lodash ซึ่ง Cypress แถมมาให้ในชื่อ Cypress._:
const users = faker.helpers.multiple(() => createUser(), { count: 25 });
const admins = Cypress._.times(3, () => createUser({ role: 'admin' }));ถ้าเคยเรียน Faker จาก tutorial รุ่นเก่า ชื่อ method หลายตัวย้ายที่ไปแล้ว:
faker เดิม (deprecated) | @faker-js/faker ปัจจุบัน |
|---|---|
faker.datatype.number({ min: 1, max: 5 }) | faker.number.int({ min: 1, max: 5 }) |
faker.datatype.float() | faker.number.float({ fractionDigits: 2 }) |
faker.datatype.uuid() | faker.string.uuid() |
faker.datatype.datetime() | faker.date.anytime() |
faker.name.findName() | faker.person.fullName() |
faker.random.arrayElement([...]) | faker.helpers.arrayElement([...]) |
ทำให้ข้อมูลสุ่มทำซ้ำได้
ข้อมูลสุ่มช่วยเจอ bug ที่ข้อมูลตายตัวซ่อนไว้ แต่ test ที่ fail แล้วทำซ้ำไม่ได้นั้นน่าหงุดหงิดมาก ให้ใส่ seed ให้ Faker เพื่อให้แต่ละรอบได้ผลเหมือนเดิม:
// cypress/support/e2e.ts
import { faker } from '@faker-js/faker';
beforeEach(() => {
faker.seed(12345);
});และเมื่อข้อมูลถูกสร้างแบบสุ่ม อย่า assert กับข้อความที่ hard-code ไว้ ให้เก็บ object ที่สร้างขึ้นไว้แล้ว assert กับ field ของมันแทน
Helper ต่อ endpoint และ query string
รวม intercept ของแต่ละ endpoint ไว้ใน helper ตัวเดียวที่คืนทั้งข้อมูลและ route endpoint ที่คืน list มักรับ filter และ pagination ผ่าน query string:
// cypress/support/api/articles.ts
import { faker } from '@faker-js/faker';
import { createArticle, type Article } from '../factories';
export type ArticleFilter = Partial<{ term: string; categoryId: number; page: number }>;
export const mockGetArticles = (
filter: ArticleFilter = {},
articles: Article[] = faker.helpers.multiple(() => createArticle(), { count: 10 }),
total = articles.length,
) => {
const query = Cypress._.mapValues(
Cypress._.omitBy(filter, Cypress._.isNil),
String,
);
const response = { items: articles, total, page: filter.page ?? 1 };
cy.intercept({ method: 'GET', pathname: '/api/articles', query }, { body: response })
.as('getArticles');
return response;
};การ match ด้วย pathname กับ query แข็งแรงกว่าการประกอบ URL เต็มเอง เพราะลำดับของ parameter ไม่มีผล และไม่ต้อง URL-encode คำค้นหาเอง ค่าใน query จะถูกเทียบแบบ string ตัว helper จึงแปลงค่าด้วย String
test ค้นหาและเปลี่ยนหน้าจึงเขียนแค่สิ่งที่เปลี่ยน:
it('searches and pages through articles', () => {
mockGetArticles();
cy.visit('/articles');
cy.wait('@getArticles');
const results = mockGetArticles({ term: 'cypress' });
cy.findByRole('searchbox', { name: 'Search' }).type('cypress{enter}');
cy.wait('@getArticles');
cy.findAllByRole('article').should('have.length', results.items.length);
mockGetArticles({ term: 'cypress', page: 2 });
cy.findByRole('button', { name: 'Next page' }).click();
cy.wait('@getArticles').its('request.query.page').should('eq', '2');
});การเรียกแต่ละครั้งจะ register alias @getArticles ใหม่ด้วย matcher ที่เจาะจงขึ้น และเพราะ intercept ตัวล่าสุดชนะ cy.wait แต่ละครั้งจึงได้ request ของขั้นตอนที่มันตามหลัง
รอ request ด้วย cy.wait กับ alias
cy.wait('@alias') จะหยุดรอจนกว่า request ที่ match จะเสร็จ แล้วส่ง interception ออกมา ซึ่งมีทั้ง request, response และข้อมูลเวลา ใช้ประโยชน์ได้สองอย่าง
อย่างแรกคือ synchronization อย่าใช้ cy.wait(2000) การรอ request จริงเร็วกว่า และไม่พังเมื่อเครื่อง CI ช้า
อย่างที่สองคือ assert ว่าแอปส่งอะไรออกไป:
cy.wait('@createWord').then(({ request, response }) => {
expect(request.body).to.deep.equal({ word: 'Dolor' });
expect(request.headers).to.have.property('authorization');
expect(response?.statusCode).to.eq(201);
});ถ้า request เดียวกันถูกยิงหลายครั้ง ให้เรียก cy.wait('@alias') หนึ่งครั้งต่อหนึ่ง request หรือใช้ cy.wait(['@getUser', '@getSettings']) เพื่อรอหลาย route พร้อมกัน
Login ตาม role ด้วย helper visitAs
สำหรับ role-based access control นั้น UI test ส่วนใหญ่ไม่จำเป็นต้องผ่านฟอร์ม sign-in แค่ต้องทำให้แอปเชื่อว่ามี user ที่มี role นั้น login อยู่ ให้ stub endpoint ของ profile แล้ววาง token ไว้ในที่ที่แอปจะอ่าน:
// cypress/support/auth.ts
import { faker } from '@faker-js/faker';
import { createUser, type Role, type User } from './factories';
export const visitAs = (role: Role, path: string): User => {
const user = createUser({ role });
cy.intercept('GET', '/api/me', { body: user }).as('getProfile');
cy.visit(path, {
onBeforeLoad(win) {
win.localStorage.setItem('accessToken', faker.string.uuid());
},
});
cy.wait('@getProfile');
return user;
};onBeforeLoad เขียน token ลงใน window ของแอปก่อนที่โค้ดของแอปจะเริ่มทำงาน จึงไม่มี race กับขั้นตอน startup ของแอป test เรื่องสิทธิ์จึงสั้นและอ่านเหมือนตาราง:
it('shows user management to admins', () => {
visitAs('admin', '/settings');
cy.findByRole('link', { name: 'Manage users' }).should('be.visible');
});
it('hides user management from viewers', () => {
visitAs('viewer', '/settings');
cy.findByRole('link', { name: 'Manage users' }).should('not.exist');
});
it('redirects to sign-in when the token is rejected', () => {
cy.intercept('GET', '/api/me', { statusCode: 401 });
cy.visit('/settings');
cy.location('pathname').should('eq', '/login');
});ควรเก็บ test sign-in จริงที่ยิงไป backend จริงไว้สักสองสามตัวสำหรับ flow การ authentication เอง stub พวกนี้ทดสอบพฤติกรรมของ UI ตาม role ไม่ได้ทดสอบความปลอดภัยของ API กฎการเข้าถึงยังต้องบังคับใช้ที่ฝั่ง server เสมอ
Intercept form data และการอัปโหลดไฟล์
วิธี assert body ของ request ขึ้นกับ content type
JSON ถูก parse ให้แล้ว request.body จึงเป็น object เหมือนตัวอย่าง cy.wait ด้านบน
ฟอร์มแบบ URL-encoded (application/x-www-form-urlencoded) มาเป็น string ให้ parse ด้วย URLSearchParams:
cy.intercept('POST', '/api/contact').as('contact');
// ...fill in and submit the form...
cy.wait('@contact').then(({ request }) => {
const form = new URLSearchParams(request.body);
expect(form.get('email')).to.eq('[email protected]');
expect(form.get('message')).to.contain('Hello');
});การอัปโหลดแบบ multipart (multipart/form-data) มี byte ของไฟล์อยู่ข้างใน body จึงอาจเป็น string หรือ binary buffer ก็ได้ ให้ decode ก่อนแล้วตรวจเฉพาะส่วนที่สนใจ:
cy.intercept('POST', '/api/avatar', { statusCode: 201, body: { url: '/avatars/1.png' } })
.as('uploadAvatar');
cy.findByLabelText('Profile photo').selectFile('cypress/fixtures/avatar.png');
cy.findByRole('button', { name: 'Upload' }).click();
cy.wait('@uploadAvatar').then(({ request }) => {
expect(request.headers['content-type']).to.include('multipart/form-data');
const body =
typeof request.body === 'string'
? request.body
: new TextDecoder().decode(request.body);
expect(body).to.include('name="avatar"; filename="avatar.png"');
});
cy.findByRole('img', { name: 'Profile photo' })
.should('have.attr', 'src', '/avatars/1.png');test นี้ยืนยันว่าชื่อ field และชื่อไฟล์ที่ส่งไปถูกต้อง ซึ่งจับ bug ที่เจอบ่อยได้ คือ front-end กับ API เข้าใจชื่อ field ในฟอร์มไม่ตรงกัน
คำถามที่พบบ่อย
cy.intercept กับ cy.request ต่างกันอย่างไร
cy.intercept ดักจับ request ที่แอปส่งจาก browser แล้ว stub หรือตรวจดูได้ ส่วน cy.request ส่ง request ตรงจาก test เอง เช่น ใช้เตรียมข้อมูลหรือ login ผ่าน API ทั้งสองแก้ปัญหาต่างกัน และมักอยู่ใน test suite เดียวกัน
ควรใช้ fixture หรือ Faker
ใช้ fixture เมื่อ test ต้อง assert ค่าที่เจาะจงและไม่ควรเปลี่ยน ใช้ factory กับ Faker เมื่อต้องการข้อมูลจำนวนมาก ต้องการความต่างในแต่ละ test หรือต้องการรูปแบบข้อมูลที่สมจริง หลาย suite ใช้ทั้งคู่ คือ factory สำหรับข้อมูลส่วนใหญ่ และ fixture สำหรับ response ต้นแบบไม่กี่ตัว
จะ intercept request ที่มี query parameter ใน Cypress อย่างไร
ส่ง route matcher เป็น object ที่มี pathname และ query เช่น { pathname: '/api/articles', query: { page: '2' } } string ธรรมดาอย่าง '/api/articles' หลวมกว่าที่เห็น และอาจไปจับ /api/articles/5 หรือ request ที่มี query string แบบไหนก็ได้ ค่าใน query จะถูกเทียบเป็น string
แพ็กเกจ faker บน npm ยังมีคนดูแลอยู่ไหม
ไม่มีแล้ว ให้ใช้ @faker-js/faker แทน method ถูกย้ายไปอยู่ใน module อย่าง faker.number, faker.string และ faker.person ตัวอย่างเก่า ๆ จึงต้องแก้ตาม
Checklist
- Register
cy.interceptทุกตัวก่อน request จะถูกส่ง และตั้ง alias ให้ทุกตัว - รวม stub ที่ใช้บ่อยไว้ใน command
interceptApiและ helper ต่อ endpoint - ใช้ fixture สำหรับข้อมูลต้นแบบ และใช้ factory ของ
@faker-js/fakerที่ override ได้สำหรับข้อมูลที่เหลือ อย่าลืมใส่ seed - match endpoint ที่คืน list ด้วย
pathnameและquery - เปลี่ยน
cy.wait(ms)เป็นcy.wait('@alias')แล้ว assert กับ request ที่ได้มา - ใช้ helper
visitAs(role, path)กับ test เรื่องสิทธิ์ และเก็บ test sign-in จริงไว้บ้าง - decode body ของฟอร์มและ multipart ก่อน assert
intercept ชุดเดียวกันนี้ใช้ได้ใน Cypress component testing ด้วย factory ที่เขียนครั้งเดียวจึงใช้ได้กับทั้งสอง suite ถ้าต้องการคนช่วยดูกลยุทธ์การทดสอบฝั่ง front-end Vectorkub พัฒนาและดูแลเว็บแอปที่ใช้งานจริงบน production
