Skip to main content

Cypress E2E Testing: Full User Journey Guide

End-to-end testing with Cypress means navigating through your entire application as a real user would, filling forms, clicking links, navigating pages, and verifying the expected outcome. Unlike component tests (which test single components in isolation) or unit tests (which test functions), E2E tests verify that all components, pages, and services work together correctly.

An E2E test might look like: a user lands on your app, signs up with an email, verifies their email, logs in, creates a project, adds team members, and publishes the project—all in one test, all from a real browser. I've built hundreds of these tests across e-commerce, SaaS, and social applications, and the most reliable tests follow a few key patterns.

Component Tests vs. E2E Tests

Component tests (covered in earlier articles) test a single component in isolation. E2E tests test complete user journeys across an entire application:

AspectComponent TestE2E Test
ScopeSingle componentFull application workflow
Setup TimeMillisecondsSeconds (browser startup, page load)
IsolationMocked dependenciesReal application, mocked APIs
Best ForComponent logic, UI interactionsComplete workflows, integration
DebuggingEasy (real DevTools)Moderate (full app context)

A typical testing strategy includes both: component tests for fast feedback on individual features, E2E tests for confidence that features work end-to-end.

Setting Up E2E Tests

E2E tests live in a separate directory from component tests. During Cypress initialization, choose "E2E Testing" to create the cypress/e2e folder:

npx cypress open
# Choose "E2E Testing" in the Launchpad

Configure E2E tests in cypress.config.js:

module.exports = defineConfig({
e2e: {
baseUrl: 'http://localhost:3000', // Your app's URL
defaultCommandTimeout: 4000,
requestTimeout: 5000,
viewportWidth: 1280,
viewportHeight: 720,
setupNodeEvents(on, config) {
// Optional: register plugins or event listeners
},
},
})

Writing Your First E2E Test

A simple E2E test verifies that a user can navigate your app:

// cypress/e2e/homepage.cy.js
describe('Homepage', () => {
it('loads and displays welcome message', () => {
// Navigate to the app
cy.visit('/')

// Verify page title
cy.get('h1').should('contain', 'Welcome')

// Verify navigation is present
cy.get('[data-testid="nav-home"]').should('exist')
cy.get('[data-testid="nav-about"]').should('exist')
})
})

Run with npx cypress open --e2e, click the test, and watch it navigate and verify.

Testing User Signup Workflows

Here's a realistic signup flow:

// cypress/e2e/auth/signup.cy.js
describe('User Signup', () => {
beforeEach(() => {
// Navigate to signup page before each test
cy.visit('/signup')
})

it('allows user to sign up with email', () => {
// Fill form fields
cy.get('[data-testid="email-input"]').type('[email protected]')
cy.get('[data-testid="password-input"]').type('SecurePassword123!')
cy.get('[data-testid="confirm-password-input"]').type('SecurePassword123!')
cy.get('[data-testid="agree-terms"]').check()

// Mock the signup API
cy.intercept('POST', '/api/auth/signup', {
statusCode: 201,
body: { userId: 42, email: '[email protected]', token: 'jwt_token_here' },
}).as('signup')

// Submit the form
cy.get('[data-testid="signup-button"]').click()

// Verify API was called
cy.wait('@signup')

// Verify redirect to dashboard
cy.url().should('include', '/dashboard')

// Verify user is logged in (e.g., user menu shows email)
cy.get('[data-testid="user-menu"]').should('contain', '[email protected]')
})

it('shows error for invalid email', () => {
cy.get('[data-testid="email-input"]').type('not-an-email')
cy.get('[data-testid="password-input"]').type('password')
cy.get('[data-testid="signup-button"]').click()

// Verify error message
cy.get('[data-testid="email-error"]').should('contain', 'Valid email required')
})

it('prevents signup if passwords do not match', () => {
cy.get('[data-testid="email-input"]').type('[email protected]')
cy.get('[data-testid="password-input"]').type('Password123!')
cy.get('[data-testid="confirm-password-input"]').type('DifferentPassword!')

cy.get('[data-testid="signup-button"]').click()

cy.get('[data-testid="password-error"]').should('contain', 'Passwords do not match')
})
})

Testing Login and Authentication Flows

Verify that users can log in and access protected pages:

// cypress/e2e/auth/login.cy.js
describe('User Login', () => {
it('logs in user and persists session', () => {
cy.visit('/login')

// Fill login form
cy.get('[data-testid="email-input"]').type('[email protected]')
cy.get('[data-testid="password-input"]').type('Password123!')

// Mock successful login
cy.intercept('POST', '/api/auth/login', {
statusCode: 200,
body: {
token: 'jwt_token_abc123',
user: { id: 1, email: '[email protected]', name: 'John Doe' },
},
}).as('login')

cy.get('[data-testid="login-button"]').click()

cy.wait('@login')

// Should redirect to dashboard
cy.url().should('include', '/dashboard')

// Token should be stored (in localStorage, sessionStorage, or cookie)
cy.window().then((win) => {
expect(win.localStorage.getItem('authToken')).to.exist
})
})

it('denies access to protected pages without auth', () => {
// Clear any stored auth token
cy.clearLocalStorage('authToken')

// Try to visit protected page
cy.visit('/dashboard', { failOnStatusCode: false })

// Should redirect to login
cy.url().should('include', '/login')
})

it('allows access to protected page with valid token', () => {
// Set auth token (simulating logged-in state)
cy.window().then((win) => {
win.localStorage.setItem('authToken', 'valid_token_123')
})

// Mock API that verifies token
cy.intercept('GET', '/api/auth/verify', {
statusCode: 200,
body: { valid: true, user: { id: 1 } },
})

cy.visit('/dashboard')

// Should load dashboard
cy.get('[data-testid="dashboard-header"]').should('contain', 'Dashboard')
})
})

Testing Multi-Step Workflows

Test workflows that span multiple pages:

// cypress/e2e/workflows/create-project.cy.js
describe('Create Project Workflow', () => {
beforeEach(() => {
// Set up authenticated user
cy.window().then((win) => {
win.localStorage.setItem('authToken', 'valid_token')
})

cy.intercept('GET', '/api/auth/verify', {
statusCode: 200,
body: { valid: true, user: { id: 1 } },
})
})

it('completes multi-step project creation', () => {
// Step 1: Navigate to projects page
cy.visit('/dashboard/projects')
cy.get('[data-testid="new-project-button"]').click()

// Step 2: Fill project details
cy.url().should('include', '/projects/new')
cy.get('[data-testid="project-name"]').type('My Awesome Project')
cy.get('[data-testid="project-description"]').type('A great project')
cy.get('[data-testid="next-button"]').click()

// Step 3: Choose project template
cy.url().should('include', '/projects/new/template')
cy.get('[data-testid="template-react"]').click()
cy.get('[data-testid="next-button"]').click()

// Step 4: Configure settings
cy.url().should('include', '/projects/new/settings')
cy.get('[data-testid="visibility-public"]').check()
cy.get('[data-testid="create-button"]').click()

// Mock project creation API
cy.intercept('POST', '/api/projects', {
statusCode: 201,
body: {
id: 1,
name: 'My Awesome Project',
slug: 'my-awesome-project',
},
}).as('createProject')

cy.wait('@createProject')

// Should redirect to project page
cy.url().should('include', '/projects/my-awesome-project')
cy.get('h1').should('contain', 'My Awesome Project')
})
})

Testing Form Submissions and Validation

Verify that forms validate input and submit correctly:

// cypress/e2e/forms/contact-form.cy.js
describe('Contact Form', () => {
beforeEach(() => {
cy.visit('/contact')
})

it('validates form before submission', () => {
// Try to submit empty form
cy.get('[data-testid="submit-button"]').click()

// Verify validation errors appear
cy.get('[data-testid="name-error"]').should('be.visible')
cy.get('[data-testid="email-error"]').should('be.visible')
cy.get('[data-testid="message-error"]').should('be.visible')
})

it('submits form with valid data', () => {
// Fill form
cy.get('[data-testid="name-input"]').type('John Smith')
cy.get('[data-testid="email-input"]').type('[email protected]')
cy.get('[data-testid="message-input"]').type('I have a question about your service')

// Mock form submission
cy.intercept('POST', '/api/contact', {
statusCode: 200,
body: { success: true, id: 'contact_123' },
}).as('submitContact')

cy.get('[data-testid="submit-button"]').click()

cy.wait('@submitContact')

// Verify success message
cy.get('[data-testid="success-message"]').should(
'contain',
'Thank you for contacting us'
)

// Verify form is reset
cy.get('[data-testid="name-input"]').should('have.value', '')
})
})

Testing Navigation and Routing

Verify that navigation works correctly:

describe('Navigation', () => {
it('navigates between pages via links', () => {
cy.visit('/')

// Click About link
cy.get('[data-testid="nav-about"]').click()
cy.url().should('include', '/about')
cy.get('h1').should('contain', 'About')

// Click Back to Home
cy.get('[data-testid="nav-home"]').click()
cy.url().should('equal', Cypress.config('baseUrl') + '/')
})

it('maintains state during navigation', () => {
// Fill form on page 1
cy.visit('/form')
cy.get('[data-testid="name-input"]').type('John')

// Navigate away
cy.get('[data-testid="nav-home"]').click()

// Navigate back (browser may restore state, test accordingly)
cy.visit('/form')

// Depending on app behavior, form might be empty or populated
// Adjust assertion based on your implementation
})
})

Debugging E2E Tests

Cypress provides rich debugging tools for E2E tests:

it('test with debugging', () => {
cy.visit('/')

// Pause execution and open DevTools
cy.pause()

// Or take a screenshot at a specific point
cy.screenshot('after-login')

// Or save debug logs
cy.log('User logged in successfully')

// Run a debug step
cy.get('button').debug()
})

Key Takeaways

  • E2E tests verify complete user workflows across the entire application, unlike component tests which test single components.
  • Use cy.visit() to navigate to pages, fill forms with cy.get() and cy.type(), and assert on the result.
  • Mock API calls with cy.intercept() to control external dependencies and make tests reliable.
  • Test authentication flows by setting auth tokens and verifying protected pages are accessible.
  • Test multi-step workflows by navigating through pages, filling forms, and verifying redirects.
  • Use beforeEach to set up common state (login, navigation to page) for all tests in a suite.

Frequently Asked Questions

Should I run E2E tests against a real backend or mock APIs?

In development and CI, mock all external APIs with cy.intercept() to keep tests fast and reliable. In staging/production environments, you can run E2E tests against real servers, but always use test accounts and avoid critical operations.

How do I test features that depend on third-party services (Stripe, Twilio, etc.)?

Mock the API endpoint that your app calls. For example, if your app calls /api/create-charge which internally calls Stripe, mock the /api/create-charge endpoint. The key is mocking the boundary between your app and external services.

How do I handle authentication in E2E tests?

Set auth tokens or cookies in beforeEach by directly manipulating localStorage, sessionStorage, or using cy.setCookie(). Alternatively, include a login step in each test (slower but more realistic).

Can I run E2E tests in parallel?

Yes. Cypress supports parallel test execution. However, use separate test data or test accounts for each parallel run to avoid conflicts. Configure parallelization in your CI provider (GitHub Actions, GitLab CI, etc.).

How do I test websocket connections in E2E tests?

Cypress doesn't have built-in WebSocket support, but you can mock WebSocket connections using a library or disable WebSockets in tests and use a fallback mechanism (like polling). Alternatively, test WebSocket features through integration tests that use a real server.

Further Reading