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

Frontend

Cypress Component Testing: Mount, Spy, Stub และ Intercept API

ตั้งค่า Cypress component testing ให้ React และ Vite, mount component ด้วย cy.mount, ตรวจ callback ด้วย cy.spy, เปลี่ยนค่าที่ return ด้วย cy.stub และ mock API ด้วย cy.intercept

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

cypress component testing

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 testE2E test
สิ่งที่รันcomponent ตัวเดียว เสิร์ฟผ่าน bundler ของ dev serverทั้งแอปผ่าน URL
จุดเริ่มต้นcy.mount(<Component />)cy.visit('/path')
ต้องมี backend ไหมไม่ต้อง mock request ด้วย cy.interceptส่วนใหญ่ต้องมี หรือต้อง mock
ความเร็วเร็ว ไม่ต้องผ่าน routing หรือ loginช้ากว่า
เหมาะกับstate, edge case, props และ callbackuser journey ที่ข้ามหลายหน้า
ตำแหน่งไฟล์ specอยู่ข้าง component เช่น Button.cy.tsxcypress/e2e/*.cy.ts

ทีมส่วนใหญ่ใช้ทั้งสองแบบ คือ E2E test จำนวนไม่มากสำหรับ flow สำคัญ และ component test สำหรับพฤติกรรมละเอียดของแต่ละชิ้น

ตั้งค่า Cypress component testing

ติดตั้ง Cypress แล้วเปิดขึ้นมา:

bash
npm install --save-dev cypress
npx cypress open

เลือก Component Testing ในหน้า launcher Cypress จะตรวจว่าโปรเจกต์ใช้ framework และ bundler อะไร ติดตั้งส่วนที่ขาด แล้วสร้างไฟล์ให้:

  • cypress.config.ts ที่มี section component
  • cypress/support/component.ts ที่ register cy.mount
  • cypress/support/component-index.html หน้าที่ใช้ mount component

config ของโปรเจกต์ React + Vite หน้าตาแบบนี้:

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

ts
// 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 ตามปกติ:

tsx
// 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 แทน:

tsx
// 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 ถูกเรียกถูกต้องหรือไม่:

tsx
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 ได้:

ts
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 ชำระเงิน หรืออ่านเวลาปัจจุบัน

tsx
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 ได้:

ts
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 พฤติกรรมใหม่จะทับของเดิม ซึ่งมีประโยชน์มากเวลาทดสอบปุ่ม "ลองอีกครั้ง":

tsx
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 อยู่

tsx
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

bash
npx cypress run --component

เพิ่มเป็น script คู่กับ E2E:

json
{
  "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

  1. รัน npx cypress open เลือก Component Testing แล้วให้ Cypress สร้าง config ให้
  2. import global CSS และ command ของ Testing Library ใน cypress/support/component.ts
  3. ครอบ provider ไว้ใน mount ที่เขียนเอง เพื่อให้แต่ละ test สั้น
  4. ใช้ cy.spy().as(...) กับ callback prop และใช้ cy.stub() กับ side effect
  5. register cy.intercept ก่อน cy.mount และทดสอบให้ครบทั้ง state โหลด ข้อมูลว่าง และ error
  6. รัน cypress run --component ใน CI ก่อน E2E suite ที่ช้ากว่า

แนวทางที่ใช้ได้ดีคือทดสอบทุก state ของ component ที่นี่ และเก็บ E2E test ไว้สำหรับ journey ที่ข้ามหลายหน้า ถ้าทีมของคุณต้องการคนช่วยวางระบบทดสอบแบบนี้ให้เว็บแอป Vectorkub ช่วยได้

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

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

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