End to End Tests with Playwright for Vue Apps
AI generated
<v/>
{ }
Vue.js · Playwright · E2E Testing · CI/CD
End to End Tests with Playwright for Vue Apps
stable user flows instead of flaky sleep calls

Playwright tests Vue applications as a whole in a real browser, with auto-waiting instead of fixed timeouts, network interception instead of a real backend, and parallel execution across multiple browser engines. That makes end to end tests predictable instead of a constant source of CI flakiness.

19 min read auto-waiting · page objects · API mocking Playwright 1.4x · Vue 3 · Vite

1. Why Playwright for Vue E2E tests

An end to end test with Playwright starts a Vue application exactly the way a real user experiences it: with full routing, real API calls to a test environment or mocked backends, real rendering, and real browser events. Unlike isolated component tests, an E2E test verifies the entire path from landing on a page to completing an action, such as logging in, filling a cart, and finishing checkout. Playwright was built from the ground up for exactly this use case and comes with native support for Chromium, Firefox, and WebKit, with no driver installation or Selenium Grid.

The decisive architectural difference from older tools lies in the direct communication over the Chrome DevTools Protocol, or the equivalent protocols for Firefox and WebKit. That means Playwright knows the actual state of the page in real time, instead of guessing through polling whether an element is ready for interaction yet. For Vue applications with asynchronous data loading, Suspense boundaries, and delayed hydration, that is a decisive advantage over tools that depend on fixed wait times.

This article builds Playwright up from initial setup through page objects, selector strategies, and mocking, all the way to parallelized CI execution, always with a focus on typical Vue 3 and Nuxt applications.

2. Setup and project structure

Installing Playwright via npm init playwright@latest automatically generates a sensible base structure with playwright.config.ts, a tests directory, and example tests. For Vue projects, it is worth configuring a webServer entry that automatically starts the local dev server or a production build before the tests run, and shuts it down again afterward. That way nobody on the team has to remember to keep a server running manually.

A second important configuration point is baseURL, which simplifies all relative page.goto() calls and makes switching between local, staging, and CI environments controllable through a single environment variable. For Vue projects with multiple store views or language versions, an additional projects array is worth adding, running the same test set against multiple base URLs or locale configurations.


// playwright.config.ts — project setup with automatic dev server
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests/e2e",
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [["html", { open: "never" }], ["list"]],
  use: {
    baseURL: process.env.BASE_URL || "http://localhost:5173",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
  },
  webServer: {
    command: "npm run dev",
    url: "http://localhost:5173",
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    { name: "firefox", use: { ...devices["Desktop Firefox"] } },
    { name: "webkit", use: { ...devices["Desktop Safari"] } },
  ],
});

With this configuration, all tests automatically run against a freshly started dev server, traces and screenshots are only generated on failures, which saves disk space, and the three most important browser engines are covered in parallel. For a Vue project, this setup is usually sufficient for the entire project lifetime without major changes.

3. Page objects for maintainable test suites

Without structure, E2E test suites quickly grow into unwieldy collections of selectors and interactions. The page object pattern encapsulates the selectors and actions of a page or a recurring UI area inside its own class. If a selector in the Vue component changes later, only the page object needs adjusting, not every single test that uses that page.

For Vue applications with reusable components such as a global navigation or a cart widget, it is worth adding a separate page object for these components, independent of the specific page. That way, a CartWidget object can be reused in tests for the product page just as much as in tests for the checkout page, without duplicating selectors.


// tests/e2e/pages/LoginPage.ts — Page Object encapsulating selectors and actions
import { Page, Locator, expect } from "@playwright/test";

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorBanner: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByTestId("email-input");
    this.passwordInput = page.getByTestId("password-input");
    this.submitButton = page.getByTestId("submit-button");
    this.errorBanner = page.getByTestId("error-banner");
  }

  async goto() {
    await this.page.goto("/login");
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorBanner).toContainText(message);
  }
}

// tests/e2e/login.spec.ts — using the Page Object in a test
import { test, expect } from "@playwright/test";
import { LoginPage } from "./pages/LoginPage";

test("shows an error for invalid credentials", async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login("user@example.com", "wrong-password");
  await loginPage.expectError("Invalid credentials");
});

4. Selector strategy: data-testid instead of CSS classes

The choice of selectors determines the stability of an entire E2E suite. Tailwind CSS classes change frequently for purely stylistic reasons, text selectors break on translations, and generated IDs from Vue components are often unstable between builds. The most robust solution is a dedicated data-testid attribute that exists exclusively for tests and is completely independent of design changes.

Playwright supports this strategy natively through page.getByTestId(), which by default accesses the data-testid attribute, but is also configurable to other attribute names via selectors.setTestIdAttribute(). In Vue components, it is worth setting data-testid only on elements that are actually test targets, not on every single tag, to avoid unnecessarily bloating the markup.


// tests/e2e/checkout.spec.ts — stable selectors via data-testid, resilient to CSS refactors
import { test, expect } from "@playwright/test";

test("completes checkout with a valid payment method", async ({ page }) => {
  await page.goto("/checkout");

  // Stable regardless of Tailwind class changes or copy edits
  await page.getByTestId("payment-method-select").selectOption("credit-card");
  await page.getByTestId("card-number-input").fill("4242424242424242");
  await page.getByTestId("card-expiry-input").fill("12/28");
  await page.getByTestId("card-cvc-input").fill("123");

  await page.getByTestId("place-order-button").click();

  await expect(page.getByTestId("order-confirmation")).toBeVisible();
  await expect(page).toHaveURL(/\/order-confirmation\/\d+/);
});

5. Auto-waiting instead of explicit sleep calls

A central design principle of Playwright is auto-waiting: before an action such as click() or fill() runs, Playwright automatically waits until the target element is visible, enabled, and not obscured by another element. For Vue applications where components appear in the DOM only with a delay, due to v-if, asynchronous loading, or transition animations, this removes the need for fixed setTimeout waits that were common in classic Selenium suites.

Where auto-waiting reaches its limits, for example with entirely asynchronous state transitions that have no visible DOM signal, Playwright offers explicit wait primitives such as expect.poll() or page.waitForResponse(). These are reserved for targeted waiting scenarios and never replace blanket sleep calls, which artificially slow down tests without making them any more reliable.


// tests/e2e/product-search.spec.ts — relying on auto-waiting, no manual sleeps
import { test, expect } from "@playwright/test";

test("filters products after debounced search input", async ({ page }) => {
  await page.goto("/products");

  await page.getByTestId("search-input").fill("keyboard");

  // No sleep needed — Playwright auto-waits for the DOM to settle
  await expect(page.getByTestId("product-card")).toHaveCount(3);

  // Explicit wait only for a network response with no visible DOM signal
  const responsePromise = page.waitForResponse((res) =>
    res.url().includes("/api/products/search") && res.status() === 200
  );
  await page.getByTestId("sort-select").selectOption("price-asc");
  await responsePromise;

  await expect(page.getByTestId("product-card").first()).toContainText("EUR");
});

6. API mocking and network interception

Playwright offers a powerful tool in page.route() to intercept network requests before they reach a real API. This is especially valuable for Vue applications that depend on external payment providers, third party APIs, or unstable test environments. Instead of testing against a real backend that brings its own latency, outages, and rate limits, Playwright responds directly with a controlled fixture.

An important use case is deliberately producing error states that would be hard to reproduce against a real backend, such as a 503 error in the middle of a checkout flow. Through route.fulfill(), you can precisely control which status code, response body, and headers are returned, enabling targeted tests for error handling, retry logic, and fallback UI in the Vue application.


// tests/e2e/order-history.spec.ts — intercepting API calls with page.route
import { test, expect } from "@playwright/test";

test("shows order history from a mocked API response", async ({ page }) => {
  await page.route("**/api/orders", async (route) => {
    await route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify([
        { id: 101, status: "delivered", total: 89.9 },
        { id: 102, status: "in-transit", total: 34.5 },
      ]),
    });
  });

  await page.goto("/orders");

  await expect(page.getByTestId("order-row")).toHaveCount(2);
  await expect(page.getByTestId("order-row").first()).toContainText("delivered");
});

test("shows a retry prompt when the API responds with an error", async ({ page }) => {
  await page.route("**/api/orders", (route) => route.fulfill({ status: 503 }));

  await page.goto("/orders");

  await expect(page.getByTestId("error-banner")).toBeVisible();
  await expect(page.getByTestId("retry-button")).toBeVisible();
});

7. Visual regression tests and screenshots

Besides functional checks, Playwright offers built-in visual regression tests through expect(page).toHaveScreenshot(). A reference screenshot is generated on the first run and compared pixel by pixel, with a configurable tolerance, on every subsequent run. For Vue component libraries or design systems with many visual states, that is a valuable safety net against unintended CSS regressions that functional tests would not catch.

It is important to use visual tests deliberately and sparingly, only on stable, deterministic pages with no random content, animated elements, or time dependent data. Dynamic content such as timestamps or randomly generated IDs must be masked before the screenshot, or replaced with fixed test data, otherwise the comparison constantly produces false failures.

8. Parallelization and CI integration

Playwright runs tests in parallel across multiple worker processes by default, which drastically reduces the total runtime of a large E2E suite. In the CI configuration, the number of workers can be tuned specifically to the available CPU cores of the runner. For particularly large Vue projects, Playwright additionally supports sharding, where the entire test suite is split across multiple independent CI jobs that run in parallel on different machines.

The built-in trace mode with the on-first-retry option produces a complete recording for failed tests, including DOM snapshots, network log, and console output, which can be replayed locally with npx playwright show-trace. That significantly reduces the time spent debugging in CI, because developers can step through the exact state of the page at the time of failure, instead of having to reproduce the bug locally first.

9. Playwright compared to Cypress

Playwright and Cypress are both established tools for end to end tests, but they differ in important architectural details that should matter when choosing tooling for a Vue project.

Criterion Playwright Cypress
Browser engines Chromium, Firefox, WebKit natively Chromium based, WebKit only experimental
Multiple tabs / contexts Fully supported Limited, one tab per test
Auto-waiting Yes, for all actions Yes, for all actions
Parallelization out of the box Yes, workers + sharding Only with Cypress Cloud (paid)
Component testing Experimental Mature, first class feature

For pure end to end suites focused on cross browser coverage and free parallelization, Playwright is ahead. For teams that also want component testing in the same toolchain, Cypress is often the more pragmatic choice. Many Vue projects use both tools side by side: Cypress for component testing, Playwright for the lean E2E suite across multiple browser engines.

Mironsoft

Stable E2E test suites and CI pipelines for Vue and Nuxt applications

Tired of flaky E2E tests but no time for a rebuild?

We build Playwright test suites for your Vue application, set up page objects and API mocking cleanly, and integrate parallel test runs into your existing CI pipeline.

Test architecture

Page objects, selector strategy, and folder structure for maintainable suites

API mocking

Network interception with page.route for deterministic test runs

CI parallelization

Sharding and worker configuration for fast pipeline runtimes

10. Summary

End to end tests with Playwright replace fragile test suites based on fixed wait times with a tool that knows the actual state of a Vue application in real time. Auto-waiting eliminates the need for explicit sleep calls, page objects keep selectors maintainable, and page.route() enables deterministic API mocking without depending on real backends. Native support for Chromium, Firefox, and WebKit covers cross browser compatibility without extra drivers or grid infrastructure.

The biggest win comes from teams consistently relying on data-testid selectors instead of CSS classes and using visual regression tests deliberately, not everywhere. Combined with parallelization through workers and sharding, an extensive E2E suite can run in minutes instead of hours even for larger Vue projects, making E2E tests practical again for every pull request pipeline.

End to End Tests with Playwright — the essentials at a glance

Auto-waiting

Playwright automatically waits for visibility and interactivity, no explicit sleep calls needed.

Page objects

Encapsulate selectors and actions per page, keeping suites maintainable across markup changes.

API mocking

page.route() intercepts network requests and delivers controlled fixtures instead of a real backend.

Parallelization

Workers and sharding significantly reduce the total runtime of large suites in CI.

11. FAQ: End to End Tests with Playwright for Vue Apps

1Why good for async loading?
Playwright knows the real time DOM state and automatically waits until elements are interactive, with no fixed timeouts.
2data-testid over CSS classes?
Exists only for tests, stays untouched by design refactors, CSS changes, and translations.
3Mocking API calls?
Intercept network requests with page.route() and deliver a controlled response with route.fulfill().
4What are page objects?
Encapsulate selectors and actions of a page. Markup changes only touch the page object, not every test.
5Still need explicit waits?
Usually not, auto-waiting covers most cases. Use page.waitForResponse only for special cases.
6Visual regression tests?
expect(page).toHaveScreenshot() compares pixel by pixel. Mask dynamic content beforehand.
7Speeding up a large suite?
Parallel workers and sharding across multiple CI jobs.
8Value of trace mode?
Records DOM, network, and console, replayable with npx playwright show-trace for fast debugging.
9Playwright or Cypress?
Playwright for cross browser and parallelization, Cypress for mature component testing. Many teams combine both.
10Testing multiple language versions?
Through a projects array running the same test set against multiple base URLs or locales.