End-to-End Testing

An end-to-end test drives the built app in a real browser: what it catches, how Playwright and Cypress differ, and what it costs.

Syntaxtest('name', async ({ page }) => { await page.goto('/') })

An end-to-end test drives a real browser through the application you ship. It opens a URL, clicks what a person would click, and asserts on what the screen shows. Nothing is faked unless you fake it on purpose.

What a component test cannot see

A component test mounts one component with props you supply. It never exercises the router, so it cannot prove that signing in lands on /dashboard. It never carries a Pinia store across a navigation, never calls your real API layer, and never runs the production build. A CSS rule that covers the submit button passes every component test and breaks every user. Those are the failures end-to-end tests exist for.

Playwright and Cypress

npm create vue@latest offers both. Playwright runs your test file in Node and drives Chromium, Firefox and WebKit over a control protocol; its runner is parallel by default and its expect assertions retry until they pass or time out. Cypress runs the test code inside the browser next to the app, which makes its interactive runner pleasant for debugging. Either is defensible. The examples here use Playwright.

What it costs

A component test takes milliseconds. An end-to-end test takes seconds, needs a server and a build, and downloads browser binaries before it can start. Thirty tests are a few minutes; three hundred are something people start skipping. Choose the count deliberately rather than letting it grow.

Example

Example · vue
// tests/e2e/counter.spec.ts
import { test, expect } from '@playwright/test'

test('the dashboard count survives a route change', async ({ page }) => {
  await page.goto('/dashboard')

  const increment = page.getByRole('button', { name: 'Add one' })
  await increment.click()
  await increment.click()

  await expect(page.getByTestId('count')).toHaveText('2')

  await page.getByRole('link', { name: 'Settings' }).click()
  await expect(page).toHaveURL(/\/settings$/)

  await page.getByRole('link', { name: 'Dashboard' }).click()
  await expect(page.getByTestId('count')).toHaveText('2')
})

When to use it

  • Prove the sign-in redirect still works against the built app before a release.
  • Catch a CSS change that slides the checkout button under a sticky footer.
  • Confirm a Pinia store keeps the cart when the user navigates between routes.
  • Smoke-test a deployed preview URL after every build.

More examples

The config that starts the app for you

webServer builds the app and starts Vite's preview server on port 4173, then waits for that url to answer before the first test runs. baseURL is what makes page.goto('/dashboard') a relative path. reuseExistingServer keeps a dev loop from restarting the server on every run while CI always starts a clean one. trace: 'on-first-retry' records a replayable trace only for tests that failed once, which is the setting you want when a test fails on CI and passes locally.

Example · typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests/e2e',
  use: {
    baseURL: 'http://localhost:4173',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: {
    command: 'npm run build && npm run preview',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
})

The same journey in Cypress

Cypress chains commands instead of awaiting them, and its should assertions retry the same way Playwright's expect does. Reach for this version when the team already runs Cypress, or when you want the time-travelling runner to step through a failure frame by frame. Note that the element is re-queried on the second click: a Cypress subject held across a re-render can become detached from the DOM.

Example · javascript
// cypress/e2e/dashboard.cy.js
describe('dashboard count', () => {
  it('survives a route change', () => {
    cy.visit('/dashboard')

    cy.contains('button', 'Add one').click()
    cy.contains('button', 'Add one').click()
    cy.get('[data-testid=count]').should('have.text', '2')

    cy.contains('a', 'Settings').click()
    cy.location('pathname').should('eq', '/settings')

    cy.go('back')
    cy.get('[data-testid=count]').should('have.text', '2')
  })
})

What the component-level test of the same behaviour looks like

Side by side with the primary example, this shows the division of labour. The component test proves the click handler increments and the template renders the new value. It says nothing about the router, the store, the build output or the stylesheet, which is exactly why the end-to-end test is worth its seconds.

Example · typescript
// src/components/__tests__/CounterCard.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import CounterCard from '../CounterCard.vue'

describe('CounterCard', () => {
  it('raises the count on click', async () => {
    const wrapper = mount(CounterCard, { props: { label: 'Add one' } })

    await wrapper.get('button').trigger('click')

    expect(wrapper.get('[data-testid=count]').text()).toBe('1')
    // Output: passes in a few milliseconds, with no browser and no server
  })
})

Discussion

  • Be the first to comment on this lesson.