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

cy.visit() of a deployed or locally served application

cy.mount() of one component imported by the spec

Server

Your application server, started by you

A Vite or Webpack dev server, started and stopped by Cypress

Configuration block

e2e in the Cypress configuration

component in the Cypress configuration

Spec files and support file

cypress/e2e/**/*.cy.{js,jsx,ts,tsx}, support file cypress/support/e2e.js

**/*.cy.{js,jsx,ts,tsx} (files matching the end-to-end pattern are excluded), support file cypress/support/component.js, index page cypress/support/component-index.html

Backend

Real, or stubbed with cy.intercept()

None; requests the component makes are stubbed with cy.intercept()

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:

  1. Reads component.devServer from the configuration and starts the matching dev server (Vite or Webpack) on an available port. Both implementations ship inside the cypress package, so @cypress/vite-dev-server and @cypress/webpack-dev-server are not installed separately.

  2. Sets baseUrl to http://localhost: plus that port.

  3. Loads the index page (cypress/support/component-index.html by 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).

  4. Runs the spec. cy.mount() renders the component into the element marked data-cy-root, and every test starts from a clean root: the previous component is unmounted between tests.

  5. When the run ends or the app closes, shuts the dev server down.

sequenceDiagram participant C as Cypress (Node.js) participant D as Dev server (Vite or Webpack) participant B as Browser, application frame C->>D: start from component.devServer D-->>C: port C->>B: open component-index.html B->>D: import support file and spec D-->>B: compiled modules B->>B: support file registers cy.mount B->>B: test calls cy.mount, component renders into data-cy-root B->>B: cy.get, click, should run against the real DOM C->>D: close when the run ends

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 @angular-devkit/build-angular installed

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

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

cypress.config.ts

Holds the component block with devServer. This is the only required setting.

cypress/support/component.ts (or .tsx, .js, .jsx)

Loaded before every spec: registers cy.mount(), imports global styles and runtime setup.

cypress/support/component-index.html

The page the component is rendered into: global link tags, fonts and the data-cy-root element.

*.cy.ts and *.cy.tsx specs

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,
  },
});

The valid combinations are:

framework bundler values Notes

react

vite, webpack

vue

vite, webpack

Nuxt 3 and later projects use vue with vite (see below).

svelte

vite, webpack

Alpha support.

next

webpack

Uses Next.js-specific Webpack presets.

angular

webpack

Accepts an options.projectConfig override.

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 viteConfig is omitted, Cypress searches upward from the project root for a vite.config.ts, .js, .mjs, .cjs, .mts or .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 explicit viteConfig. The React example below has a plain vite.config.ts with the React plugin, and Cypress used it unchanged:

    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({ plugins: [react()] });
  • Webpack: when webpackConfig is omitted, Cypress searches upward for a webpack.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 }),
    },
  },
});

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 in viteConfig.

  • Aliases declared only under compilerOptions.paths in tsconfig.json are a type-checking feature that neither Vite nor Webpack reads. Add vite-tsconfig-paths to the Vite plugins, or tsconfig-paths-webpack-plugin under resolve.plugins for 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)),
          },
        },
      },
    },
  },
});

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,
      });
    },
  },
});

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

devServer

none (required)

Framework and bundler, or a function that starts a server.

indexHtmlFile

cypress/support/component-index.html

The page components are rendered into: global assets, fonts, external scripts and the root element.

supportFile

cypress/support/component.js (with .ts, .jsx or .tsx variants)

Compiled and loaded before each spec. false disables it.

specPattern

**/*.cy.{js,jsx,ts,tsx}

Which files are component specs. Files matching e2e.specPattern are excluded.

excludeSpecPattern

['/snapshots/*', '/image_snapshots/*']

Globs to ignore. **/node_modules/** is always excluded.

devServerPublicPathRoute

/__cypress/src

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 public/; overriding it incorrectly stops specs and assets loading.

justInTimeCompile

true

Compiles only the resources a spec needs, just before it runs (Webpack).

experimentalSingleTabRunMode

false

Runs all specs in one tab to speed up cypress run, at the cost of spec isolation.

slowTestThreshold

250

Milliseconds after which the default reporter marks a test as slow.

setupNodeEvents

none

Registers Node events (cy.task(), code coverage); see Debugging, Studio, Plugins & Reporters.

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}',
  },
});

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>

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

import { mount } from 'cypress/react'

Renders JSX with React DOM into the root element. Supports React 18 and 19.

Vue

import { mount } from 'cypress/vue'

Uses Vue Test Utils internally, but in the real browser instead of an emulated DOM.

Angular

import { mount } from 'cypress/angular'

Uses Angular TestBed internally, in the real browser.

Svelte

import { mount } from 'cypress/svelte'

Mounts a Svelte 5 component with props.

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

mount(jsx, options?, rerenderKey?) with options alias (alias for the mount), log (log into the Command Log, true by default), strict (render in React strict mode) and ReactDom (a custom ReactDOM implementation)

component and rerender(jsx)

Vue

mount(component, options?) where options are Vue Test Utils mounting options: props, slots, global (plugins, components, stubs, mocks, provide, directives), attrs, data and others

wrapper (the Vue Test Utils wrapper) and component (the instance)

Angular

mount(component or template string, config?) where config takes componentProperties, plus imports, declarations and providers as in a TestBed module

fixture (the ComponentFixture) and component (the instance)

Svelte

mount(Component, options?) with props, anchor, context, intro and log

component (the instance)

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: cy.mount(<Stepper count={100} />)

Pass a spy as the callback prop: <Stepper onChange={spy} />

Vue

props option: cy.mount(Stepper, { props: { count: 100 } })

Pass a spy as the on<Event> prop, for example onChange or 'onUpdate:modelValue'

Angular

componentProperties option, or an Angular template string with bindings

createOutputSpy('alias') as the output property, or spy on the emitter of the yielded component

Svelte

props option

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);

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"
  }
}

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 being input type="radio" elements. cy.getByRole() (new in Cypress 16.2) matches only native elements for the role by default, and for radio the native element is input. The first version of this spec, written without native: false, failed after the 4-second timeout with a message that explained exactly this: it listed the accessible roles it did find (a radiogroup named "Rating" and five radio elements) and said that elements with a role attribute were skipped unless { native: false } is passed. Components built on native button, input and a elements, such as the Expandable spec 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 assertion calledOnceWithExactly checks the call. The spy is created before cy.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 is wrapper.setProps().

  • The read-only test clicks the disabled button with force: true to 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.

Running it

The React project was run in both Electron and Chrome, and the 11 tests passed in each. The Vue and Svelte projects below were run the same way.

cd react && npx cypress run --component --browser chrome

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,
  },
});

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:

  • props is an option of cy.mount(), and event listeners are props too: onUpdate:modelValue is the listener for the update:modelValue event, which is why a spy is passed under that name.

  • slots takes a string (text or HTML) or a component, which makes the slot content part of the test.

  • The yielded wrapper is the Vue Test Utils wrapper, so setProps() and the rest of that API are available.

  • TypeScript needs a shim so that the editor and tsc accept .vue imports; real projects normally use vue-tsc. The shim used here declares the module as DefineComponent<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,
  },
});
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

export default { preprocess: vitePreprocess() };
<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
    },
  },
});

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' },
  },
});

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',
  },
});

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'],
          },
        },
      },
    },
  },
});

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);
});

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:

  1. cypress/support/component-index.html for everything that is a tag in the application’s head: stylesheets, font link tags, external scripts.

  2. cypress/support/component.js (or the .ts, .tsx variant) for everything that is imported in JavaScript: global CSS, a reset, UI-library setup, store and router creation, plugin installation, the cy.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'

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 link tag to the index page.

Everything renders in a default serif font

Fonts are not loaded. Add the font link tags to the index page. For local font files with Vite, put them in the public directory and reference them with root-relative paths (Cypress sets Vite’s base from devServerPublicPathRoute); with Webpack, import the font from a module, or add a static entry under devServer in the Webpack config. Check the browser’s network tab for a 404.

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 cy.mount() command.

Root-level styles live in App.vue or App.svelte

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 __next_css__DO_NOT_USE__ element in the index page head.

Angular: Sass @use cannot find a stylesheet

A custom projectConfig replaced the detected build options; repeat stylePreprocessorOptions.includePaths in buildOptions.

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 buildOptions.styles.

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.

  1. A definition file, conventionally definition.cjs, loaded by the setup wizard in Node.js. Its type must equal the package name on npm, and the name must follow cypress-ct-* or @organization/cypress-ct-* (for example cypress-ct-react-js or @cypress/cypress-ct-react). Cypress scans the project’s node_modules for packages with that naming convention and lists them, labelled "community", as framework options.

  2. A mount adapter, conventionally index.mjs, which exports a named function mount. It renders the component into the root element that Cypress prepared and registers cleanup for between tests.

  3. A package.json whose exports field sends node to 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.html with the file named by cypressConfig.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:success on devServerEvents when a build finishes, and recompile when dev-server:specs:changed reports 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);

Code coverage, selectors and other tooling

Component tests share the Cypress tooling with end-to-end tests:

  • Code coverage: the @cypress/code-coverage plugin works for component tests, but its support import must be added to the component support file (cypress/support/component.ts); importing it only in e2e.ts collects nothing during component runs. The task registration in setupNodeEvents is the same as for end-to-end tests. See Debugging, Studio, Plugins & Reporters.

  • Selectors: data-cy attributes are the convention in the official examples (cy.get('[data-cy=counter]')); the examples above use cy.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 the viewportWidth and viewportHeight options in the component block, 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

cy.mount is not a function, or a TypeScript error on cy.mount

The support file does not register the command, or the Chainable declaration is missing from the type-checked files.

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.

Failed to resolve import "@/..." or module not found

Aliases must be resolvable by the bundler Cypress uses; declare them in viteConfig or webpackConfig, and add vite-tsconfig-paths for tsconfig paths.

cy.getByRole('radio') times out although the DOM shows radio buttons

The elements have the role through an attribute. Pass { native: false } (Cypress 16.2).

A test that expects a render error passes without asserting anything

cy.mount() is asynchronous. Register cy.on('uncaught:exception') before mounting and assert that the handler was called.

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 "type": "module" or use an .mjs Vite config.

Angular tests fail to compile after the upgrade to Cypress 16

Angular must be 21 or newer, @angular-devkit/build-angular must be installed, and zone.js is not needed. Remove autoSpyOutputs and autoDetectChanges.

A Next.js page component receives undefined props

getServerSideProps and getStaticProps do not run in component tests; test pages end to end.

Specs pass in cypress open and not in cypress run, or the reverse

Check the browser (Electron versus Chrome) and that experimentalSingleTabRunMode is not hiding isolation problems.

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 cy.mount() or beforeEach.

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)

cy.getByRole() query, used by the examples on this page; role attributes need { native: false }.

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 cypress/angular-zoneless is merged into cypress/angular; zone.js is no longer required; @angular/platform-browser-dynamic is replaced by @angular/platform-browser; autoSpyOutputs and autoDetectChanges are removed. Next.js 14 is no longer supported (15.0.4 or later, or 16). The Electron browser is deprecated as a test browser. Node.js 22, 24 and 26 or later are required.

15.14.0

Vite 8 support added for component testing.

15.11.0

Manual bootstrap script injection through data-cy-bootstrap, a workaround for React server-side rendering hydration mismatches.

15.9.0

experimentalRunAllSpecs also works for component testing.

15.8.0

Angular 21 supported and zoneless testing introduced through angular-zoneless.

15.7.0

Next.js 16 supported, with Webpack.

15.0.0

Angular 20 supported (it still required zone.js and @angular-devkit/build-angular); Vite 7 supported; Vite 4 support removed; @cypress/vite-dev-server became ESM only; webpack-dev-server 4 is no longer supported.

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. cypress/react18 and cypress/angular-signals folded into cypress/react and cypress/angular. cypress open-ct and cypress run-ct removed in favour of --component. justInTimeCompile replaced the experimental option and is on by default.