กลับไปยังบทความ

Frontend

Cypress E2E Testing: คู่มือเขียน Test ที่เสถียร ตั้งแต่ Assertion ถึง Custom Command

เรียนรู้ Cypress E2E testing แบบใช้งานจริง ตั้งแต่โครงสร้าง test และ Mocha hook, assertion, retry-ability ของ should กับ then, wrap, its และ invoke, custom command, environment variable และ cy.request

24 กันยายน 256910 นาทีในการอ่าน

cypress e2e testing guide

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ตรวจอะไรความเร็วเครื่องมือที่พบบ่อย
UnitFunction หรือ module เดียวแบบแยกส่วนระดับมิลลิวินาทีVitest, Jest
ComponentUI 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

bash
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 ขั้นต่ำ:

ts
// 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 หนึ่งตัว:

ts
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:

ts
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

ts
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():

ts
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 ไปเรื่อย ๆ เอง

ts
// Retries until the API responds and three rows render
cy.get('table tbody tr').should('have.length', 3)

.then() ทำงานต่างออกไป callback ของมันรัน ครั้งเดียว กับค่าที่ command ก่อนหน้าส่งมา และไม่มีการ retry:

ts
// 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

ts
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

ts
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 ที่กำกวมในหน้าที่มีฟอร์มหน้าตาคล้ายกันหลายชุด:

ts
cy.get('form#shipping').within(() => {
  cy.get('input[name=city]').type('Bangkok')
  cy.get('input[name=zip]').type('10110')
})

วน loop ด้วย each

ts
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')
ts
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:

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:

ts
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:

ts
// cypress.config.ts
export default defineConfig({
  e2e: { baseUrl: 'http://localhost:3000' },
  env: { apiUrl: 'http://localhost:8080' },
})
bash
# 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 โดยตรง:

ts
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

  1. ใช้ E2E กับเส้นทางการใช้งานที่สำคัญ และย้าย edge case ไปอยู่ใน unit test และ component test
  2. ตั้ง baseUrl และอ่านค่าที่ขึ้นกับ environment ผ่าน Cypress.env
  3. เลือก element ด้วย attribute data-cy หรือ role ตามหลัก accessibility ไม่ใช่ CSS class
  4. Reset state ใน beforeEach และใช้ cy.request seed ข้อมูลให้เร็ว
  5. Assert ด้วย .should() เพื่อให้ Cypress retry ได้ และเลี่ยง cy.wait แบบกำหนดเวลาตายตัว
  6. ย้ายขั้นตอนที่ซ้ำไปเป็น custom command ที่มี type และ cache การ login ด้วย cy.session
  7. ตั้ง lint ดัก .only ที่หลุดเข้า commit

Test suite ขนาดเล็กที่ทำตามกฎเหล่านี้จะยังเร็วและน่าเชื่อถือแม้แอปจะโตขึ้น ถ้าต้องการคนช่วยวางกลยุทธ์ E2E testing ให้เว็บแอปของคุณ Vectorkub พัฒนาและทดสอบเว็บแอปพลิเคชันที่ใช้งานจริงบน production

พร้อมสร้างสิ่งที่ยิ่งใหญ่หรือยัง?

เล่าโปรเจคของคุณให้เราฟัง — เราจะพาคุณไปถึง

เลือกคุกกี้ที่เราใช้ได้ คุกกี้ที่จำเป็นช่วยให้เว็บไซต์ทำงานได้และเปิดใช้งานเสมอ