Component Testing with Cypress for Vue: Setup, Mocking, and Practice
AI generated
<v/>
{ }
Vue.js · Cypress · Component Testing · Frontend
Component Testing with Cypress for Vue
real components in a real browser

Component Testing with Cypress closes the gap between isolated unit tests and slow end to end runs. Vue components are actually mounted in the browser, with a real DOM, real events, and controllable network calls, without booting up the entire application.

18 min read cy.mount · cy.intercept · custom commands Cypress 13 · Vue 3 · Vite

1. Component testing between unit and E2E tests

Component testing with Cypress occupies a niche that classic unit tests with Vitest and Testing Library cannot cover: the component is not rendered in a simulated JSDOM environment, but in a real, isolated browser tab. That means real layout, real CSS, real event propagation, and real browser APIs are all available, while only a single component is being tested, not the entire application with routing, server, and database. For Vue projects with complex visual states, drag and drop interactions, or CSS transitions, that is a real advantage over JSDOM based tests.

The decisive difference from a full end to end test lies in scope. An E2E test with Cypress or Playwright starts the complete application, navigates through pages, and verifies entire user flows. Component testing, on the other hand, mounts a single Vue component directly, with explicit props and mocks for every external dependency. The result is tests that run almost as fast as unit tests, but with the reliability of real browser rendering. This exact middle ground is what makes component testing attractive for design systems, reusable UI libraries, and complex forms in Vue applications.

In practice, teams often rely either exclusively on unit tests with shallow mounting, or exclusively on sluggish E2E suites. Component testing with Cypress fills the gap between these two, and this article builds it up step by step, from setup through mocking to CI pipeline integration.

2. Setting up Cypress Component Testing for Vue 3

Getting started with component testing begins with the Cypress installation and picking the right framework in the setup wizard. For a Vite based Vue project, Cypress automatically detects the matching dev server adapter and suggests the @cypress/vue configuration. It is important to keep cypress.config.ts cleanly separated from the E2E configuration, since component testing starts its own dev server and uses its own support file.

A common pitfall: global plugins such as Pinia, Vue Router, or an i18n setup, which are registered via app.use() in the real application, are entirely absent in the isolated component test context. That is why you define a central mount wrapper in the support file that already preconfigures the necessary plugins, so individual test files do not have to repeat the same boilerplate on every test.


// cypress.config.ts — Component Testing setup for a Vite-based Vue 3 project
import { defineConfig } from "cypress";

export default defineConfig({
  component: {
    devServer: {
      framework: "vue",
      bundler: "vite",
    },
    specPattern: "src/**/*.cy.{js,ts}",
    supportFile: "cypress/support/component.ts",
  },
});

// cypress/support/component.ts — global mount wrapper with plugins
import { mount } from "cypress/vue";
import { createPinia } from "pinia";
import { createRouter, createWebHistory } from "vue-router";

Cypress.Commands.add("mount", (component, options = {}) => {
  options.global = options.global || {};
  options.global.plugins = options.global.plugins || [];

  // Always provide a fresh Pinia instance per test
  options.global.plugins.push(createPinia());

  // Only attach router when the test explicitly needs it
  if (options.router !== false) {
    const router = createRouter({
      history: createWebHistory(),
      routes: options.routes || [{ path: "/", component: { template: "<div/>" } }],
    });
    options.global.plugins.push(router);
  }

  return mount(component, options);
});

This central configuration means every individual test file only needs to hand over the component and its specific props, while Pinia and the router are automatically available. That reduces boilerplate significantly and makes component testing with Cypress considerably more pleasant day to day, because new tests can be written in a few lines instead of rebuilding the entire plugin chain each time.

3. The mount API: props, events, and slots

The core of component testing is the cy.mount() command, which renders a single Vue component inside an isolated iframe. Input values are set through the props option, and values for composables based on provide/inject can be injected through global.provide. Once mounted, the component behaves exactly as it does in production, including reactive updates, watchers, and lifecycle hooks.

A key advantage over Testing Library in JSDOM: emitted events can be queried directly through the returned wrapper reference, and CSS classes that result from real browser layout calculations, such as display: none triggered by media queries, are evaluated correctly. This makes it possible to test responsive components whose behavior depends on actual viewport size, something a simulated DOM environment cannot reliably provide.


// ProductCard.cy.js — mounting a component with props and asserting emitted events
import ProductCard from "./ProductCard.vue";

describe("ProductCard", () => {
  it("renders price and title from props", () => {
    cy.mount(ProductCard, {
      props: {
        product: { id: 42, title: "Wireless Keyboard", price: 79.9, inStock: true },
      },
    });

    cy.get("[data-testid='product-title']").should("contain.text", "Wireless Keyboard");
    cy.get("[data-testid='product-price']").should("contain.text", "79.90");
  });

  it("emits add-to-cart with the product id when the button is clicked", () => {
    const product = { id: 42, title: "Wireless Keyboard", price: 79.9, inStock: true };

    cy.mount(ProductCard, { props: { product } }).then(({ wrapper }) => {
      cy.get("[data-testid='add-to-cart']").click().then(() => {
        expect(wrapper.emitted("add-to-cart")).to.have.length(1);
        expect(wrapper.emitted("add-to-cart")[0]).to.deep.equal([42]);
      });
    });
  });

  it("disables the button when the product is out of stock", () => {
    cy.mount(ProductCard, {
      props: { product: { id: 7, title: "Sold Out Mouse", price: 29.9, inStock: false } },
    });

    cy.get("[data-testid='add-to-cart']").should("be.disabled");
  });
});

4. Simulating interactions: clicks, forms, keyboard

Because component testing with Cypress runs in a real browser, standard interaction commands such as cy.get().click(), cy.get().type(), and cy.get().select() work identically to a classic E2E suite. This is a noticeable difference from Testing Library, where user interactions must be synthetically generated through fireEvent or userEvent. With Cypress, a click() command triggers actual mouse events with correct coordinates, which produces more realistic results, especially for components with @click.stop, overlay elements, or drag handlers.

For more complex forms in Vue, it is worth combining each input with an explicit assertion of the resulting state, instead of only checking the final submit. That makes it visible exactly where a validation fails when a test goes red, instead of only reporting a generic "form was not submitted".


// LoginForm.cy.js — simulating real keyboard and mouse interactions
import LoginForm from "./LoginForm.vue";

describe("LoginForm", () => {
  it("shows a validation error for an invalid email", () => {
    cy.mount(LoginForm);

    cy.get("[data-testid='email-input']").type("not-an-email");
    cy.get("[data-testid='password-input']").type("supersecret123");
    cy.get("[data-testid='submit-button']").click();

    cy.get("[data-testid='email-error']").should("contain.text", "valid email address");
  });

  it("submits valid credentials and emits the login event", () => {
    cy.mount(LoginForm).then(({ wrapper }) => {
      cy.get("[data-testid='email-input']").type("user@example.com");
      cy.get("[data-testid='password-input']").type("supersecret123");
      cy.get("[data-testid='submit-button']").click().then(() => {
        expect(wrapper.emitted("login")).to.exist;
      });
    });
  });

  it("supports keyboard-only submission via Enter", () => {
    cy.mount(LoginForm);
    cy.get("[data-testid='email-input']").type("user@example.com");
    cy.get("[data-testid='password-input']").type("supersecret123{enter}");
    cy.get("[data-testid='submit-button']").should("have.attr", "aria-pressed", "true");
  });
});

5. Mocking network requests with cy.intercept

Component testing isolates a Vue component from the rest of the application, but many components load data themselves through fetch or composables like useFetch. This is exactly where cy.intercept() comes in: the command catches network calls at the browser level before they reach the backend, and responds with predefined fixtures. That makes tests deterministic, independent of real servers, and allows targeted testing of error states that would be hard to reproduce against a real backend.

An important difference from unit test mocking with vi.mock(): cy.intercept() mocks at the network level, not at the module level. That means the component's actual fetch implementation remains untouched and runs for real, only the response comes from the fixture. This also surfaces bugs in the component's own fetch logic that would go unnoticed with pure module mocking.


// OrderList.cy.js — intercepting network calls at the browser level
import OrderList from "./OrderList.vue";

describe("OrderList", () => {
  it("renders orders returned by the API", () => {
    cy.intercept("GET", "/api/orders", {
      statusCode: 200,
      body: [
        { id: 1, status: "shipped", total: 129.5 },
        { id: 2, status: "processing", total: 49.0 },
      ],
    }).as("getOrders");

    cy.mount(OrderList);
    cy.wait("@getOrders");

    cy.get("[data-testid='order-row']").should("have.length", 2);
    cy.get("[data-testid='order-row']").first().should("contain.text", "shipped");
  });

  it("shows an error state when the API responds with 500", () => {
    cy.intercept("GET", "/api/orders", { statusCode: 500 }).as("getOrdersFailed");

    cy.mount(OrderList);
    cy.wait("@getOrdersFailed");

    cy.get("[data-testid='error-banner']").should("be.visible");
    cy.get("[data-testid='retry-button']").should("exist");
  });

  it("retries the request when the retry button is clicked", () => {
    cy.intercept("GET", "/api/orders", { statusCode: 500 }).as("firstAttempt");
    cy.mount(OrderList);
    cy.wait("@firstAttempt");

    cy.intercept("GET", "/api/orders", { statusCode: 200, body: [] }).as("retryAttempt");
    cy.get("[data-testid='retry-button']").click();
    cy.wait("@retryAttempt");

    cy.get("[data-testid='empty-state']").should("be.visible");
  });
});

6. Testing slots and scoped slots deliberately

Vue components with slots present a particular challenge for component testing, because the actual content is determined by the caller, not by the component itself. Cypress's mount API allows slots to be filled through the slots option, using either static markup or render functions. For default slots, a simple HTML string is enough, whereas scoped slots require a function that accepts the slot props provided by the child.

In both cases, the test should verify that the slot props are correctly passed through to the caller, not just that the slot content renders at all. Especially for scoped slots that expose, say, pagination or sorting state to the outside, that is the actual point of the test, uncovering bugs in the component's internal logic.


// DataTable.cy.js — testing default slots and scoped slots
import DataTable from "./DataTable.vue";

describe("DataTable slots", () => {
  it("renders custom markup passed into the default slot", () => {
    cy.mount(DataTable, {
      props: { items: [] },
      slots: {
        empty: "<p data-testid='custom-empty'>No entries found</p>",
      },
    });

    cy.get("[data-testid='custom-empty']").should("be.visible");
  });

  it("passes row data through the scoped slot to the caller", () => {
    cy.mount(DataTable, {
      props: { items: [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }] },
      slots: {
        row: (props) => `<td data-testid="row-${props.item.id}">${props.item.name}</td>`,
      },
    });

    cy.get("[data-testid='row-1']").should("contain.text", "Alice");
    cy.get("[data-testid='row-2']").should("contain.text", "Bob");
  });
});

7. Custom commands for recurring checks

Once multiple test files repeat the same mount options, the same interceptions, or the same assertions, a custom command is worth introducing. Cypress lets you register your own commands in cypress/support/component.ts, extending the TypeScript type definitions along the way. For Vue projects, custom commands are especially useful for form filling helpers, login simulations, or standardized fixture loaders.

It is important not to make custom commands too generic. A command that covers too many edge cases through parameters becomes an error source in its own right and hard to debug. The better practice is a small, clearly named command per recurring task, such as cy.fillLoginForm() or cy.mockOrdersApi(), instead of a single all powerful helper.


// cypress/support/component.ts — reusable custom commands
Cypress.Commands.add("fillLoginForm", (email, password) => {
  cy.get("[data-testid='email-input']").clear().type(email);
  cy.get("[data-testid='password-input']").clear().type(password);
});

Cypress.Commands.add("mockOrdersApi", (orders = []) => {
  cy.intercept("GET", "/api/orders", { statusCode: 200, body: orders }).as("ordersRequest");
});

// Usage inside a spec file
describe("Checkout flow", () => {
  it("logs in and shows the order list", () => {
    cy.mockOrdersApi([{ id: 1, status: "shipped", total: 99.0 }]);
    cy.mount(App);
    cy.fillLoginForm("user@example.com", "supersecret123");
    cy.get("[data-testid='submit-button']").click();
    cy.wait("@ordersRequest");
  });
});

8. Component testing in the CI pipeline

Component testing shows its full value only once it runs reliably in continuous integration. Cypress offers a headless mode for this via cypress run --component, which runs without a visible UI and integrates with GitHub Actions, GitLab CI, or any other runner. For Vue monorepos, it is worth running component tests in parallel with unit tests, since both cover different classes of bugs and should not block each other.

For stable CI runs, it is essential that no real external services are contacted. Every component that makes network calls must be fully mocked through cy.intercept(), otherwise network latency or outages of external services lead to flaky tests. Screenshots and videos of failed runs, which Cypress generates automatically, considerably speed up debugging in CI, because developers see the exact visual state at the time of failure instead of just a stack trace line.

9. Cypress Component Testing compared

Component testing with Cypress is one of several options in the testing toolbox for Vue applications. The choice between Cypress component testing, classic unit testing with Vitest, and full end to end testing has a direct impact on runtime, realism, and maintenance effort.

Criterion Unit test (Vitest + JSDOM) Cypress Component Testing Full E2E test
Rendering environment Simulated DOM (JSDOM) Real browser Real browser
Test scope Single function / component Single component, isolated Entire application
CSS and layout verified No, classes only Yes, real rendering Yes, real rendering
Runtime per test Very fast (ms) Fast (seconds) Slow (several seconds)
Backend required No No, via cy.intercept Yes, usually a test environment

In practice, these three approaches do not exclude each other but complement one another. Pure logic, such as composables or utility functions, is cheapest to test with Vitest. Components with complex visual behavior, CSS dependent logic, or slot interactions benefit the most from Cypress component testing. Full user flows across multiple pages still belong in a lean E2E suite that is deliberately kept small to keep CI runtimes under control.

Mironsoft

Vue testing strategies and stable test suites for frontend teams

Reliable component tests for your Vue application?

We integrate Cypress Component Testing into existing Vue projects, set up mocking strategies with cy.intercept, and deliver stable, meaningful test suites instead of flaky E2E runs.

Test setup

Setting up Cypress Component Testing for Vite and Vue 3, including Pinia and router

Mocking strategy

Network mocking with cy.intercept and fixture structures for deterministic tests

CI integration

Integrating and parallelizing component tests headlessly in existing pipelines

10. Summary

Component testing with Cypress closes the gap between isolated unit tests and sluggish end to end suites by rendering Vue components individually, but in a real browser. cy.mount() replaces classic shallow mounting, cy.intercept() takes over deterministic network mocking, and slots can be filled deliberately through the mount options with static or dynamic content. Custom commands reduce boilerplate across many test files without sacrificing the readability of individual tests.

The biggest lever is understanding component testing not as a replacement, but as a complement to unit and E2E tests. Pure logic belongs in fast Vitest suites, complete user behavior across multiple pages belongs in a lean E2E suite, and everything in between, especially visually complex or slot based components, benefits the most from Cypress component testing. Whoever deliberately separates these three layers ends up with a test pyramid that is both fast and meaningful.

Component Testing with Cypress — the essentials at a glance

Mount API

cy.mount() renders a single Vue component in a real browser, with props, events, and slots exactly as in production.

Network mocking

cy.intercept() mocks at the network level, not the module level, and thereby surfaces real fetch bugs inside the component.

Slots and scoped slots

The slots option accepts both static markup and render functions for scoped slots.

CI readiness

Headless mode with cypress run --component, full mocking of external calls prevents flaky tests in the pipeline.

11. FAQ: Component Testing with Cypress for Vue

1Difference from unit testing?
Real browser with full CSS rendering instead of simulated JSDOM. Matters for layout dependent behavior and real browser APIs.
2Backend required?
No, cy.intercept catches network calls at the browser level and answers them with fixtures, without a real backend.
3Mocking Pinia stores?
Fresh Pinia instance via global.plugins when mounting, or createTestingPinia with initialState for predefined state.
4Testing scoped slots?
Pass a render function through the slots option that accepts slot props and produces checkable markup from them.
5cy.intercept instead of vi.mock?
cy.intercept mocks at the network level, the component's real fetch logic runs and gets tested rather than skipped.
6Runs in CI?
Yes, with cypress run --component in headless mode. Full mocking of external calls prevents flaky tests.
7Worth it for simple components?
For pure logic, a fast Vitest test is usually enough. Component testing pays off for visually complex components.
8Reducing boilerplate?
Custom commands in the support file, for example a central mount wrapper with preconfigured plugins.
9Testing responsive behavior?
Yes, with cy.viewport the window size can be set, media queries are evaluated correctly in a real browser.
10Replaces E2E tests?
No, full user flows across multiple pages still belong in a lean, deliberately small E2E suite.