Cypress Complete Guide | E2E Testing, Automation & CI/CD

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

// 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-testid attributes 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()) in beforeEach to keep tests fast
  • Group setup in beforeEach, teardown in afterEach
  • Keep tests independent — each test should be runnable alone

Avoid

  • cy.wait(1000) (arbitrary sleeps) — use aliases with cy.wait('@alias') instead
  • Shared mutable state between tests — always reset state
  • Relying on CSS class names or IDs that change with design iterations
  • Nesting describe blocks 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() and cy.get()
  • Add data-testid attributes 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

  • Playwright Complete Guide
  • Jest Complete Guide
  • GitHub Actions CI/CD Guide