Cypress component testing คือการ mount component ทีละตัวขึ้นมาใน browser จริง โดยไม่ต้องเปิดทั้งแอป คุณยังได้ command ของ Cypress, time-travel debugging และการควบคุม network ครบเหมือน E2E test แต่แต่ละ test จะ render แค่ปุ่ม ฟอร์ม หรือการ์ดหนึ่งตัว ด้วย props ที่คุณกำหนดเอง จึงเหมาะกับการตรวจพฤติกรรมของ component ในหลาย ๆ state ซึ่งถ้าต้องไล่ผ่านหน้าเว็บเต็ม ๆ จะทั้งช้าและยุ่งยาก
บทความนี้ตั้งค่า component testing ให้โปรเจกต์ React + Vite แล้วอธิบาย cy.mount, cy.spy, cy.stub (รวมถึงการเปลี่ยนค่าที่ return ในแต่ละครั้งที่ถูกเรียก) และการ intercept API จากใน component test แนวคิดเดียวกันใช้กับ Vue, Angular และ Svelte ได้ ต่างกันแค่ตัวที่ import มาใช้ mount ส่วนพื้นฐานอย่าง describe, it และ assertion ดูได้ใน คู่มือ Cypress E2E testing
Component test ต่างจาก E2E test อย่างไร
| Component test | E2E test | |
|---|---|---|
| สิ่งที่รัน | component ตัวเดียว เสิร์ฟผ่าน bundler ของ dev server | ทั้งแอปผ่าน URL |
| จุดเริ่มต้น | cy.mount(<Component />) | cy.visit('/path') |
| ต้องมี backend ไหม | ไม่ต้อง mock request ด้วย cy.intercept | ส่วนใหญ่ต้องมี หรือต้อง mock |
| ความเร็ว | เร็ว ไม่ต้องผ่าน routing หรือ login | ช้ากว่า |
| เหมาะกับ | state, edge case, props และ callback | user journey ที่ข้ามหลายหน้า |
| ตำแหน่งไฟล์ spec | อยู่ข้าง component เช่น Button.cy.tsx | cypress/e2e/*.cy.ts |
ทีมส่วนใหญ่ใช้ทั้งสองแบบ คือ E2E test จำนวนไม่มากสำหรับ flow สำคัญ และ component test สำหรับพฤติกรรมละเอียดของแต่ละชิ้น
ตั้งค่า Cypress component testing
ติดตั้ง Cypress แล้วเปิดขึ้นมา:
npm install --save-dev cypress
npx cypress openเลือก Component Testing ในหน้า launcher Cypress จะตรวจว่าโปรเจกต์ใช้ framework และ bundler อะไร ติดตั้งส่วนที่ขาด แล้วสร้างไฟล์ให้:
cypress.config.tsที่มี sectioncomponentcypress/support/component.tsที่ registercy.mountcypress/support/component-index.htmlหน้าที่ใช้ mount component
config ของโปรเจกต์ React + Vite หน้าตาแบบนี้:
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
specPattern: 'src/**/*.cy.{ts,tsx}',
},
});Cypress ใช้ vite.config.ts ตัวเดียวกับแอป ดังนั้น path alias, plugin และ environment variable ทำงานเหมือนกันทุกอย่าง ถ้าเป็นโปรเจกต์ webpack ให้ตั้ง bundler: 'webpack' ส่วน Next.js ใช้ framework: 'next'
support file เป็นที่ register command mount ให้ import global style ไว้ที่นี่ด้วย เพื่อให้ component render ด้วย CSS ชุดเดียวกับ production:
// cypress/support/component.ts
import './commands';
import '../../src/index.css';
import { mount } from 'cypress/react';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);ตั้งแต่ Cypress 14 ขึ้นไป cypress/react รองรับทั้ง React 18 และ 19 ถ้ายังใช้ Cypress 13 กับ React 18 ให้ import จาก cypress/react18 แทน ส่วน Vue ใช้ cypress/vue, Angular ใช้ cypress/angular และ Svelte ใช้ cypress/svelte
Mount component ด้วย cy.mount
cy.mount render component ลงในหน้า test จากนั้นก็ใช้ command ของ Cypress ตามปกติ:
// src/components/Counter.cy.tsx
import { Counter } from './Counter';
describe('<Counter />', () => {
it('starts at the initial value', () => {
cy.mount(<Counter initial={5} />);
cy.findByText('Count: 5').should('be.visible');
});
it('increments and decrements', () => {
cy.mount(<Counter initial={0} />);
cy.findByRole('button', { name: 'Increment' }).click().click();
cy.findByRole('button', { name: 'Decrement' }).click();
cy.findByText('Count: 1').should('be.visible');
});
});query กลุ่ม findBy* มาจาก Cypress Testing Library ต้อง import @testing-library/cypress/add-commands ใน support file ของ component ด้วย เหตุผลที่ query พวกนี้ดีกว่า CSS selector อธิบายไว้ใน Cypress Testing Library และ accessible query
ครอบ component ด้วย provider
component จริงมักต้องมี router, theme หรือ client สำหรับดึงข้อมูล แทนที่จะเขียน wrapper ซ้ำในทุก test ให้ขยาย command mount แทน:
// cypress/support/component.tsx
import { mount, type MountOptions } from 'cypress/react';
import { MemoryRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
type Options = MountOptions & { route?: string };
Cypress.Commands.add('mount', (component: ReactNode, options: Options = {}) => {
const { route = '/', ...mountOptions } = options;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return mount(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>{component}</MemoryRouter>
</QueryClientProvider>,
mountOptions,
);
});เมื่อ support file มี JSX ให้เปลี่ยนนามสกุลเป็น .tsx และถ้าย้ายตำแหน่งไฟล์ก็ต้องแก้ supportFile ใน config ด้วย การสร้าง QueryClient ใหม่ทุกครั้งที่ mount ช่วยไม่ให้ข้อมูลใน cache ข้ามไปมาระหว่าง test และอย่าลืมแก้ type ของ Chainable ให้ mount รับ option route ที่เพิ่มมา
ตรวจ callback ด้วย cy.spy
cy.spy() บันทึกการเรียก function โดยไม่เปลี่ยนการทำงานของมัน ใน component test ใช้บ่อยที่สุดกับการตรวจว่า callback prop ถูกเรียกถูกต้องหรือไม่:
import { LoginForm } from './LoginForm';
it('submits the email and password', () => {
const onSubmit = cy.spy().as('onSubmit');
cy.mount(<LoginForm onSubmit={onSubmit} />);
cy.findByLabelText('Email').type('[email protected]');
cy.findByLabelText('Password').type('secret123{enter}');
cy.get('@onSubmit').should('have.been.calledOnce');
cy.get('@onSubmit').should('have.been.calledWith', {
email: '[email protected]',
password: 'secret123',
});
});
it('does not submit an empty form', () => {
const onSubmit = cy.spy().as('onSubmit');
cy.mount(<LoginForm onSubmit={onSubmit} />);
cy.findByRole('button', { name: 'Sign in' }).click();
cy.get('@onSubmit').should('not.have.been.called');
});รูปแบบ cy.spy(object, 'methodName') จะครอบ method ที่มีอยู่แล้ว ทำให้ดูการเรียกที่เกิดขึ้นภายใน component ได้:
cy.spy(console, 'error').as('consoleError');
cy.mount(<ProfileCard user={user} />);
cy.get('@consoleError').should('not.have.been.called');spy และ stub ที่สร้างด้วย cy.spy กับ cy.stub จะถูกคืนค่าเดิมให้อัตโนมัติหลังจบแต่ละ test การตั้ง alias ด้วย .as() ทำให้ assert ผ่าน cy.get('@alias') ได้ และยังเห็นจำนวนครั้งที่ถูกเรียกใน command log ด้วย
เปลี่ยนพฤติกรรมด้วย cy.stub
cy.stub() บันทึกการเรียกได้เหมือน spy แต่จะ แทนที่ function เดิม คุณจึงกำหนดได้ว่าจะให้ return อะไร ใช้เมื่อ function จริงทำสิ่งที่ไม่อยากให้เกิดใน test เช่น เปิด dialog ของ browser เรียก SDK ชำระเงิน หรืออ่านเวลาปัจจุบัน
it('deletes only after the user confirms', () => {
const onDelete = cy.spy().as('onDelete');
cy.mount(<DeleteButton onDelete={onDelete} />);
cy.window().then((win) => {
cy.stub(win, 'confirm').as('confirm').returns(false);
});
cy.findByRole('button', { name: 'Delete' }).click();
cy.get('@confirm').should('have.been.calledOnce');
cy.get('@onDelete').should('not.have.been.called');
});เปลี่ยนค่าที่ stub return
stub ของ Cypress คือ Sinon stub จึงกำหนดผลลัพธ์แยกตามครั้งที่เรียกหรือตาม argument ได้:
const getPrice = cy.stub();
getPrice.returns(100); // every call returns 100
getPrice.onFirstCall().returns(100); // first call
getPrice.onSecondCall().returns(120); // second call
getPrice.withArgs('THB').returns(3500); // only for this argument
const fetchUser = cy.stub();
fetchUser.resolves({ id: 1, name: 'Ana' }); // returns a resolved Promise
fetchUser.rejects(new Error('Network')); // returns a rejected Promiseถ้าเรียก .returns() หรือ .resolves() ซ้ำภายหลังใน test พฤติกรรมใหม่จะทับของเดิม ซึ่งมีประโยชน์มากเวลาทดสอบปุ่ม "ลองอีกครั้ง":
it('shows the data after a retry', () => {
const api = { loadStats: cy.stub().rejects(new Error('Server error')) };
cy.mount(<StatsPanel api={api} />);
cy.findByRole('alert').should('contain.text', 'Could not load stats');
cy.then(() => {
api.loadStats.resolves({ visitors: 1200 });
});
cy.findByRole('button', { name: 'Try again' }).click();
cy.findByText('1,200 visitors').should('be.visible');
});สังเกต cy.then() command ของ Cypress ถูกเข้าคิวไว้ก่อนแล้วค่อยรัน ถ้าเขียน JavaScript ธรรมดาคั่นระหว่าง command บรรทัดนั้นจะรันก่อน assertion ตัวแรกทำงานเสร็จ การครอบด้วย cy.then() ทำให้การเปลี่ยนค่าเกิดขึ้นตรงตำแหน่งที่ถูกต้องในคิว
ตัวอย่างนี้ส่ง dependency เข้าไปเป็น prop โดยตั้งใจ เพราะการ stub named export ของ ES module (import { loadStats } from './api') ทำได้ไม่แน่นอน binding ของ module ถูก assign ใหม่ไม่ได้ ทางที่ดีคือส่ง service ผ่าน props หรือ context หรือ export เป็น object ที่ stub method ได้ เช่น cy.stub(statsApi, 'load')
Intercept API ใน component test
ถ้า component ดึงข้อมูลเอง ก็ไม่ต้องมี backend cy.intercept ใช้ใน component test ได้เหมือนใน E2E test ทุกอย่าง เพราะ request จาก component ที่ mount ไว้ยังวิ่งผ่าน proxy ของ Cypress อยู่
import { UserCard } from './UserCard';
describe('<UserCard />', () => {
it('renders the user from the API', () => {
cy.intercept('GET', '/api/users/42', {
body: { id: 42, name: 'Ana Silva', role: 'admin' },
}).as('getUser');
cy.mount(<UserCard userId={42} />);
cy.wait('@getUser');
cy.findByRole('heading', { name: 'Ana Silva' }).should('be.visible');
cy.findByText('admin').should('be.visible');
});
it('shows a loading state', () => {
cy.intercept('GET', '/api/users/42', {
delay: 1000,
body: { id: 42, name: 'Ana Silva', role: 'admin' },
});
cy.mount(<UserCard userId={42} />);
cy.findByRole('progressbar').should('be.visible');
});
it('shows an error when the request fails', () => {
cy.intercept('GET', '/api/users/42', { statusCode: 500 });
cy.mount(<UserCard userId={42} />);
cy.findByRole('alert').should('contain.text', 'Something went wrong');
});
});ต้อง register intercept ก่อน cy.mount เสมอ เพราะ component อาจยิง request ทันทีที่ render URL แบบ relative จะเทียบกับ dev server ที่ Cypress เปิดขึ้นมา ถ้า component เรียก host อื่น ให้ใช้ URL เต็ม หรือ glob อย่าง **/api/users/*
state ตอนโหลดและตอน error คือจุดที่ component test คุ้มค่าที่สุด ถ้าเป็น E2E ต้องมี backend ที่ช้าหรือพังจริงถึงจะเห็น แต่ที่นี่ใช้บรรทัดเดียว ส่วนเรื่อง fixture, factory สำหรับ mock data และการตรวจ body ของ request อ่านต่อได้ใน การ mock API ใน Cypress ด้วย intercept และ Faker
รัน component test ใน CI
npx cypress run --componentเพิ่มเป็น script คู่กับ E2E:
{
"scripts": {
"test:component": "cypress run --component",
"test:e2e": "cypress run --e2e"
}
}component test ไม่ต้องเปิด app server จึงรันได้ตั้งแต่ช่วงต้นของ pipeline ต่อจาก lint และ type check ได้เลย
คำถามที่พบบ่อย
cy.spy กับ cy.stub ต่างกันอย่างไร
cy.spy บันทึกการเรียกและปล่อยให้ function เดิมทำงานต่อ ส่วน cy.stub บันทึกการเรียกและแทนที่ function เดิม คุณจึงกำหนดได้ว่าจะ return อะไรหรือจะ throw ใช้ spy เพื่อตรวจ callback และใช้ stub เพื่อตัด side effect ออก
ใช้ cy.visit ใน component test ได้ไหม
ไม่ได้ component test เริ่มด้วย cy.mount และไม่มี URL ของแอปให้ visit ถ้า test ต้องเปลี่ยนหน้าไปมา ให้เขียนเป็น E2E test หรือครอบ component ด้วย memory router
Cypress component testing ใช้แทน Jest หรือ Vitest ได้ไหม
ใช้แทน component test ที่เขียนด้วย React Testing Library บน jsdom ได้ในระดับหนึ่ง Cypress รันใน browser จริง layout, CSS และ focus จึงทำงานเหมือน production ส่วน unit test ของ function ธรรมดายังรันใน Vitest หรือ Jest ได้เร็วกว่า
ทำไม intercept ใน component test ไม่ match
ตรวจว่า register ก่อน cy.mount หรือยัง method ตรงไหม และ URL ตรงกับ URL เต็มของ request หรือไม่ รวมถึง host ถ้า component เรียกไปอีก origin หนึ่ง command log จะแสดงทุก request และบอกว่ามี route ไหน match บ้าง
Checklist
- รัน
npx cypress openเลือก Component Testing แล้วให้ Cypress สร้าง config ให้ - import global CSS และ command ของ Testing Library ใน
cypress/support/component.ts - ครอบ provider ไว้ใน
mountที่เขียนเอง เพื่อให้แต่ละ test สั้น - ใช้
cy.spy().as(...)กับ callback prop และใช้cy.stub()กับ side effect - register
cy.interceptก่อนcy.mountและทดสอบให้ครบทั้ง state โหลด ข้อมูลว่าง และ error - รัน
cypress run --componentใน CI ก่อน E2E suite ที่ช้ากว่า
แนวทางที่ใช้ได้ดีคือทดสอบทุก state ของ component ที่นี่ และเก็บ E2E test ไว้สำหรับ journey ที่ข้ามหลายหน้า ถ้าทีมของคุณต้องการคนช่วยวางระบบทดสอบแบบนี้ให้เว็บแอป Vectorkub ช่วยได้
