Cypress Complete Guide | E2E Testing, Automation & CI/CD
이 글의 핵심
Cypress is a modern E2E testing framework that eliminates flaky tests through automatic waiting, offers Time Travel debugging, and integrates seamlessly into CI/CD pipelines. This guide walks you through everything from setup to advanced patterns.
What This Guide Covers
Cypress is the go-to E2E testing framework for modern frontend teams. After switching from Selenium to Cypress, many teams report 60% less time writing tests and dramatically fewer flaky failures — because Cypress automatically waits for elements instead of relying on arbitrary sleep() calls.
This guide takes you from zero to production-ready Cypress setup, covering:
- Installation and configuration
- Writing your first tests
- API mocking with
cy.intercept() - Test data with Fixtures
- Custom Commands for DRY tests
- CI/CD integration with GitHub Actions
- Component Testing
1. What is Cypress?
Cypress is a JavaScript-native E2E testing framework that runs directly in the browser. Unlike Selenium-based tools that communicate via WebDriver over HTTP, Cypress executes inside the browser itself — giving it direct access to the DOM, network layer, and application code.
Key advantages:
- Automatic waiting: Cypress retries commands until elements are available, eliminating
sleep()hacks - Time Travel: Step through each command with before/after DOM snapshots in the Test Runner
- Real-time reload: Tests re-run instantly on file save
- Automatic screenshots & video: Captured on failure (or always, configurable)
- Network control: Intercept and mock HTTP requests at the browser level
Supported browsers: Chrome, Edge, Firefox, Electron (headless)
2. Installation & Setup
Install
npm install -D cypress
Open the Test Runner
npx cypress open
This launches the Cypress UI where you choose between E2E Testing and Component Testing.
cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.{js,ts}',
setupNodeEvents(on, config) {
// register node event listeners here
},
},
viewportWidth: 1280,
viewportHeight: 720,
video: true,
screenshotOnRunFailure: true,
retries: {
runMode: 2, // retry failed tests in CI
openMode: 0, // no retries in interactive mode
},
});
Project structure
cypress/
e2e/ ← test files (*.cy.ts)
fixtures/ ← static test data (JSON)
support/
commands.ts ← custom commands
e2e.ts ← global setup (imported automatically)
cypress.config.ts
3. Writing Your First Tests
Login test
// cypress/e2e/login.cy.ts
describe('Login', () => {
beforeEach(() => {
cy.visit('/login');
});
it('logs in successfully with valid credentials', () => {
cy.get('input[name="email"]').type('john@example.com');
cy.get('input[name="password"]').type('password123');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome, John').should('be.visible');
});
it('shows an error for invalid credentials', () => {
cy.get('input[name="email"]').type('john@example.com');
cy.get('input[name="password"]').type('wrong-password');
cy.get('button[type="submit"]').click();
cy.contains('Invalid credentials').should('be.visible');
cy.url().should('include', '/login'); // stays on login page
});
});
Best practice: Use data-testid attributes for stable selectors that survive CSS/structure changes:
<button data-testid="submit-btn">Log In</button>
cy.get('[data-testid="submit-btn"]').click();
4. Core Commands Reference
Navigation & Querying
// Navigate
cy.visit('/');
cy.visit('https://example.com');
cy.go('back');
cy.reload();
// Query elements
cy.get('.btn'); // CSS selector
cy.get('[data-testid="user-list"]'); // data attribute (recommended)
cy.contains('Submit'); // by text content
cy.contains('li', 'Item 1'); // element + text combo
cy.get('form').find('input'); // scoped query
Actions
cy.get('input[name="email"]').type('user@example.com');
cy.get('input[name="email"]').clear().type('new@example.com');
cy.get('button').click();
cy.get('button').dblclick();
cy.get('select').select('Option 1');
cy.get('input[type="checkbox"]').check();
cy.get('input[type="checkbox"]').uncheck();
cy.get('input[type="file"]').selectFile('cypress/fixtures/photo.jpg');
Assertions (.should())
// Text
cy.get('.title').should('have.text', 'Hello World');
cy.get('.title').should('contain', 'Hello');
// Visibility
cy.get('.modal').should('be.visible');
cy.get('.modal').should('not.exist');
// State
cy.get('button').should('be.disabled');
cy.get('input').should('have.value', 'test@example.com');
// URL
cy.url().should('include', '/dashboard');
cy.url().should('eq', 'http://localhost:3000/dashboard');
// Multiple assertions (all run before moving on)
cy.get('.card')
.should('be.visible')
.and('have.class', 'active')
.and('contain', 'John');
5. API Mocking with cy.intercept()
cy.intercept() lets you control network requests — stub responses, add delays, or assert that specific calls were made.
Stubbing a GET request
describe('User list', () => {
beforeEach(() => {
cy.intercept('GET', '/api/users', {
statusCode: 200,
body: [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
],
}).as('getUsers');
cy.visit('/users');
});
it('displays users returned by the API', () => {
cy.wait('@getUsers'); // wait for the intercepted request
cy.contains('Alice').should('be.visible');
cy.contains('Bob').should('be.visible');
});
});
Stubbing a POST and asserting the request body
it('creates a new user', () => {
cy.intercept('POST', '/api/users', {
statusCode: 201,
body: { id: 3, name: 'Carol' },
}).as('createUser');
cy.get('[data-testid="new-user-btn"]').click();
cy.get('input[name="name"]').type('Carol');
cy.get('button[type="submit"]').click();
cy.wait('@createUser').its('request.body').should('deep.equal', { name: 'Carol' });
cy.contains('Carol').should('be.visible');
});
Simulating errors
cy.intercept('GET', '/api/data', { statusCode: 500 }).as('serverError');
cy.visit('/dashboard');
cy.wait('@serverError');
cy.contains('Something went wrong').should('be.visible');
6. Fixtures
Fixtures are static JSON files that store test data. Keep test data in fixtures to separate it from test logic.
cypress/fixtures/users.json
[
{ "id": 1, "name": "Alice", "email": "alice@example.com", "role": "admin" },
{ "id": 2, "name": "Bob", "email": "bob@example.com", "role": "user" }
]
Using fixtures in tests
describe('User list', () => {
beforeEach(() => {
cy.fixture('users').then((users) => {
cy.intercept('GET', '/api/users', users).as('getUsers');
});
cy.visit('/users');
});
it('shows all users from fixture', () => {
cy.wait('@getUsers');
cy.get('[data-testid="user-row"]').should('have.length', 2);
cy.contains('Alice').should('be.visible');
});
});
You can also load fixtures with this context using function() syntax:
beforeEach(function () {
cy.fixture('users').as('usersData');
});
it('uses fixture data', function () {
expect(this.usersData).to.have.length(2);
});
7. Custom Commands
Custom commands let you extract repetitive sequences into reusable helpers — keeping your tests DRY.
Define a login command
// cypress/support/commands.ts
declare global {
namespace Cypress {
interface Chainable {
login(email: string, password: string): Chainable<void>;
loginByApi(email: string, password: string): Chainable<void>;
}
}
}
// UI-based login
Cypress.Commands.add('login', (email, password) => {
cy.visit('/login');
cy.get('input[name="email"]').type(email);
cy.get('input[name="password"]').type(password);
cy.get('button[type="submit"]').click();
cy.url().should('not.include', '/login');
});
// API-based login (faster — skips the UI)
Cypress.Commands.add('loginByApi', (email, password) => {
cy.request('POST', '/api/auth/login', { email, password })
.its('body.token')
.then((token) => {
window.localStorage.setItem('auth_token', token);
});
cy.visit('/dashboard');
});
Using custom commands in tests
describe('Dashboard', () => {
beforeEach(() => {
cy.loginByApi('alice@example.com', 'password123'); // fast API login
});
it('shows the dashboard header', () => {
cy.contains('Welcome, Alice').should('be.visible');
});
});
Tip: Use API-based login for most tests to avoid slow UI flows in beforeEach. Only test the login UI itself in the login spec.
8. Handling Async & Network Timing
cy.wait() for aliases
cy.intercept('GET', '/api/products').as('getProducts');
cy.visit('/products');
cy.wait('@getProducts'); // waits up to defaultCommandTimeout
// Assert on the response
cy.wait('@getProducts').its('response.statusCode').should('eq', 200);
Multiple waits
cy.wait(['@getUser', '@getOrders']).then(([userReq, ordersReq]) => {
expect(userReq.response.statusCode).to.eq(200);
expect(ordersReq.response.body).to.have.length.greaterThan(0);
});
Custom timeouts
// Override timeout for a slow assertion
cy.get('[data-testid="report"]', { timeout: 10000 }).should('be.visible');
9. CI/CD Integration
GitHub Actions
# .github/workflows/cypress.yml
name: Cypress E2E Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
cypress-run:
runs-on: ubuntu-latest
strategy:
matrix:
# run 3 parallel containers
containers: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run Cypress tests
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm run preview
wait-on: 'http://localhost:4173'
record: true # requires CYPRESS_RECORD_KEY
parallel: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Running headlessly in CI
# Run all E2E tests headlessly
npx cypress run
# Run a specific spec
npx cypress run --spec "cypress/e2e/login.cy.ts"
# Run in a specific browser
npx cypress run --browser chrome
10. Component Testing
Cypress 10+ includes a Component Testing mode that mounts components in isolation — no full server needed.
Setup (cypress.config.ts)
import { defineConfig } from 'cypress';
import { devServer } from '@cypress/vite-dev-server';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
},
});
Writing a component test
// src/components/Button.cy.tsx
import Button from './Button';
describe('Button', () => {
it('renders the label', () => {
cy.mount(<Button label="Click me" />);
cy.contains('Click me').should('be.visible');
});
it('calls onClick when clicked', () => {
const onClickSpy = cy.spy().as('onClickSpy');
cy.mount(<Button label="Click me" onClick={onClickSpy} />);
cy.contains('Click me').click();
cy.get('@onClickSpy').should('have.been.calledOnce');
});
it('is disabled when loading', () => {
cy.mount(<Button label="Submit" loading={true} />);
cy.get('button').should('be.disabled');
});
});
Component tests run faster than E2E tests and are ideal for testing complex UI interactions in isolation.
11. Best Practices
Do
- Use
data-testidattributes as selectors — they survive refactoring - Use
cy.intercept()to stub external APIs so tests don’t depend on real backends - Log in via API (
cy.request()) inbeforeEachto keep tests fast - Group setup in
beforeEach, teardown inafterEach - Keep tests independent — each test should be runnable alone
Avoid
cy.wait(1000)(arbitrary sleeps) — use aliases withcy.wait('@alias')instead- Shared mutable state between tests — always reset state
- Relying on CSS class names or IDs that change with design iterations
- Nesting
describeblocks more than 2 levels deep
Summary & Checklist
Core concepts:
- Cypress: Browser-native E2E test framework
- cy.intercept(): Mock HTTP requests and assert on them
- Fixtures: Separate test data from test logic
- Custom Commands: Reusable test helpers
- Component Testing: Mount components in isolation (Cypress 10+)
Implementation checklist:
- Install Cypress and configure
cypress.config.ts - Write your first E2E test with
cy.visit()andcy.get() - Add
data-testidattributes to your UI components - Mock API calls with
cy.intercept() - Create a
cy.login()custom command - Add fixtures for reusable test data
- Configure GitHub Actions for CI/CD
- Enable parallel test runs with Cypress Cloud
Related Posts
- Playwright Complete Guide
- Jest Complete Guide
- GitHub Actions CI/CD Guide