Cypress Component Testing
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
A component test checks one piece of a user interface — a rating widget, an expandable description, a form field — in a
real browser, without starting the whole application, logging in or navigating to the right page first. Cypress does
this by compiling your specs with a development server (Vite or Webpack), loading your component into a small HTML page
and letting the same commands used in end-to-end tests (cy.get(), .click(), .should(), cy.intercept(),
cy.spy()) drive it. This page covers the whole mechanism for Cypress 16.2.0: the supported frameworks and versions,
the configuration, the cy.mount() command for React, Angular, Vue and Svelte, styling, custom mount commands, custom
framework definitions and the pitfalls. It was checked on 25 October 2026.
The complete examples were run with Cypress 16.2.0 on Chrome 155 and on Electron (reported by Cypress as Electron 146, headless): a React 19 and Vite 8
project (11 tests, all passing), a Vue 3.5 and Vite 8 project (6 tests, all passing) and a Svelte 5 and Vite 8 project
(2 tests, all passing). Component tests do not need a running server, so they were not run against the Bookshelf
application itself: the components mirror two widgets of the application described in
Example Application & Test Layout (the star rating of the
review form and the expandable book description), and the review list stubs the shape of its
GET /api/reviews?bookId= endpoint. The Angular, Next.js, Webpack and custom-framework configurations were not run,
because they need an Angular workspace, a Next.js application, a Webpack project and a published framework package that
were outside the scope of the runs; they are written out in full from the official documentation and are labelled as
such where they appear.
What a component test is, and how it differs from an end-to-end test
An end-to-end test visits a URL and exercises the deployed application. A component test never visits anything: it
imports a component into the spec, and cy.mount() renders it into an empty container of a blank page that Cypress
serves. The browser is real (the box model, CSS, focus, events and layout all work), so Cypress’s actionability checks
apply: a button covered by an overlay cannot be clicked, a hidden element fails should('be.visible'). That is the
main advantage over Node-based runners that emulate a DOM.
| Aspect | End-to-end test | Component test |
|---|---|---|
Entry point |
|
|
Server |
Your application server, started by you |
A Vite or Webpack dev server, started and stopped by Cypress |
Configuration block |
|
|
Spec files and support file |
|
|
Backend |
Real, or stubbed with |
None; requests the component makes are stubbed with |
What it proves |
The layers work together |
One unit behaves correctly, including its styles |
Component tests are cheap and precise but cannot prove that the layers of an application work together, so a healthy
suite combines both types; the test design principles in
Test Design & Page Objects apply to both. Playwright takes a
different approach to the same problem, a gallery page driven by a mount fixture, which is described in
Playwright Component Testing; the trade-offs between the tools
are in Choosing a Tool. The Cypress commands themselves are explained in
Cypress Core Concepts,
Queries, Assertions & Custom Commands and
Network, Stubs, Spies & Clocks; this page does not
repeat them.
How it works
When a component run starts, Cypress performs these steps:
-
Reads
component.devServerfrom the configuration and starts the matching dev server (Vite or Webpack) on an available port. Both implementations ship inside thecypresspackage, so@cypress/vite-dev-serverand@cypress/webpack-dev-serverare not installed separately. -
Sets
baseUrltohttp://localhost:plus that port. -
Loads the index page (
cypress/support/component-index.htmlby default) in the application frame, then dynamically imports the support file and the active spec from the dev server, compiled with the same transforms the application uses (JSX, single-file components, CSS modules, path aliases). -
Runs the spec.
cy.mount()renders the component into the element markeddata-cy-root, and every test starts from a clean root: the previous component is unmounted between tests. -
When the run ends or the app closes, shuts the dev server down.
Two consequences follow. First, anything the component needs from the surrounding application — global CSS, fonts,
providers, a router, plugins — has to be recreated in the support file or the index page, because the real entry point
(main.tsx, main.ts, App.vue) is never executed. Second, because the dev server compiles the spec, a component test
can import anything the application can import, and it can use the application’s own bundler configuration.
Supported frameworks and versions
Cypress 16.2.0 ships official mount adapters for React, Angular, Vue and Svelte inside the cypress package, so no
@cypress/react, @cypress/vue or @cypress/angular package is needed any more: the adapters are imported as
cypress/react, cypress/vue, cypress/angular and cypress/svelte.
| Combination | UI library | Bundler | Status |
|---|---|---|---|
React with Vite |
React 18 to 19 |
Vite 8 |
Supported |
React with Webpack |
React 18 to 19 |
Webpack 5 |
Supported |
Next.js 15 and 16 |
React 18 to 19 |
Webpack 5 |
Supported (Turbopack, the Next.js 16 default, is not supported) |
Vue with Vite |
Vue 3 |
Vite 8 |
Supported |
Vue with Webpack |
Vue 3 |
Webpack 5 |
Supported |
Angular |
Angular 21 to 22 |
Webpack 5 |
Supported; needs |
Svelte with Vite |
Svelte 5 |
Vite 8 |
Alpha |
Svelte with Webpack |
Svelte 5 |
Webpack 5 |
Alpha |
Community framework definitions exist for Qwik and Lit (both on Vite), and anyone can write one (see Custom frameworks). Three version facts matter when upgrading to Cypress 16:
-
Vite 5, 6 and 7 are no longer supported by the Vite dev server; the minimum is Vite 8.0.0, so upgrade Vite and any
@vitejs/*plugin that pins an older major before upgrading Cypress. -
Angular 18, 19 and 20 are no longer supported; the minimum is 21.0.0, and the zoneless harness is now the default.
-
Next.js 14 is no longer supported; Next.js 15.0.4 or later is required.
Create React App and the Vue CLI service were dropped in Cypress 14, as were React 16 and 17, Vue 2, Svelte 3 and 4 and
Angular 16 or older; the cypress open-ct and cypress run-ct commands were removed in the same release and replaced by
the --component flag.
Setting up a project
Install Cypress in the project that contains the components, then open it once and choose component testing:
npm install --save-dev cypress
npx cypress open --component
Source: React component testing
The setup wizard detects the framework and the bundler, checks that the required dependencies are installed, writes the configuration and shows the files it created. A project can also be set up by hand, which is what the examples below do; the wizard output is the same set of files:
| File | Purpose |
|---|---|
|
Holds the |
|
Loaded before every spec: registers |
|
The page the component is rendered into: global |
|
The tests, usually next to the component they test. |
Components are run with the same CLI as end-to-end tests plus the --component flag. cypress open --component opens
the interactive app (headed, with time travel and the component rendered visibly next to the Command Log), and
cypress run --component runs the specs headless in the terminal, which is the form to use in continuous integration:
npx cypress run --component # every component spec, Electron by default
npx cypress run --component --browser chrome # in Chrome
npx cypress run --component --spec "src/components/RatingStars.cy.tsx"
Electron is the default run browser but is deprecated as a test browser in Cypress 16 (the run prints a deprecation
warning and says it will be removed in a future major version), so use --browser chrome or another installed browser
in new setups. Running the React project below with --browser chrome printed this summary:
Spec Tests Passing Failing Pending Skipped
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ✔ Expandable.cy.tsx 169ms 2 2 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ Providers.cy.tsx 77ms 2 2 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ RatingStars.cy.tsx 331ms 4 4 - - - │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ ReviewList.cy.tsx 646ms 3 3 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! 00:01 11 11 - - -
On the macOS machine used for the runs, every cypress run also printed a warning that Cypress failed to trash the
existing run results, followed by spawn Unknown system error -86, before the run started. The message states that it
does not affect the exit code, and it did not; it appeared only on that machine and was not investigated further.
Configuring component testing
The devServer option
component.devServer is the only required setting. Naming the framework and the bundler is enough; Cypress then wires
the right dev server and finds your existing bundler configuration:
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
},
video: false,
},
});
Source: Configure component tests
The valid combinations are:
framework |
bundler values |
Notes |
|---|---|---|
|
|
|
|
|
Nuxt 3 and later projects use |
|
|
Alpha support. |
|
|
Uses Next.js-specific Webpack presets. |
|
|
Accepts an |
A community or custom framework definition (a package named cypress-ct-* or @org/cypress-ct-*) can also be given as
the framework, paired with a supported bundler.
Automatic bundler configuration
With the object syntax Cypress reuses the configuration you already develop with, so you rarely duplicate it:
-
Vite: when
viteConfigis omitted, Cypress searches upward from the project root for avite.config.ts,.js,.mjs,.cjs,.mtsor.cts, loads it and merges in its own settings (plugins, the public path, the spec entries and the file-system allow rules). If none is found, Cypress shows an error asking for one or for an explicitviteConfig. The React example below has a plainvite.config.tswith the React plugin, and Cypress used it unchanged:import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()] });Source: React component testing
-
Webpack: when
webpackConfigis omitted, Cypress searches upward for awebpack.config.*. For Next.js and Angular it applies a framework preset first; for React, Vue and Svelte it merges the detected configuration with a Cypress-specific overlay.
On Vite 8, a project whose package.json has no "type": "module" makes Vite print a warning that the config uses ESM
syntax in a file loaded as CommonJS (it is planned to stop working in a later Vite major version). The examples set
"type": "module" and no longer print it.
Overriding the bundler configuration
Pass viteConfig or webpackConfig to add plugins, change aliases or point at a file outside the project root. Both
accept an object or an async function returning an object, and the result is merged with the detected configuration.
This block was not run; it is the documented shape:
import { defineConfig } from 'cypress';
import customViteConfig from './vite.config.custom';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'vite',
// an object...
viteConfig: customViteConfig,
// ...or a function evaluated at run time, merged with the detected vite.config
// viteConfig: async () => ({ ...(await import('./vite.config')).default }),
},
},
});
Source: Configure component tests
For Webpack, the same position takes webpackConfig (an object, a file loaded with require or an async function).
Path aliases
Cypress defines no aliases of its own: an import such as @/components/Button resolves only if the bundler
configuration Cypress uses resolves it, through resolve.alias in the detected or supplied vite.config or
webpack.config. Two cases need care:
-
Meta-frameworks that own the bundler (Nuxt, for example) configure Vite internally, and Cypress does not execute
nuxt.config, so their aliases and auto-imports are invisible and have to be declared inviteConfig. -
Aliases declared only under
compilerOptions.pathsintsconfig.jsonare a type-checking feature that neither Vite nor Webpack reads. Addvite-tsconfig-pathsto the Vite plugins, ortsconfig-paths-webpack-pluginunderresolve.pluginsfor Webpack.
import { defineConfig } from 'cypress';
import { fileURLToPath } from 'url';
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
viteConfig: {
resolve: {
alias: {
'@': fileURLToPath(new URL('./', import.meta.url)),
'~': fileURLToPath(new URL('./', import.meta.url)),
},
},
},
},
},
});
Source: Configure component tests
This block was not run (it targets a Nuxt-style project).
Function syntax
When the object syntax does not expose an option, import the dev server yourself and call it from a devServer
function. The function receives cypressDevServerConfig with specs (the spec files about to run), cypressConfig (the
resolved configuration) and devServerEvents (an event emitter for compile events), and must return, or resolve to, an
object with the port of the running server and an optional close callback. Extra options can be stored on
component.devServerConfig and arrive as the second argument. Not run:
import { defineConfig } from 'cypress';
import { devServer as viteDevServer } from '@cypress/vite-dev-server';
export default defineConfig({
component: {
devServer(cypressDevServerConfig) {
return viteDevServer({
...cypressDevServerConfig,
framework: 'react',
viteConfig: async () => (await import('./vite.config')).default,
});
},
},
});
Source: Configure component tests
For Webpack the import is devServer as webpackDevServer from @cypress/webpack-dev-server, which is a dependency of
cypress and is not installed separately. A project on another bundler passes a function that starts its own server;
the requirements for that are described under Custom frameworks.
Other component options
| Option | Default | Meaning |
|---|---|---|
|
none (required) |
Framework and bundler, or a function that starts a server. |
|
|
The page components are rendered into: global assets, fonts, external scripts and the root element. |
|
|
Compiled and loaded before each spec. |
|
|
Which files are component specs. Files matching |
|
|
Globs to ignore. |
|
|
URL prefix under which compiled specs and assets are served. Set it to an empty string to line it up with Vite’s default
public path when component tests load assets from |
|
|
Compiles only the resources a spec needs, just before it runs (Webpack). |
|
|
Runs all specs in one tab to speed up |
|
|
Milliseconds after which the default reporter marks a test as slow. |
|
none |
Registers Node events ( |
Settings placed at the top level of the configuration apply to both testing types, and the component object overrides
them for component tests only, for example viewportWidth and viewportHeight for a smaller default viewport.
To restrict the specs to the source tree:
export default defineConfig({
component: {
devServer: { framework: 'react', bundler: 'vite' },
specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}',
},
});
Source: Configure component tests
Custom index file and the root element
The index page is a plain HTML document. The scaffolded one is minimal: the div with the data-cy-root attribute is
where cy.mount() renders. The file used by the examples:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>
Source: Styling components
Point component.indexHtmlFile at another path (relative to the project, or absolute) to use a different page.
Next.js projects additionally need an element with the id __next_css__DO_NOT_USE__ in the head, which Cypress adds
when scaffolding a Next.js project: Next.js injects its CSS through style-loader, which looks for that marker, and
without it global stylesheets can fail to apply or mounting can throw. Cypress 15.11 also added a manual bootstrap
script injection through a script tag with the data-cy-bootstrap attribute, a workaround for React server-side
rendering hydration mismatches when the index page contains pre-rendered markup.
The cy.mount command
Cypress has no built-in cy.mount(). It is a custom command that the support file registers by wrapping the mount
function exported by the framework adapter, and the setup wizard writes that file for you. Registering your own command
rather than importing mount into every spec has two advantages: the command is global (no import per test), and it is
the one place to wrap every component in the providers, plugins and routers it needs.
Registering the command
| Framework | Import | What mounting does |
|---|---|---|
React |
|
Renders JSX with React DOM into the root element. Supports React 18 and 19. |
Vue |
|
Uses Vue Test Utils internally, but in the real browser instead of an emulated DOM. |
Angular |
|
Uses Angular TestBed internally, in the real browser. |
Svelte |
|
Mounts a Svelte 5 component with |
The minimal React support file, as used by the example project below (the stylesheet import and the TypeScript
declaration that types cy.mount are the two additions to the bare command):
import { mount } from 'cypress/react';
import '../../src/index.css';
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
Cypress.Commands.add('mount', mount);
Source: cy.mount
The three other support files look the same, with the matching import. TypeScript projects need the Chainable
declaration so that cy.mount type-checks; the docs recommend placing it in a cypress.d.ts in the project root and, if
the types are not found, adding that file to the include of every tsconfig.json. In the examples it lives in the
support file itself, and tsc --noEmit passed for all three projects with the types entry cypress.
Mount options and return values
Each adapter has its own option object, and cy.mount() yields an object with a handle on what was rendered.
| Framework | Signature and options | Yielded value |
|---|---|---|
React |
|
|
Vue |
|
|
Angular |
|
|
Svelte |
|
|
The React adapter also exports getContainerEl(), which returns the root element, and the Angular adapter exports
createOutputSpy(alias), described below. For Vue, the full list of options is the Vue Test Utils mount option list.
Every cy.mount() call is a normal Cypress command: it is queued and runs in order with the commands around it, so it
returns before the component is on screen. Assertions after it retry until the component has rendered, which is why
specs need no waitFor and no act(). A consequence for error handling is shown under Providers and render errors.
Passing data and testing events
Each framework passes data in its own way, but the test shape is identical: mount, interact, assert. Spies created with
cy.spy() are the replacement for event-handler mocks, and cy.stub() is the choice when the callback must not run any
real behaviour (see Network, Stubs, Spies & Clocks).
| Framework | Props and inputs | Events and outputs |
|---|---|---|
React |
JSX props: |
Pass a spy as the callback prop: |
Vue |
|
Pass a spy as the |
Angular |
|
|
Svelte |
|
Pass a spy as a callback prop (Svelte 5 components use callback props instead of dispatched events) |
The Angular helper creates an event emitter whose emit method is spied on, and registers the spy under the alias. This
is the documented usage (not run, because no Angular workspace was available):
import { StepperComponent } from './stepper.component';
import { mount, createOutputSpy } from 'cypress/angular';
it('emits the incremented value', () => {
mount(StepperComponent, {
componentProperties: { change: createOutputSpy('changeSpy') },
});
cy.get('[data-cy=increment]').click();
cy.get('@changeSpy').should('have.been.calledWith', 1);
});
Source: Angular API
Custom mount commands
Because cy.mount() is a command you define, the right place for repetitive wrapping is the support file. The pattern
is the same everywhere: accept the usual arguments, add what the real application provides around the component, call
the adapter’s mount.
The Vue example project installs the same plugin the application installs in main.ts. The plugin (a minimal
translation helper), the component that uses it and the support file:
import type { App } from 'vue';
export const messages: Record<string, string> = { greeting: 'Welcome to the Bookshelf' };
export const i18n = {
install(app: App) {
app.config.globalProperties.$t = (key: string) => messages[key] ?? key;
},
};
Source: Vue examples
<template>
<h1 data-cy="greeting">{{ $t('greeting') }}</h1>
</template>
Source: Vue examples
import { mount } from 'cypress/vue';
import '../../src/index.css';
import { i18n } from '../../src/i18n';
type MountParams = Parameters<typeof mount>;
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
}
}
}
// Wrap every mount with the plugins the real application installs in main.ts.
Cypress.Commands.add('mount', (component: MountParams[0], options: MountParams[1] = {}) => {
options.global = options.global || {};
options.global.plugins = options.global.plugins || [];
options.global.plugins.push(i18n);
return mount(component, options);
});
Source: cy.mount
The spec needs no knowledge of the plugin, which is the point:
import Greeting from './Greeting.vue';
describe('<Greeting />', () => {
it('uses the plugin installed by the custom cy.mount()', () => {
cy.mount(Greeting);
cy.get('[data-cy=greeting]').should('have.text', 'Welcome to the Bookshelf');
});
});
Source: Vue examples
The React equivalent wraps the component in a provider. The React example project tests a provider-dependent component by wrapping it in the spec, and the same wrapping belongs in the custom command when every component needs it. The router and Redux variants from the official documentation follow (not run, because the example project has neither library):
import { mount } from 'cypress/react';
import { MemoryRouter } from 'react-router-dom';
Cypress.Commands.add('mount', (component, options = {}) => {
const { routerProps = { initialEntries: ['/'] }, ...mountOptions } = options;
const wrapped = <MemoryRouter {...routerProps}>{component}</MemoryRouter>;
return mount(wrapped, mountOptions);
});
Source: React examples
import { mount } from 'cypress/react';
import { Provider } from 'react-redux';
import { getStore } from '../../src/store';
Cypress.Commands.add('mount', (component, options = {}) => {
// Use the default store if one is not provided
const { reduxStore = getStore(), ...mountOptions } = options;
const wrapped = <Provider store={reduxStore}>{component}</Provider>;
return mount(wrapped, mountOptions);
});
Source: React examples
For Angular, the documented custom command registers default declarations, providers or imports, merging in whatever a test passes (not run):
import { Type } from '@angular/core';
import { mount, MountConfig, MountResponse } from 'cypress/angular';
import { ButtonComponent } from 'src/app/button/button.component';
import { CardComponent } from 'src/app/card/card.component';
const declarations = [ButtonComponent, CardComponent];
function customMount<T>(component: string | Type<T>, config?: MountConfig<T>) {
if (!config) {
config = { declarations };
} else {
config.declarations = [...(config?.declarations || []), ...declarations];
}
return mount<T>(component, config);
}
declare global {
namespace Cypress {
interface Chainable {
mount<T>(component: string | Type<T>, config?: MountConfig<T>): Cypress.Chainable<MountResponse<T>>;
}
}
}
Cypress.Commands.add('mount', customMount);
Source: Angular examples
A complete example: React and Vite
The React project contains the two widgets and a review list. It was created by hand, which is equivalent to the wizard
output, and its package.json has the dependencies below (all installed with CYPRESS_INSTALL_BINARY=0 because the
Cypress binary was already in the cache):
{
"type": "module",
"scripts": { "test": "cypress run --component" },
"devDependencies": {
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"cypress": "^16.2.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"typescript": "^7.0.2",
"vite": "^8.3.5"
}
}
Source: React component testing
The layout is conventional: specs sit next to the components, the support file and index page are in cypress/support,
and the cypress.config.ts and vite.config.ts shown earlier are at the root.
cypress.config.ts
vite.config.ts
cypress/fixtures/reviews.json
cypress/support/component.tsx
cypress/support/component-index.html
src/index.css
src/components/RatingStars.tsx src/components/RatingStars.cy.tsx
src/components/Expandable.tsx src/components/Expandable.cy.tsx
src/components/ReviewList.tsx src/components/ReviewList.cy.tsx
src/components/Theme.tsx src/components/Providers.cy.tsx
The star rating
The component is the review form’s star control: a radio group of buttons whose checked state is controlled by a
value prop and which reports clicks through onChange.
type Props = {
value: number;
onChange: (value: number) => void;
max?: number;
readOnly?: boolean;
};
export function RatingStars({ value, onChange, max = 5, readOnly = false }: Props) {
return (
<div className="stars" role="radiogroup" aria-label="Rating">
{Array.from({ length: max }, (_, i) => i + 1).map(n => (
<button
key={n}
type="button"
role="radio"
aria-checked={n === value}
aria-label={`${n} star${n === 1 ? '' : 's'}`}
disabled={readOnly}
onClick={() => onChange(n)}
>
{n <= value ? '★' : '☆'}
</button>
))}
</div>
);
}
Source: React examples
The spec covers a controlled value with a spy, a stateful parent, the read-only state and rerender():
import { useState } from 'react';
import { RatingStars } from './RatingStars';
describe('<RatingStars />', () => {
it('shows the current value and reports clicks', () => {
const onChange = cy.spy().as('onChange');
cy.mount(<RatingStars value={2} onChange={onChange} />);
cy.getByRole('radio', { name: '2 stars', native: false }).should('have.attr', 'aria-checked', 'true');
cy.getByRole('radio', { name: '5 stars', native: false }).click();
cy.get('@onChange').should('have.been.calledOnceWithExactly', 5);
});
it('keeps its own state when wrapped in a stateful parent', () => {
function Controlled() {
const [value, setValue] = useState(2);
return <RatingStars value={value} onChange={setValue} />;
}
cy.mount(<Controlled />);
cy.getByRole('radio', { name: '5 stars', native: false }).click();
cy.getByRole('radio', { name: '5 stars', native: false }).should('have.attr', 'aria-checked', 'true');
cy.getByRole('radio', { name: '2 stars', native: false }).should('have.attr', 'aria-checked', 'false');
});
it('ignores clicks when read-only', () => {
const onChange = cy.stub().as('onChange');
cy.mount(<RatingStars value={3} onChange={onChange} readOnly />);
cy.getByRole('radio', { native: false }).should('have.length', 5);
cy.getByRole('radio', { name: '1 star', native: false }).should('be.disabled');
cy.getByRole('radio', { name: '1 star', native: false }).click({ force: true });
cy.get('@onChange').should('not.have.been.called');
});
it('re-renders with new props through rerender()', () => {
cy.mount(<RatingStars value={1} onChange={() => {}} readOnly />).then(({ rerender }) => {
rerender(<RatingStars value={4} onChange={() => {}} readOnly />);
});
cy.getByRole('radio', { name: '4 stars', native: false }).should('have.attr', 'aria-checked', 'true');
});
});
Source: React examples
Five points in this spec are worth stating:
-
The buttons carry
role="radio"as an attribute rather than beinginput type="radio"elements.cy.getByRole()(new in Cypress 16.2) matches only native elements for the role by default, and forradiothe native element isinput. The first version of this spec, written withoutnative: false, failed after the 4-second timeout with a message that explained exactly this: it listed the accessible roles it did find (aradiogroupnamed "Rating" and fiveradioelements) and said that elements with aroleattribute were skipped unless{ native: false }is passed. Components built on nativebutton,inputandaelements, such as theExpandablespec below, need no option. -
cy.spy().as('onChange')creates a spy and gives it an alias;cy.get('@onChange')retrieves it, and the Sinon-Chai assertioncalledOnceWithExactlychecks the call. The spy is created beforecy.mount()and passed as a prop. -
A component with no state of its own needs a parent to show a change. The second test defines a small controlled wrapper inside the test and mounts that, which keeps the component under test unmodified.
-
cy.mount()yields{ component, rerender }.rerender(jsx)re-renders with new props in the same root without remounting, which is how a prop transition is tested; the Vue equivalent iswrapper.setProps(). -
The read-only test clicks the disabled button with
force: trueto prove nothing is emitted. Without the option, Cypress’s actionability check refuses to click a disabled element, which is itself a guarantee a Node-based DOM runner does not give.
The expandable description and styling
import type { ReactNode } from 'react';
type Props = {
title: string;
expanded: boolean;
setExpanded: (expanded: boolean) => void;
children?: ReactNode;
};
export function Expandable({ title, expanded, setExpanded, children }: Props) {
return (
<section className="expandable">
<button type="button" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
{title}
</button>
{expanded && <div className="body">{children}</div>}
</section>
);
}
Source: React examples
import { Expandable } from './Expandable';
describe('<Expandable />', () => {
it('shows the body only when expanded', () => {
const setExpanded = cy.spy().as('setExpanded');
cy.mount(
<Expandable title="Description" expanded={false} setExpanded={setExpanded}>
A story about a lighthouse.
</Expandable>,
);
cy.contains('A story about a lighthouse.').should('not.exist');
cy.getByRole('button', { name: 'Description' }).should('have.attr', 'aria-expanded', 'false').click();
cy.get('@setExpanded').should('have.been.calledWith', true);
cy.mount(
<Expandable title="Description" expanded setExpanded={setExpanded}>
A story about a lighthouse.
</Expandable>,
);
cy.contains('A story about a lighthouse.').should('be.visible');
});
it('is rendered with the global stylesheet', () => {
cy.mount(<Expandable title="Styled" expanded setExpanded={() => {}}>Body</Expandable>);
cy.get('.expandable .body').should('have.css', 'margin-top', '8px');
cy.get('body').should('have.css', 'font-family').and('match', /system-ui/);
});
});
Source: React examples
The first test mounts the component twice in one test, collapsed and then expanded; the second asserts computed styles
(margin-top of 8px from the global stylesheet and the system font stack), which only works because the support file
imports the stylesheet. Styling and global setup shows what happens when that import is missing.
A component that calls the API
The review list fetches the reviews of one book. Its test never contacts Bookshelf: cy.intercept() answers the request
with a fixture, a delayed empty body and a 500, so the three states are tested deterministically.
import { useEffect, useState } from 'react';
type Review = { id: number; author: string; rating: number; text: string };
export function ReviewList({ bookId }: { bookId: number }) {
const [reviews, setReviews] = useState<Review[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/reviews?bookId=${bookId}`)
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<Review[]>;
})
.then(setReviews)
.catch(e => setError(String(e.message)));
}, [bookId]);
if (error) return <p role="alert">Could not load reviews: {error}</p>;
if (!reviews) return <p aria-busy="true">Loading reviews…</p>;
if (reviews.length === 0) return <p>No reviews yet.</p>;
return (
<ul aria-label="Reviews">
{reviews.map(r => (
<li key={r.id}>
<strong>{r.author}</strong> ({r.rating}/5): {r.text}
</li>
))}
</ul>
);
}
Source: cy.intercept
[
{ "id": 1, "author": "reader", "rating": 5, "text": "Clear, calm and surprisingly funny." },
{ "id": 2, "author": "admin", "rating": 4, "text": "Great chapter on build pipelines." }
]
Source: cy.fixture
import { ReviewList } from './ReviewList';
describe('<ReviewList />', () => {
it('renders the reviews returned by the API', () => {
cy.intercept('GET', '/api/reviews?bookId=1', { fixture: 'reviews.json' }).as('reviews');
cy.mount(<ReviewList bookId={1} />);
cy.wait('@reviews');
cy.getByRole('list', { name: 'Reviews' }).find('li').should('have.length', 2);
cy.contains('li', 'reader').should('contain.text', '(5/5)');
});
it('shows a loading state while the request is pending', () => {
cy.intercept('GET', '/api/reviews*', { body: [], delay: 500 });
cy.mount(<ReviewList bookId={1} />);
cy.get('[aria-busy=true]').should('contain.text', 'Loading reviews');
cy.contains('No reviews yet.').should('be.visible');
});
it('shows an error when the server fails', () => {
cy.intercept('GET', '/api/reviews*', { statusCode: 500, body: {} });
cy.mount(<ReviewList bookId={1} />);
cy.getByRole('alert').should('contain.text', 'HTTP 500');
});
});
Source: cy.intercept
Requests made with a relative URL go to the dev server’s origin, so the route is matched with a relative path. In Cypress 16 the native network path used by Chromium browsers and the legacy proxy path (Firefox, WebKit) both support this interception, as explained in Network, Stubs, Spies & Clocks. Routes can also be matched with a full URL or a glob, so a component that calls another origin is stubbed the same way.
Providers and render errors
Theme.tsx holds a context provider, a component that reads the context, and a component that throws while rendering
(it dereferences an optional prop):
import { createContext, useContext, type ReactNode } from 'react';
const ThemeContext = createContext<'light' | 'dark'>('light');
export function ThemeProvider({ theme, children }: { theme: 'light' | 'dark'; children: ReactNode }) {
return (
<ThemeContext.Provider value={theme}>
<div data-theme={theme}>{children}</div>
</ThemeContext.Provider>
);
}
export function ThemeBadge() {
const theme = useContext(ThemeContext);
return <span data-cy="theme-badge">Theme: {theme}</span>;
}
export function Broken({ user }: { user?: { name: string } }) {
return <p>{user!.name}</p>;
}
Source: React examples
import { ThemeBadge, ThemeProvider, Broken } from './Theme';
describe('providers and render errors', () => {
it('wraps the component in the provider it needs', () => {
cy.mount(
<ThemeProvider theme="dark">
<ThemeBadge />
</ThemeProvider>,
);
cy.get('[data-cy=theme-badge]').should('have.text', 'Theme: dark');
});
it('surfaces a render error as an uncaught exception', () => {
const seen = cy.spy().as('renderError');
cy.on('uncaught:exception', err => {
expect(err.message).to.include("reading 'name'");
seen();
return false;
});
cy.mount(<Broken />);
cy.get('@renderError').should('have.been.calledOnce');
});
});
Source: React examples
cy.mount() only enqueues the mount, so a component that throws while rendering does not throw in the test function:
the error surfaces later as an uncaught exception in the application frame, and Cypress fails the test on any uncaught
exception by default. To assert on a render error, register cy.on('uncaught:exception') before mounting, check the
error and return false so the test does not fail. The spy plus the calledOnce assertion in the example makes sure
the handler really ran; without it the test would pass even if no error was thrown. A React error boundary renders its
fallback but does not stop the error from reaching Cypress, so the handler is needed in that case too; the official
React and Svelte examples show the same technique with an ErrorBoundary and svelte:boundary. During the run Vite
printed the error with its source location to the terminal, which is expected output, not a failure.
The same example in Vue
The Vue project uses framework: 'vue' with Vite and @vitejs/plugin-vue:
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'vue',
bundler: 'vite',
},
video: false,
},
});
Source: Vue component testing
The star rating is a single-file component using v-model conventions (the modelValue prop and the
update:modelValue event), and the expandable description uses a default slot:
<script setup lang="ts">
const props = withDefaults(defineProps<{ modelValue: number; max?: number; readOnly?: boolean }>(), {
max: 5,
readOnly: false,
});
const emit = defineEmits<{ 'update:modelValue': [value: number] }>();
</script>
<template>
<div class="stars" role="radiogroup" aria-label="Rating">
<button
v-for="n in props.max"
:key="n"
type="button"
role="radio"
:aria-checked="n === props.modelValue"
:aria-label="`${n} star${n === 1 ? '' : 's'}`"
:disabled="props.readOnly"
@click="emit('update:modelValue', n)"
>
{{ n <= props.modelValue ? '★' : '☆' }}
</button>
</div>
</template>
Source: Vue examples
import RatingStars from './RatingStars.vue';
describe('<RatingStars />', () => {
it('shows the current value and emits update:modelValue on click', () => {
const onUpdate = cy.spy().as('onUpdate');
cy.mount(RatingStars, {
props: { modelValue: 2, 'onUpdate:modelValue': onUpdate },
});
cy.getByRole('radio', { name: '2 stars', native: false }).should('have.attr', 'aria-checked', 'true');
cy.getByRole('radio', { name: '5 stars', native: false }).click();
cy.get('@onUpdate').should('have.been.calledOnceWithExactly', 5);
});
it('updates the display when the parent changes the prop (setProps)', () => {
cy.mount(RatingStars, { props: { modelValue: 1, readOnly: true } }).then(({ wrapper }) => {
wrapper.setProps({ modelValue: 4 });
});
cy.getByRole('radio', { name: '4 stars', native: false }).should('have.attr', 'aria-checked', 'true');
cy.getByRole('radio', { name: '1 star', native: false }).should('have.attr', 'aria-checked', 'false');
});
it('is disabled when read-only', () => {
cy.mount(RatingStars, { props: { modelValue: 3, readOnly: true } });
cy.getByRole('radio', { native: false }).should('have.length', 5);
cy.getByRole('radio', { name: '1 star', native: false }).should('be.disabled');
});
});
Source: Vue examples
<script setup lang="ts">
defineProps<{ title: string; expanded: boolean }>();
defineEmits<{ toggle: [expanded: boolean] }>();
</script>
<template>
<section class="expandable">
<button type="button" :aria-expanded="expanded" @click="$emit('toggle', !expanded)">{{ title }}</button>
<div v-if="expanded" class="body"><slot /></div>
</section>
</template>
Source: Vue examples
import Expandable from './Expandable.vue';
describe('<Expandable />', () => {
it('renders the default slot only when expanded', () => {
const onToggle = cy.spy().as('onToggle');
cy.mount(Expandable, {
props: { title: 'Description', expanded: false, onToggle },
slots: { default: 'A story about a lighthouse.' },
});
cy.contains('A story about a lighthouse.').should('not.exist');
cy.getByRole('button', { name: 'Description' }).click();
cy.get('@onToggle').should('have.been.calledWith', true);
});
it('shows the slot content and the global stylesheet applies', () => {
cy.mount(Expandable, {
props: { title: 'Description', expanded: true },
slots: { default: '<em data-cy="slot">A story about a lighthouse.</em>' },
});
cy.get('[data-cy=slot]').should('be.visible');
cy.get('.expandable .body').should('have.css', 'margin-top', '8px');
});
});
Source: Vue examples
Vue specifics visible in these specs:
-
propsis an option ofcy.mount(), and event listeners are props too:onUpdate:modelValueis the listener for theupdate:modelValueevent, which is why a spy is passed under that name. -
slotstakes a string (text or HTML) or a component, which makes the slot content part of the test. -
The yielded
wrapperis the Vue Test Utils wrapper, sosetProps()and the rest of that API are available. -
TypeScript needs a shim so that the editor and
tscaccept.vueimports; real projects normally usevue-tsc. The shim used here declares the module asDefineComponent<any, any, any>.
Nuxt 3 and later projects are tested as Vue 3 with Vite: Cypress has no dedicated Nuxt framework definition and does not
read nuxt.config, so aliases must be declared in viteConfig and auto-imports (ref, useRoute,
useRuntimeConfig) are not available to a component mounted in isolation unless it imports them explicitly or the
equivalent Vite plugin is added.
The same example in Svelte
Svelte 5 component testing is in alpha. The configuration needs a svelte.config.js for the Vite plugin as well:
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: {
framework: 'svelte',
bundler: 'vite',
},
video: false,
},
});
Source: Svelte component testing
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default { preprocess: vitePreprocess() };
Source: Svelte component testing
<script lang="ts">
let { value, onChange, max = 5, readOnly = false }: {
value: number;
onChange: (value: number) => void;
max?: number;
readOnly?: boolean;
} = $props();
</script>
<div class="stars" role="radiogroup" aria-label="Rating">
{#each Array.from({ length: max }, (_, i) => i + 1) as n (n)}
<button
type="button"
role="radio"
aria-checked={n === value}
aria-label={`${n} star${n === 1 ? '' : 's'}`}
disabled={readOnly}
onclick={() => onChange(n)}
>
{n <= value ? '★' : '☆'}
</button>
{/each}
</div>
Source: Svelte examples
import RatingStars from './RatingStars.svelte';
describe('<RatingStars />', () => {
it('shows the current value and calls onChange on click', () => {
const onChange = cy.spy().as('onChange');
cy.mount(RatingStars, { props: { value: 2, onChange } });
cy.getByRole('radio', { name: '2 stars', native: false }).should('have.attr', 'aria-checked', 'true');
cy.getByRole('radio', { name: '5 stars', native: false }).click();
cy.get('@onChange').should('have.been.calledOnceWithExactly', 5);
});
it('is disabled when read-only and exposes the component instance', () => {
cy.mount(RatingStars, { props: { value: 3, onChange: () => {}, readOnly: true } }).then(({ component }) => {
expect(component).to.exist;
});
cy.getByRole('radio', { name: '1 star', native: false }).should('be.disabled');
});
});
Source: Svelte examples
Svelte 5 components take callback props rather than dispatching events, so a spy is passed as onChange in props.
The yielded object has the mounted component. A component that throws is tested with a svelte:boundary fixture
component and the same uncaught:exception handler as above.
Webpack, Next.js and Angular
These setups were not run; their configuration is reproduced from the official documentation.
Webpack keeps the same component.devServer shape with bundler: 'webpack'. For React the project needs react,
react-dom and webpack 5; @cypress/react, @cypress/webpack-dev-server and webpack-dev-server ship inside the
cypress package.
import { defineConfig } from 'cypress';
import webpackConfig from './webpack.config';
export default defineConfig({
component: {
devServer: {
framework: 'react',
bundler: 'webpack',
webpackConfig, // optional: Cypress infers the config when omitted
},
},
});
Source: React component testing
Next.js 15 and 16 use framework: 'next' with bundler: 'webpack'. Component tests cannot exercise getServerSideProps
or getStaticProps, which run only on the server, so a page mounted as a component receives undefined props; the
recommendation is to test pages end to end and individual components with component tests. Turbopack is not supported.
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: { framework: 'next', bundler: 'webpack' },
},
});
Source: React component testing
Angular 21 and 22 are supported through the Angular CLI. cypress/angular requires @angular-devkit/build-angular,
even for zoneless projects or projects built with @angular/build, and from Cypress 16 it supports zoneless testing with
no configuration (zone.js is no longer required; zoneless is the default in Angular 21 and 22). The spec pattern is
usually narrowed to .cy.ts files:
import { defineConfig } from 'cypress';
export default defineConfig({
component: {
devServer: { framework: 'angular', bundler: 'webpack' },
specPattern: '**/*.cy.ts',
},
});
Source: Angular component testing
Projects that are not a plain Angular CLI workspace (a monorepo with a non-default layout) replace the detected
angular.json data with options.projectConfig, which then has to repeat every build option the tests rely on:
export default defineConfig({
component: {
devServer: {
framework: 'angular',
bundler: 'webpack',
options: {
projectConfig: {
root: '',
sourceRoot: 'apps/my-app',
buildOptions: {
outputPath: 'dist/my-app',
index: 'apps/my-app/src/index.html',
main: 'apps/my-app/src/main.ts',
tsConfig: 'apps/my-app/tsconfig.app.json',
inlineStyleLanguage: 'scss',
assets: ['apps/my-app/src/favicon.ico', 'apps/my-app/src/assets'],
styles: ['apps/my-app/src/styles.scss'],
},
},
},
},
},
});
Source: Angular component testing
Angular specs use the idioms below. Standalone components are the simplest case, and imports, declarations and
providers apply to module-declared components; a template string can mount a component together with bindings.
import { StepperComponent } from './stepper.component';
import { createOutputSpy } from 'cypress/angular';
it('mounts a standalone component with an input', () => {
cy.mount(StepperComponent, { componentProperties: { count: 100 } });
cy.get('[data-cy=counter]').should('have.text', '100');
});
it('mounts from a template string', () => {
cy.mount('<app-stepper [count]="100" (change)="change.emit($event)"></app-stepper>', {
declarations: [StepperComponent],
componentProperties: { change: createOutputSpy('changeSpy') },
});
cy.get('[data-cy=increment]').click();
cy.get('@changeSpy').should('have.been.calledWith', 101);
});
Source: Angular examples
Signal inputs (input(), model()) are supported by componentProperties, which accepts the value, a writable
signal or the input signal itself; legacy @Input() decorators are updated through fixture.componentRef.setInput() so
that change detection runs. autoSpyOutputs and autoDetectChanges were removed in Cypress 16.
Styling and global setup
Unstyled or oddly styled components are the most common first-day problem. The reason is that styles and runtime setup
your application loads outside the component — in main.ts, in App.vue, in a link tag of index.html — do not run in
a component test. Styles are business logic too (a covered button cannot be clicked, a modal that cannot be dismissed),
and Cypress is able to test them only if they are loaded. There are exactly two places to recreate them:
-
cypress/support/component-index.htmlfor everything that is a tag in the application’shead: stylesheets, fontlinktags, external scripts. -
cypress/support/component.js(or the.ts,.tsxvariant) for everything that is imported in JavaScript: global CSS, a reset, UI-library setup, store and router creation, plugin installation, thecy.mount()command.
The advice from the documentation is to extract the setup the application and the tests share into one module (for
example src/setup.js) that main.js and the support file both import, so the two cannot drift. The cost of forgetting is
real, and the React example proves it: the Expandable spec asserts margin-top: 8px from src/index.css. With the
index.css import removed from cypress/support/component.tsx, the test failed after the four-second timeout:
1) <Expandable />
is rendered with the global stylesheet:
Timed out retrying after 4000ms
+ expected - actual
-'0px'
+'8px'
Source: Styling components
The import was restored afterwards, and the numbers in this page come from the restored project.
Specific style problems and their fixes, from the official documentation:
| Symptom | Cause and fix |
|---|---|
Component works but looks unstyled |
The global stylesheet is not imported. Import it in the support file, or add its |
Everything renders in a default serif font |
Fonts are not loaded. Add the font |
Reset or normalize CSS is ignored |
It is imported in the wrong file; import it in the support file or the index page. |
Component needs a theme, i18n or UI-library provider |
Wrap the component in a custom |
Root-level styles live in |
Move global rules into a stylesheet and import it from both the root component and the support file. |
Next.js: styles missing or mount throws |
Keep the |
Angular: Sass |
A custom |
Angular with Tailwind CSS 4: utility classes do nothing |
Tailwind 4 generates utilities through its own build plugin, which the Angular pipeline does not run. Precompile the
stylesheet with the Tailwind CLI and list the output file in |
The Angular rows were not run. Because Cypress checks visibility and overlap with the real box model, a missing stylesheet may also change whether a click succeeds: elements that are 0px high or overlapped by unstyled siblings fail actionability checks that the styled component would pass.
Custom frameworks
A library with no official adapter can still get the same experience as React or Vue by publishing a framework
definition, the mechanism Cypress uses internally. A definition has three parts, and Cypress ships a template
repository for them (cypress-io/cypress-ct-definition-template). None of this was run, because it requires publishing a
package; the code below is the documented Solid.js example, condensed.
-
A definition file, conventionally
definition.cjs, loaded by the setup wizard in Node.js. Itstypemust equal the package name on npm, and the name must followcypress-ct-*or@organization/cypress-ct-*(for examplecypress-ct-react-jsor@cypress/cypress-ct-react). Cypress scans the project’snode_modulesfor packages with that naming convention and lists them, labelled "community", as framework options. -
A mount adapter, conventionally
index.mjs, which exports a named functionmount. It renders the component into the root element that Cypress prepared and registers cleanup for between tests. -
A
package.jsonwhoseexportsfield sendsnodeto the definition file and everything else to the adapter.
// definition.cjs
const { defineFrameworkDefinition } = require('cypress');
const solidDep = {
type: 'solid-js', // unique identifier
name: 'Solid', // human readable name
package: 'solid-js', // npm package
installer: 'solid-js', // may include a version or tag, used to build the install command
description: 'A declarative, efficient, and flexible JavaScript library for building user interfaces.',
minVersion: '^1.6.0',
};
const solidVitePlugin = {
type: 'solid-js-vite-plugin',
name: 'Vite Plugin Solid',
package: 'vite-plugin-solid',
installer: 'vite-plugin-solid',
description: 'A simple integration to run solid-js with vite',
minVersion: '^1.6.0 || ^2.0.0',
};
module.exports = defineFrameworkDefinition({
type: '@lmiller1990/cypress-ct-solid-js', // must match the npm package name
name: 'Solid.js', // label in the setup wizard
supportedBundlers: ['vite'], // 'vite' and/or 'webpack'
detectors: [solidDep], // how Cypress auto-selects this definition
dependencies: (bundler) => [solidDep, solidVitePlugin], // offered for installation when missing
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"></svg>', // optional
});
// index.mjs
import { getContainerEl, setupHooks } from '@cypress/mount-utils';
import { render } from 'solid-js/web';
let dispose;
function cleanup() {
dispose?.();
}
export function mount(component, options = {}) {
// The root element Cypress prepared for this test
const root = getContainerEl();
dispose = render(() => component, root);
// Wait one tick so asynchronous rendering has executed
return cy.wait(0, { log: false }).then(() => {
if (options.log !== false) {
Cypress.log({ name: 'mount', message: 'Mounted component' });
}
});
}
// Cleanup between each test
setupHooks(cleanup);
{
"name": "@lmiller1990/cypress-ct-solid-js",
"exports": {
"node": "./definition.cjs",
"default": "./index.mjs"
},
"files": ["package.json", "definition.cjs", "index.mjs"],
"dependencies": { "@cypress/mount-utils": "^4.0.0" },
"peerDependencies": { "solid-js": "^1.6.0", "cypress": "^12.7.0" }
}
A user who installs the package gets the definition in the wizard, and the wizard writes a support file that registers the adapter:
import { mount } from '@lmiller1990/cypress-ct-solid-js';
Cypress.Commands.add('mount', mount);
A project on a bundler other than Vite or Webpack, or one that needs full control over compilation, goes one level lower
and passes a function as component.devServer. The function receives { specs, cypressConfig, devServerEvents } and
returns { port, close } for a server it started. That server has to:
-
answer
<devServerPublicPathRoute>/index.htmlwith the file named bycypressConfig.indexHtmlFile, with a script injected that loads the support file and the active spec; -
serve the compiled files under
devServerPublicPathRoute(default/__cypress/src); -
emit
dev-server:compile:successondevServerEventswhen a build finishes, and recompile whendev-server:specs:changedreports a new spec list.
The injected script obtains the running Cypress from the parent frame, queues the support file and the spec as dynamic imports and starts the test process:
const CypressInstance = (window.Cypress = parent.Cypress);
const devServerPublicPathRoute = CypressInstance.config('devServerPublicPathRoute');
const importsToLoad = [];
const supportFilePath = CypressInstance.config('supportFile');
if (supportFilePath) {
const relative = supportFilePath.replace(CypressInstance.config('projectRoot'), '');
importsToLoad.push(() => import(`${devServerPublicPathRoute}${relative}`));
}
const { relative } = CypressInstance.spec;
importsToLoad.push(() => import(`${devServerPublicPathRoute}/${relative}`));
CypressInstance.onSpecWindow(window, importsToLoad);
CypressInstance.action('app:window:before:load', window);
Source: Configure component tests
Code coverage, selectors and other tooling
Component tests share the Cypress tooling with end-to-end tests:
-
Code coverage: the
@cypress/code-coverageplugin works for component tests, but its support import must be added to the component support file (cypress/support/component.ts); importing it only ine2e.tscollects nothing during component runs. The task registration insetupNodeEventsis the same as for end-to-end tests. See Debugging, Studio, Plugins & Reporters. -
Selectors:
data-cyattributes are the convention in the official examples (cy.get('[data-cy=counter]')); the examples above usecy.getByRole()where the component exposes meaningful roles, in line with the guidance in Test Design & Page Objects. -
Viewport and device: use
cy.viewport()in a test, or theviewportWidthandviewportHeightoptions in thecomponentblock, to test responsive components. The viewport size and scale are shown in a badge above the component in the interactive app, and a notice says that URL navigation is disabled in component tests. -
Cypress Cloud: component runs can be recorded and analysed like end-to-end runs; see Cypress Cloud. No Cloud account was used for the examples on this page.
-
Debugging: the component is rendered visibly in the application frame in
cypress open --component, so DevTools, the Command Log and time travel work as in end-to-end tests.
Common pitfalls
| Pitfall | What to do |
|---|---|
|
The support file does not register the command, or the |
Component renders unstyled, in a default font, or loses its provider |
Recreate the application’s global setup in the support file and the index page; see Styling and global setup. |
|
Aliases must be resolvable by the bundler Cypress uses; declare them in |
|
The elements have the role through an attribute. Pass |
A test that expects a render error passes without asserting anything |
|
Vite refuses to start or prints an ESM warning after the upgrade to Cypress 16 |
Cypress 16 requires Vite 8 and plugins compatible with it. Set |
Angular tests fail to compile after the upgrade to Cypress 16 |
Angular must be 21 or newer, |
A Next.js page component receives undefined props |
|
Specs pass in |
Check the browser (Electron versus Chrome) and that |
Test state leaks between tests |
Each test gets a clean root, but module-level state (a store created at import time, a mutated fixture) does not reset;
create it inside |
The getByRole and render-error rows come from the runs on this page; the others come from the official documentation and the changelog.
Component tests compared with the other approaches
Choose component tests for logic and rendering that depends on a real browser: focus handling, overlays, layout and overflow, CSS-dependent visibility, keyboard behaviour, form widgets. Keep end-to-end tests for flows that cross pages and layers. Within the tools covered in this section:
-
Cypress mounts a component from the spec itself (
cy.mount(<Stepper />)), with the compiled spec and the component in the same bundle and the test code running in the browser. -
Playwright keeps the test in Node.js and mounts a story by id through a gallery page you provide; callbacks cannot be passed from the test. See Playwright Component Testing.
-
Selenium has no component-testing mode; a component page can be served by hand and driven like any page.
-
Node-based runners with an emulated DOM are faster to start, but cannot check layout or overlap, which Cypress and Playwright both can.
What changed recently
| Version | Change |
|---|---|
16.2.0 (13 October 2026) |
|
16.0.0 (1 September 2026) |
Vite 5, 6 and 7 are no longer supported by the Vite dev server (minimum 8.0.0). Angular 18, 19 and 20 are no longer
supported (minimum 21.0.0); Angular 22 is supported; the zoneless harness is the default and |
15.14.0 |
Vite 8 support added for component testing. |
15.11.0 |
Manual bootstrap script injection through |
15.9.0 |
|
15.8.0 |
Angular 21 supported and zoneless testing introduced through |
15.7.0 |
Next.js 16 supported, with Webpack. |
15.0.0 |
Angular 20 supported (it still required |
14.0.0 |
Create React App, Vue CLI service, React 16 and 17, Vue 2, Svelte 3 and 4 and Angular 13 to 16 dropped. Support added
for React 19, Angular 19, Next.js 15, Svelte 5 and Vite 6. |