E2E Testing Vue and Nuxt Apps with Cypress
Cypress provides two testing modes for Vue and Nuxt apps: component testing (mounts components in isolation inside a real browser) and E2E testing (drives a full running app). This guide covers both modes, with practical patterns for navigation, form testing, and API mocking with cy.intercept.
Key Takeaways
Component testing vs E2E is a scope decision, not a replacement. Use component tests for fast, isolated UI verification; use E2E for user flows that cross pages and involve real server interactions.
cy.intercept stubs network requests before they leave the browser. Set up intercepts before the action that triggers the request — not after — or the real request fires first.
Chain assertions with .should(), not expect(). Cypress automatically retries .should() assertions until they pass or time out; expect() runs once and fails immediately on timing issues.
Use data-cy attributes for E2E selectors. CSS classes and text content change frequently — data-cy attributes are stable contracts between your tests and your markup.
Alias intercepts with .as() and wait with cy.wait('@alias'). Waiting for a named intercept ensures your assertions run after the network round-trip completes, not before.
Cypress is a developer-friendly E2E testing tool that runs tests inside a real browser, giving you confidence that what you test is exactly what users experience. For Vue and Nuxt applications, Cypress supports both full E2E testing and isolated component testing — each with distinct strengths.
Installation
npm install -D cypress
npx cypress openCypress will auto-detect your framework and generate the initial configuration. For Nuxt, select "E2E Testing" and then choose your preferred browser. For component testing, select "Component Testing" and choose Vue.
The generated cypress.config.ts for a Nuxt app looks like:
import { defineConfig } from 'cypress'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.cy.ts',
},
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
specPattern: 'src/**/*.cy.ts',
},
})Component Testing vs E2E Testing
Component Testing
Cypress mounts your Vue component inside a real browser, bypassing the need for a running server:
// UserCard.cy.ts
import UserCard from './UserCard.vue'
describe('UserCard', () => {
it('displays user name and role', () => {
cy.mount(UserCard, {
props: {
name: 'Alice',
role: 'Admin',
avatarUrl: 'https://example.com/avatar.jpg',
},
})
cy.get('.user-name').should('contain', 'Alice')
cy.get('.user-role').should('contain', 'Admin')
cy.get('.user-avatar').should('have.attr', 'src', 'https://example.com/avatar.jpg')
})
it('emits logout event when button clicked', () => {
const onLogout = cy.stub().as('logoutStub')
cy.mount(UserCard, {
props: {
name: 'Alice',
role: 'Admin',
onLogout,
},
})
cy.get('[data-cy="logout-btn"]').click()
cy.get('@logoutStub').should('have.been.calledOnce')
})
})Use component tests for: visual regression, prop variations, event emission, slot rendering.
E2E Testing
E2E tests drive a real running app through a browser:
// cypress/e2e/login.cy.ts
describe('Login flow', () => {
beforeEach(() => {
cy.visit('/login')
})
it('logs in with valid credentials', () => {
cy.get('[data-cy="email-input"]').type('user@example.com')
cy.get('[data-cy="password-input"]').type('correctpassword')
cy.get('[data-cy="submit-btn"]').click()
cy.url().should('include', '/dashboard')
cy.get('[data-cy="welcome-message"]').should('contain', 'Welcome back')
})
it('shows error with invalid credentials', () => {
cy.get('[data-cy="email-input"]').type('user@example.com')
cy.get('[data-cy="password-input"]').type('wrongpassword')
cy.get('[data-cy="submit-btn"]').click()
cy.url().should('include', '/login')
cy.get('[data-cy="error-message"]').should('contain', 'Invalid credentials')
})
})Use E2E tests for: critical user journeys, multi-page flows, authentication, checkout.
Testing Navigation
Cypress handles Vue Router and Nuxt routing naturally because it drives a real browser:
describe('Navigation', () => {
it('navigates between pages using the main menu', () => {
cy.visit('/')
cy.get('[data-cy="nav-about"]').click()
cy.url().should('include', '/about')
cy.get('h1').should('contain', 'About Us')
cy.get('[data-cy="nav-pricing"]').click()
cy.url().should('include', '/pricing')
cy.get('[data-cy="pricing-title"]').should('be.visible')
})
it('redirects to login when accessing protected route', () => {
cy.visit('/dashboard')
cy.url().should('include', '/login')
cy.get('[data-cy="auth-required-message"]').should('exist')
})
it('preserves redirect after login', () => {
cy.visit('/settings')
cy.url().should('include', '/login?redirect=/settings')
cy.get('[data-cy="email-input"]').type('user@example.com')
cy.get('[data-cy="password-input"]').type('password')
cy.get('[data-cy="submit-btn"]').click()
cy.url().should('include', '/settings')
})
})Testing Forms
Forms are among the most important things to test E2E. Cypress provides natural input interactions:
describe('Contact form', () => {
beforeEach(() => {
cy.visit('/contact')
})
it('submits form with valid data', () => {
cy.intercept('POST', '/api/contact', { statusCode: 200, body: { success: true } }).as('submitContact')
cy.get('[data-cy="name-input"]').type('Bob Smith')
cy.get('[data-cy="email-input"]').type('bob@example.com')
cy.get('[data-cy="subject-select"]').select('Technical Support')
cy.get('[data-cy="message-textarea"]').type('I need help with my account.')
cy.get('[data-cy="submit-btn"]').click()
cy.wait('@submitContact').its('request.body').should('deep.include', {
name: 'Bob Smith',
email: 'bob@example.com',
})
cy.get('[data-cy="success-message"]').should('be.visible')
cy.get('[data-cy="success-message"]').should('contain', 'Message sent')
})
it('shows validation errors for empty required fields', () => {
cy.get('[data-cy="submit-btn"]').click()
cy.get('[data-cy="name-error"]').should('contain', 'Name is required')
cy.get('[data-cy="email-error"]').should('contain', 'Email is required')
cy.get('[data-cy="message-error"]').should('contain', 'Message is required')
})
it('validates email format', () => {
cy.get('[data-cy="email-input"]').type('not-an-email')
cy.get('[data-cy="submit-btn"]').click()
cy.get('[data-cy="email-error"]').should('contain', 'Please enter a valid email')
})
})API Mocking with cy.intercept
cy.intercept is Cypress's request interception API. It stubs network requests at the browser level, before they reach the server.
Basic Intercept
cy.intercept('GET', '/api/posts', {
statusCode: 200,
body: [
{ id: 1, title: 'Hello World', author: 'Alice' },
{ id: 2, title: 'Getting Started', author: 'Bob' },
],
}).as('getPosts')
cy.visit('/blog')
cy.wait('@getPosts')
cy.get('[data-cy="post-card"]').should('have.length', 2)
cy.get('[data-cy="post-card"]').first().should('contain', 'Hello World')Intercept with Request Inspection
Inspect what your app actually sent:
it('sends correct payload on form submit', () => {
cy.intercept('POST', '/api/users', (req) => {
expect(req.body.role).toBe('admin')
req.reply({ statusCode: 201, body: { id: 99, ...req.body } })
}).as('createUser')
cy.get('[data-cy="name-input"]').type('Charlie')
cy.get('[data-cy="role-select"]').select('admin')
cy.get('[data-cy="save-btn"]').click()
cy.wait('@createUser')
cy.get('[data-cy="success-toast"]').should('be.visible')
})Simulating Error States
it('shows error state when API fails', () => {
cy.intercept('GET', '/api/products', {
statusCode: 500,
body: { error: 'Internal server error' },
}).as('getProducts')
cy.visit('/products')
cy.wait('@getProducts')
cy.get('[data-cy="error-state"]').should('be.visible')
cy.get('[data-cy="retry-btn"]').should('exist')
})Intercepting Nuxt Server Routes
For Nuxt apps, server routes follow the same cy.intercept pattern since they are just HTTP calls:
cy.intercept('GET', '/api/session', {
statusCode: 200,
body: { user: { id: 1, name: 'Alice' }, expiresAt: '2026-06-01' },
}).as('getSession')Authentication in E2E Tests
Re-logging in before every test is slow and brittle. Use programmatic authentication instead:
// cypress/support/commands.ts
Cypress.Commands.add('login', (email: string, password: string) => {
cy.request({
method: 'POST',
url: '/api/auth/login',
body: { email, password },
}).then((response) => {
// Store the token in localStorage or as a cookie
window.localStorage.setItem('auth-token', response.body.token)
})
})describe('Dashboard (authenticated)', () => {
beforeEach(() => {
cy.login('admin@example.com', 'password')
cy.visit('/dashboard')
})
it('shows admin controls for admin users', () => {
cy.get('[data-cy="admin-panel"]').should('be.visible')
})
})For Nuxt with server-side sessions, use cy.request to hit the login endpoint directly, then visit the protected page:
Cypress.Commands.add('loginViaApi', () => {
cy.request('POST', '/api/auth/login', {
email: Cypress.env('TEST_USER_EMAIL'),
password: Cypress.env('TEST_USER_PASSWORD'),
})
})Custom Commands for Reusability
Extract repetitive interactions into custom commands:
// cypress/support/commands.ts
Cypress.Commands.add('fillContactForm', (data: {
name: string
email: string
message: string
}) => {
cy.get('[data-cy="name-input"]').clear().type(data.name)
cy.get('[data-cy="email-input"]').clear().type(data.email)
cy.get('[data-cy="message-textarea"]').clear().type(data.message)
})
// In tests
cy.fillContactForm({ name: 'Alice', email: 'alice@example.com', message: 'Hello!' })
cy.get('[data-cy="submit-btn"]').click()Selectors Best Practices
Bad selectors cause test brittleness:
// Fragile — breaks when class or text changes
cy.get('.btn-primary').click()
cy.contains('Submit').click()
// Stable — explicit test contract
cy.get('[data-cy="submit-btn"]').click()Add data-cy attributes to elements that tests interact with:
<!-- Vue template -->
<button data-cy="submit-btn" type="submit" class="btn btn-primary">
Submit
</button>Strip data-cy attributes from production builds using a Vite plugin or a babel transform to keep the production bundle clean.
Running Cypress in CI
For CI pipelines, run Cypress in headless mode:
# Start Nuxt dev server in background, then run Cypress
nuxt dev &
npx wait-on http://localhost:3000
npx cypress run --e2e --browser chromeOr use @cypress/github-action for GitHub Actions:
- name: Run Cypress E2E tests
uses: cypress-io/github-action@v6
with:
start: npm run dev
wait-on: 'http://localhost:3000'
browser: chromeDebugging Failures
Cypress's time-travel debugger lets you step through test commands and inspect the DOM at each point. When a test fails in CI, use cy.screenshot() or Cypress Cloud's automatic screenshot capture to see exactly what the browser showed when the assertion failed.
afterEach(function () {
if (this.currentTest?.state === 'failed') {
cy.screenshot(`failed-${this.currentTest.title}`)
}
})Cypress turns E2E testing from a fragile afterthought into a reliable part of your Vue and Nuxt development workflow. The key is combining stable selectors, programmatic authentication, and precise cy.intercept mocking to write tests that catch real regressions without being brittle.