Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 029 / 100

End-to-end testing Vue apps: what developers should know

Test real user flows.

Expona Admin7 min read

End-to-end testing Vue apps: what developers should know

End-to-end testing is where theory meets reality. When unit tests pass and component tests look good, but users still encounter broken workflows, that's where E2E tests save you. Testing real user flows through your Vue application gives you the confidence that matters most: your application actually works from the browser's perspective.

Over the past 29 days, we've built solid foundations with Vue fundamentals, state management, and component patterns. Now we need to verify those pieces work together in the way users experience them. This guide covers what end-to-end testing means for Vue apps, when to reach for Playwright or Cypress, how to write maintainable tests, and the practices that keep your test suite from becoming a maintenance nightmare.

Playwright or Cypress: choosing your tool

Both Playwright and Cypress excel at testing Vue applications, but they solve the problem differently. Understanding these differences helps you make the right choice for your team and project.

Cypress runs in the browser alongside your application, giving it deep access to the DOM and JavaScript context. This makes debugging visually intuitive—you can see exactly what happened at each step. Cypress has a time-travel debugger that's genuinely helpful. The trade-off is that Cypress runs in a single browser tab and has some architectural limitations around multiple domains and complex multi-window scenarios.

Playwright runs out-of-process and controls the browser via the DevTools Protocol. It's faster, supports three major browser engines (Chromium, Firefox, WebKit), and handles complex scenarios like multiple tabs and contexts more naturally. Playwright also has better support for running tests in parallel across multiple machines. For teams running thousands of tests daily, this matters.

For a typical Vue SPA with standard authentication flows and multi-step user journeys, either tool works well. We recommend Playwright for larger teams and projects requiring parallel execution, and Cypress if your team values the visual debugging experience and you're comfortable with its single-origin limitations.

Both integrate smoothly with Vue. Here's a minimal Playwright setup:

typescript
import { test, expect } from '@playwright/test';

test('user can complete checkout flow', async ({ page }) => {
  await page.goto('http://localhost:3000');
  await page.click('button:has-text("Add to Cart")');
  await page.click('button:has-text("Checkout")');
  await expect(page).toHaveURL(/\/order-confirmation/);
});

Role-based locators: writing resilient selectors

Fragile test selectors break constantly. Every time a designer tweaks a class name or a developer refactors HTML structure, tests snap. Role-based locators solve this by finding elements the way assistive technology does—by their semantic purpose rather than implementation details.

Instead of targeting .btn-primary-xyz or data-testid="submit-button-v2", you query for role: 'button', name: 'Submit'. This approach decouples your tests from styling and technical decisions. When you refactor, as long as the semantic meaning remains, your tests keep passing.

Consider a typical Vue login form:

typescript
// ❌ Fragile: breaks when classes change
await page.click('.login-form__submit-btn--primary');

// ✅ Resilient: based on semantic role
await page.click('button:has-text("Sign in")');

Playwright and Cypress both support accessible queries. For a more complex scenario with multiple buttons, use role locators properly:

typescript
// Find a button by its accessible role and label
await page.getByRole('button', { name: 'Save changes' }).click();

// Find form inputs by label text
await page.getByLabel('Email address').fill('user@example.com');
await page.getByLabel('Password').fill('secure-password');

// Find links by their visible text
await page.getByRole('link', { name: 'View profile' }).click();

This approach also documents intent. Someone reading your test immediately understands what user action you're simulating, without needing to inspect the DOM.

Mock APIs where needed

End-to-end tests should exercise real user workflows, but they don't always need real backends. Mocking external APIs keeps tests fast, deterministic, and independent from environment state.

The key is knowing when to mock. If you're testing your Vue checkout flow, mock the payment gateway. If you're testing your authentication integration, you might want a real test user in a staging environment. The balance depends on your confidence level in each component of the system.

Playwright provides network mocking through route interception:

typescript
test('user sees product recommendations', async ({ page }) => {
  // Mock the recommendations API
  await page.route('**/api/recommendations', route => {
    route.abort('blockedbyclient');
  });

  // Or mock with specific data
  await page.route('**/api/recommendations', route => {
    route.continue({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        products: [
          { id: 1, name: 'Premium Widget', price: 99 },
          { id: 2, name: 'Standard Widget', price: 49 }
        ]
      })
    });
  });

  await page.goto('http://localhost:3000/product/1');
  await expect(page.locator('text=Premium Widget')).toBeVisible();
});

This approach lets you test error states too. What happens when the API times out? What does the user see when they have no network? These scenarios are easier to test with mocked APIs:

typescript
test('user sees retry prompt when API fails', async ({ page }) => {
  await page.route('**/api/checkout', route => {
    route.abort('failed');
  });

  await page.goto('http://localhost:3000/checkout');
  await page.click('button:has-text("Place Order")');
  
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
  
  // Now allow the request
  await page.route('**/api/checkout', route => route.continue());
  await page.click('button:has-text("Retry")');
  await expect(page).toHaveURL(/\/order-confirmation/);
});

Run against production builds

Testing against your dev server feels fast, but dev servers behave differently from production. Hot module reloading, unminified code, and different webpack/vite configurations mean you're sometimes testing a version of your app that doesn't quite match what users see.

The right practice is to build your Vue app for production, serve that build locally, and run your E2E suite against it. This catches issues like:

  • Minification breaking selectors (though role-based locators prevent this)
  • Missing environment variables in production builds
  • Asset paths breaking in certain configurations
  • Performance issues that only surface in optimized builds
  • Tree-shaking accidentally removing code you need

Most teams use a simple pattern in their test configuration:

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

export default defineConfig({
  webServer: {
    command: 'npm run build && npm run preview',
    url: 'http://localhost:4173',
    reuseExistingServer: !process.env.CI,
  },
  use: {
    baseURL: 'http://localhost:4173',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

This configuration builds your production bundle before running tests and serves it on a local port. CI environments run fresh builds every time; local development can reuse existing servers to save time.

Common mistakes

Writing tests that are too specific to implementation: Tests should verify user outcomes, not implementation details. A test that checks for a specific Vue component name or CSS class is brittle. Test what users see and do.

Testing everything with E2E: E2E tests are slow. Reserve them for critical user journeys. Use unit tests for utility functions, integration tests for component interactions, and E2E tests for signup, checkout, authentication, and high-value workflows. A typical ratio is 80% unit, 15% integration, 5% E2E.

Not handling asynchronous behavior: Vue applications load data asynchronously. Waiting for selectors to appear is better than using fixed delays. Playwright and Cypress both handle this well with proper wait mechanisms. Your tests should express the actual behavior your app exhibits.

Ignoring flakiness: A test that sometimes passes and sometimes fails is worse than no test. Usually flakiness comes from timing issues or environment instability. Invest time in making tests deterministic. If a test is too flaky to rely on, remove it until you've fixed the root cause.

Testing internal state instead of user outcomes: Don't test that store.isLoading === true. Test that a loading spinner appears. Don't test that a computed property returns the right value. Test that the user sees the expected content. This makes tests resilient to refactoring.

Key takeaways

  • End-to-end testing validates that real user flows work as expected in your Vue application, catching integration issues unit tests miss.
  • Choose Playwright for larger teams and parallel execution, Cypress for visual debugging and simpler scenarios; both integrate well with Vue.
  • Use role-based locators to write selectors that are resilient to implementation changes and clearly document user intent.
  • Mock external APIs to keep tests fast and deterministic, but run against production builds to catch optimization-related issues.
  • Keep your E2E suite focused on critical user journeys; complement it with unit and integration tests for full coverage.
  • Prioritize test reliability over coverage; a flaky test is worse than no test.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles