Cypress E2E testing คือการควบคุมแอปพลิเคชันจริงใน browser จริงแบบเดียวกับที่ผู้ใช้ทำ ตั้งแต่เปิดหน้าเว็บ พิมพ์ข้อมูลในช่องกรอก กดปุ่ม ไปจนถึงตรวจสอบสิ่งที่ปรากฏบนหน้าจอ Cypress รันอยู่ใน browser ข้างเดียวกับแอปของคุณ รอ element ให้อัตโนมัติ และ retry assertion จนกว่าจะผ่านหรือหมดเวลา ความสามารถ retry ในตัวนี้เองที่ทำให้ test ของ Cypress เสถียรเมื่อเขียนถูกวิธี และกลายเป็น flaky เมื่อเขียนผิด
บทความนี้ครอบคลุมแกนหลักของ Cypress 13 ขึ้นไป ได้แก่ ตำแหน่งของ E2E test เมื่อเทียบกับ test ประเภทอื่น การจัดโครงสร้าง test ด้วย Mocha, assertion, retry-ability และความต่างระหว่าง should กับ then, command ที่ใช้ทุกวัน, custom command, การตั้งค่า baseUrl และ environment variable รวมถึงการเรียก API ด้วย cy.request
E2E test อยู่ตรงไหนในภาพรวม
| ประเภท test | ตรวจอะไร | ความเร็ว | เครื่องมือที่พบบ่อย |
|---|---|---|---|
| Unit | Function หรือ module เดียวแบบแยกส่วน | ระดับมิลลิวินาที | Vitest, Jest |
| Component | UI component เดียวที่ render ใน browser | เร็ว | Cypress component testing, Testing Library |
| Integration | สองส่วนขึ้นไปทำงานร่วมกัน เช่น service กับฐานข้อมูล หรือ service สองตัว | ปานกลาง | Test containers, API test |
| End-to-end (E2E) | เส้นทางการใช้งานเต็มรูปแบบผ่านทั้ง stack ที่ deploy แล้ว | ช้าที่สุด | Cypress, Playwright |
E2E test ให้ความมั่นใจต่อหนึ่ง test สูงที่สุด แต่ก็มีต้นทุนในการรันและดูแลสูงที่สุดเช่นกัน จึงควรเก็บไว้ใช้กับเส้นทางสำคัญ เช่น สมัครสมาชิก login ชำระเงิน หรือ flow ที่ถ้าพังแล้วต้องมีคนตื่นมาแก้กลางดึก ส่วน edge case ละเอียด ๆ ให้ย้ายไปอยู่ใน unit test และ component test สำหรับชั้น component อ่านได้ที่ Cypress component testing
ตั้งค่าโปรเจกต์ Cypress
npm install --save-dev cypress
npx cypress open # interactive runner, scaffolds config on first run
npx cypress run # headless, for CIตั้งแต่ Cypress 10 การตั้งค่าอยู่ใน cypress.config.ts (หรือ .js) ไม่ใช่ cypress.json อีกต่อไป ตัวอย่างการตั้งค่า E2E ขั้นต่ำ:
// cypress.config.ts
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.ts',
defaultCommandTimeout: 4000,
},
})เมื่อตั้ง baseUrl แล้ว cy.visit('/login') และ cy.request('/api/health') จะอ้างอิงจาก URL นี้ เวลาจะเปลี่ยน environment ก็แก้แค่ที่เดียวแทนที่จะไล่แก้ทุก test และ Cypress ยังไม่ต้อง reload หน้าเพิ่มอีกรอบตอนเริ่ม test แรก
จัดโครงสร้าง test ด้วย describe, context และ it
Cypress ใช้ syntax แบบ BDD ของ Mocha โดย describe ใช้จัดกลุ่ม test, context เป็น alias ของ describe ที่อ่านแล้วเข้ากับการบรรยาย scenario และ it คือ test หนึ่งตัว:
describe('Login page', () => {
context('with valid credentials', () => {
it('redirects to the dashboard', () => {
cy.visit('/login')
cy.get('[data-cy=email]').type('[email protected]')
cy.get('[data-cy=password]').type('correct-horse{enter}')
cy.location('pathname').should('eq', '/dashboard')
})
})
context('with a wrong password', () => {
it('shows an error message', () => {
cy.visit('/login')
cy.get('[data-cy=email]').type('[email protected]')
cy.get('[data-cy=password]').type('wrong{enter}')
cy.contains('[role=alert]', 'Invalid email or password').should('be.visible')
})
})
})การเลือก element ด้วย attribute data-cy ทำให้ test ไม่พังเมื่อ CSS class หรือ markup เปลี่ยน และการเลือกด้วย role กับ label ตามหลัก accessibility มักจะดียิ่งกว่า ดูวิธีได้ใน Cypress Testing Library
Mocha hook
Hook ใช้รันโค้ดเตรียมการรอบ ๆ test:
describe('Cart', () => {
before(() => {
// once before all tests in this block, e.g. seed a product
})
beforeEach(() => {
// before every test: the usual place for login and visit
cy.visit('/cart')
})
afterEach(() => {
// after every test
})
after(() => {
// once after all tests
})
})Cypress แนะนำให้ reset state ใน beforeEach แทนการเก็บกวาดใน after หรือ afterEach เพราะถ้า test fail กลางทาง hook สำหรับเก็บกวาดอาจทำงานไม่จบ และการรันครั้งถัดไปจะเริ่มจาก state ที่สกปรก ตั้งแต่ Cypress 12 test isolation เปิดเป็นค่าเริ่มต้น หน้าเว็บ cookie และ local storage จะถูกล้างระหว่าง test แต่ละ test จึงต้องเตรียมสิ่งที่ตัวเองต้องใช้เอง
รันบางส่วนด้วย only และ skip
it.only('runs just this test', () => { /* ... */ })
describe.skip('skips this whole block', () => { /* ... */ }).only สะดวกตอน debug แต่ถ้าเผลอ commit เข้าไป test ที่เหลือทั้งหมดจะถูกปิดแบบเงียบ ๆ ควรเพิ่ม lint rule อย่าง mocha/no-exclusive-tests เพื่อดักไว้
Assertion ใน Cypress
Cypress มาพร้อม Chai, Chai-jQuery และ Sinon-Chai assertion ส่วนใหญ่เขียนด้วย .should() และต่อด้วย .and():
cy.get('[data-cy=todo-item]')
.should('have.length', 3)
.first()
.should('have.class', 'completed')
.and('contain', 'Buy milk')
cy.get('button[type=submit]').should('be.disabled')
cy.get('input[name=email]').should('have.value', '[email protected]')
cy.get('a.docs').should('have.attr', 'href', '/docs')
cy.url().should('include', '/dashboard')have.class, have.attr และ be.visible มาจาก Chai-jQuery นอกจากนี้ library เบื้องหลังก็เรียกใช้ตรง ๆ ได้ Cypress.$ คือ jQuery และ Cypress._ คือ Lodash ซึ่งมีประโยชน์ตอนสร้างข้อมูลทดสอบ เช่น Cypress._.times(5, ...)
Retry-ability: should กับ then
นี่คือแนวคิดที่มีผลกับความเสถียรของ test มากที่สุด query ของ Cypress อย่าง cy.get(), .find(), .contains() และ .its() จะถูก retry ไปพร้อมกับ assertion ที่ตามมา จนกว่า assertion จะผ่านหรือหมดเวลา defaultCommandTimeout (ค่าเริ่มต้น 4 วินาที) เมื่อข้อมูลโหลดมาช้า เราไม่ต้องใส่การรอเพิ่ม assertion จะ retry ไปเรื่อย ๆ เอง
// Retries until the API responds and three rows render
cy.get('table tbody tr').should('have.length', 3).then() ทำงานต่างออกไป callback ของมันรัน ครั้งเดียว กับค่าที่ command ก่อนหน้าส่งมา และไม่มีการ retry:
// Flaky: if the list hasn't rendered yet, this checks an empty result once and fails
cy.get('ul li').then(($items) => {
expect($items).to.have.length(3)
})
// Stable: the callback is retried until the expectation passes
cy.get('ul li').should(($items) => {
expect($items).to.have.length(3)
expect($items.first()).to.contain('Buy milk')
}).should() | .then() | |
|---|---|---|
| Retry | ใช่ จนกว่าจะผ่านหรือหมดเวลา | ไม่ รันครั้งเดียว |
| เรียก Cypress command ข้างในได้ | ไม่ได้ | ได้ |
| ค่าที่ส่งต่อ | Subject เดิม | ค่าที่ callback return หรือ subject เดิม |
| ใช้สำหรับ | Assertion | ทำงานกับค่าหลังจากหน้าเว็บนิ่งแล้ว |
รูปแบบที่ดีคือทำให้ state นิ่งด้วย .should() ก่อน แล้วค่อยใช้ .then() อ่านค่าและสั่ง command ต่อ หลีกเลี่ยง cy.wait(2000) เพราะการรอแบบกำหนดเวลาตายตัวถ้าไม่สั้นไปจน flaky ก็ยาวไปจนช้า ให้รอเงื่อนไขที่เจาะจงหรือ network alias แทน
Command ที่ใช้ทุกวัน
Action
cy.get('[data-cy=search]').clear().type('keyboard{enter}')
cy.get('[data-cy=terms]').check()
cy.get('select[name=country]').select('Thailand')
cy.contains('button', 'Save').click()ก่อนทำ action ใด ๆ Cypress จะตรวจว่า element มองเห็นได้ ไม่ถูก disable และไม่มีอะไรบังอยู่ ถ้า action fail ด้วยข้อความว่า element ถูก element อื่นบัง ให้แก้ที่หน้าเว็บหรือที่ test แทนการใช้ { force: true }
ตรวจสอบ URL
cy.location('pathname').should('eq', '/orders/42')
cy.location('search').should('include', 'page=2')
cy.url().should('match', /\/orders\/\d+$/)จำกัดขอบเขตด้วย within
.within() จำกัดให้ทุก query ใน callback ค้นหาเฉพาะภายใน element แม่ ช่วยเลี่ยง selector ที่กำกวมในหน้าที่มีฟอร์มหน้าตาคล้ายกันหลายชุด:
cy.get('form#shipping').within(() => {
cy.get('input[name=city]').type('Bangkok')
cy.get('input[name=zip]').type('10110')
})วน loop ด้วย each
const expected = ['Draft', 'Paid', 'Shipped']
cy.get('[data-cy=status]').each(($el, index) => {
cy.wrap($el).should('have.text', expected[index])
})wrap, its และ invoke
cy.wrap()นำค่า, jQuery element หรือ promise เข้ามาใน chain ของ Cypress เพื่อให้ใช้ command และ assertion ที่ retry ได้ นี่คือเหตุผลที่ตัวอย่าง.each()ต้อง wrap$el.its()อ่าน property เช่นcy.wrap(user).its('address.city')หรือcy.get('li').its('length').invoke()เรียก method เช่น.invoke('text'),.invoke('val'),.invoke('attr', 'href')
cy.get('[data-cy=total]')
.invoke('text')
.should('match', /\d+\.\d{2}$/)
cy.window().its('localStorage.token').should('exist')Custom command
เมื่อมีขั้นตอนเดิมซ้ำกันในหลาย spec ให้ย้ายไปเป็น custom command ใน cypress/support/commands.ts:
// cypress/support/commands.ts
Cypress.Commands.add('getByTestId', (id: string) => {
return cy.get(`[data-cy="${id}"]`)
})
Cypress.Commands.add('login', (email: string, password: string) => {
cy.session([email], () => {
cy.request('POST', '/api/login', { email, password })
.its('status')
.should('eq', 200)
})
})เพื่อให้ TypeScript รู้จัก command ใหม่ ต้องขยาย interface Cypress.Chainable:
declare global {
namespace Cypress {
interface Chainable {
getByTestId(id: string): Chainable<JQuery<HTMLElement>>
login(email: string, password: string): Chainable<void>
}
}
}
export {}cy.session() จะ cache cookie และ storage ตาม key ที่กำหนด การ login ผ่าน UI จึงเกิดแค่ครั้งเดียวแทนที่จะเกิดก่อนทุก test และ command ด้านบน login ผ่าน API ซึ่งเร็วกว่านั้นอีก แต่ควรเก็บ E2E test ไว้หนึ่งตัวที่ทดสอบฟอร์ม login จริง ส่วนการทำงานของ global declaration แบบนี้ อธิบายไว้ใน TypeScript declaration file
Environment variable ใน Cypress
อย่าฝังค่าที่ขึ้นกับ environment ไว้ในโค้ด test Cypress อ่านค่าได้จากหลายแหล่ง และค่าจาก command line หรือ OS variable ที่ขึ้นต้นด้วย CYPRESS_ จะ override ค่าในไฟล์ config:
// cypress.config.ts
export default defineConfig({
e2e: { baseUrl: 'http://localhost:3000' },
env: { apiUrl: 'http://localhost:8080' },
})# CI: override without touching files
CYPRESS_apiUrl=https://api.staging.example.com npx cypress run
npx cypress run --env apiUrl=https://api.staging.example.com
npx cypress run --config baseUrl=https://staging.example.comสำหรับค่าบนเครื่อง local ใช้ไฟล์ cypress.env.json ที่ใส่ไว้ใน .gitignore ได้ แล้วอ่านใน test ด้วย Cypress.env('apiUrl') ค่าที่ผ่าน Cypress.env จะเข้าถึงได้จากโค้ดที่รันใน browser ดังนั้นให้ใช้บัญชีทดสอบที่มีสิทธิ์ต่ำ ไม่ใช่ secret จริง
เรียก API ด้วย cy.request
cy.request() ส่ง HTTP request จาก Cypress เอง ไม่ได้ส่งจากหน้าเว็บ จึงไม่ติดข้อจำกัด CORS ใช้สำหรับ seed ข้อมูล reset state login หรือตรวจ endpoint โดยตรง:
beforeEach(() => {
cy.request('POST', `${Cypress.env('apiUrl')}/test/reset`)
cy.request({
method: 'POST',
url: `${Cypress.env('apiUrl')}/orders`,
body: { sku: 'KB-001', qty: 2 },
}).its('body.id').as('orderId')
})
it('shows the new order', function () {
cy.visit(`/orders/${this.orderId}`)
cy.contains('h1', `Order #${this.orderId}`).should('be.visible')
})โดยค่าเริ่มต้น cy.request จะทำให้ test fail เมื่อได้ response 4xx หรือ 5xx ถ้าต้องการ assert กับ response ที่เป็น error เอง ให้ส่ง failOnStatusCode: false สังเกตว่า test ใช้ function () เพราะ alias ที่อยู่บน this ใช้กับ arrow function ไม่ได้
cy.request คุยกับ server จริง ถ้าต้องการ stub หรือดัก request ที่แอปส่งจาก browser ให้ใช้ cy.intercept() ซึ่งอธิบายไว้ใน mock API ใน Cypress ด้วย intercept และ Faker
คำถามที่พบบ่อย
should กับ then ใน Cypress ต่างกันอย่างไร?
.should() retry assertion จนกว่าจะผ่านหรือหมดเวลา ส่วน .then() รัน callback ครั้งเดียวและไม่ retry ใช้ should สำหรับ assertion และใช้ then เมื่อต้องทำงานกับค่าหลังหน้าเว็บนิ่งแล้ว
cypress.json หายไปไหน?
Cypress 10 เปลี่ยนมาใช้ cypress.config.ts หรือ cypress.config.js ซึ่งแยกส่วน e2e และ component ชัดเจน ถ้ารัน npx cypress open กับโปรเจกต์เก่า Cypress จะเสนอให้ migrate ให้
รัน Cypress test แค่ตัวเดียวอย่างไร?
เติม .only ให้ it หรือ describe หรือรันแค่ spec ไฟล์เดียวด้วย npx cypress run --spec cypress/e2e/login.cy.ts
จะเลี่ยงการ login ก่อนทุก test ใน Cypress ได้อย่างไร?
ห่อขั้นตอน login ไว้ใน cy.session() ภายใน custom command Cypress จะกู้ session ที่ cache ไว้กลับมาแทนการ login ซ้ำ
ควรใช้ cy.wait แบบใส่ตัวเลขไหม?
แทบไม่ควร ให้รอเงื่อนไขด้วย .should() หรือรอ request ที่ intercept ไว้ด้วย cy.wait('@alias') แทนการหน่วงเวลาตายตัว
Checklist สำหรับ Cypress E2E
- ใช้ E2E กับเส้นทางการใช้งานที่สำคัญ และย้าย edge case ไปอยู่ใน unit test และ component test
- ตั้ง
baseUrlและอ่านค่าที่ขึ้นกับ environment ผ่านCypress.env - เลือก element ด้วย attribute
data-cyหรือ role ตามหลัก accessibility ไม่ใช่ CSS class - Reset state ใน
beforeEachและใช้cy.requestseed ข้อมูลให้เร็ว - Assert ด้วย
.should()เพื่อให้ Cypress retry ได้ และเลี่ยงcy.waitแบบกำหนดเวลาตายตัว - ย้ายขั้นตอนที่ซ้ำไปเป็น custom command ที่มี type และ cache การ login ด้วย
cy.session - ตั้ง lint ดัก
.onlyที่หลุดเข้า commit
Test suite ขนาดเล็กที่ทำตามกฎเหล่านี้จะยังเร็วและน่าเชื่อถือแม้แอปจะโตขึ้น ถ้าต้องการคนช่วยวางกลยุทธ์ E2E testing ให้เว็บแอปของคุณ Vectorkub พัฒนาและทดสอบเว็บแอปพลิเคชันที่ใช้งานจริงบน production
