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.
Table of contents
- 1. Component testing between unit and E2E tests
- 2. Setting up Cypress Component Testing for Vue 3
- 3. The mount API: props, events, and slots
- 4. Simulating interactions: clicks, forms, keyboard
- 5. Mocking network requests with cy.intercept
- 6. Testing slots and scoped slots deliberately
- 7. Custom commands for recurring checks
- 8. Component testing in the CI pipeline
- 9. Cypress Component Testing compared
- 10. Summary
- 11. FAQ
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.