Upcoming Changes to Component Testing

November 4, 2022

By The Cypress Team

Cypress 7 introduced the alpha version of a new testing type – the first since Cypress’ inception – Component Testing. Cypress 10 brought a number of updates and improvements, marking the beta version of Component Testing.

Early next week, we will be releasing Cypress 11. With this release, we are excited to announce that we are removing the beta tag. Cypress Component Testing has a stable API, and is now considered generally available.

As part of Cypress 11, we are making some small changes to the mount API, the main way you render components. These changes lay the foundation for our officially supported libraries (React, Vue, Angular, and Next.js), as well as for third party integrations and future official adapters. This will let you use Cypress with the exact same API across any component library or framework, without needing to learn any new syntax.

In this post I will outline the changes we are making, why we are making them, and how you can migrate to Cypress 11 when it is released. Migrating should be straightforward for the majority of code bases.

Changes to Mounting Options

Cypress Mount Adapters take two parameters. The first is your component. The second is the Mounting Options. Each framework has some specific mounting options – for example, Svelte Mounting Options contain a props property, and Vue’s contain a data property. Neither of these is necessary for React – idiomatically, you pass props using JSX, so a props property isn’t necessary.

Some frameworks Mounting Options also supported some additional properties which we call Style Options. Those are:

  • cssFile, cssFiles
  • style, styles
  • stylesheet, stylesheets

We’ve decided to remove these options with the goal of providing a consistent experience across all frameworks, and also minimizing the number of ways to do the same thing.

We recommend writing test specific styles in a separate file, which can then be imported into either:

  • Your supportFile (for styles shared by many tests)
  • Your spec file (for styles only used in one or two tests)

This has an additional benefit: by writing styles in a separate file, you can use the same preprocessor pipeline as your components. Styling options were injected as-is, without any transformations applied, which sometimes led to differences in test code vs production code.

An example migration looks like this:

Before (Cypress 10)

/** Card.cy.jsx */
import { mount } from ‘cypress/react’
import { Card } from ‘./Card’

it(‘renders some content’, () => {
	cy.mount(<Card title=”title” />, {
    	styles: `
        	.card { width: 100px; }
        `,
        stylesheets: [‘https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css’]
    })
})

After (Cypress 11)

/** style.css */
@import "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css";

/** Card.cy.jsx */
import { mount } from ‘cypress/react’
import { Card } from ‘./Card’
import ‘./styles.css’ // contains CDN link and custom styling.

it(‘renders some content’, () => {
	cy.mount(<Card title=”title” />)
})

We recommend importing global styles into your supportFile, making your test even more concise and consistent.

React - mountHook Removed

During the alpha and beta, Cypress exposed a mountHook function for testing React hooks. We found in practice, it’s generally preferable to test hooks like a user would – within a real component! Consider the useCounter hook:

import { useState, useCallback } from 'react'

function useCounter () {
    const [count, setCount] = useState(0)
 	const increment = useCallback(() => setCount((x) => x + 1), [])

    return { count, increment }
}

Previously, using mountHook, a test might be written as follows:

import { mountHook } from 'cypress/react'
import { useCounter } from ‘./useCounter’

it('increments the count', () => {
  mountHook(() => useCounter()).then((result) => {
    expect(result.current.count).to.equal(0)
    result.current.increment()
    expect(result.current.count).to.equal(1)
    result.current.increment()
    expect(result.current.count).to.equal(2)
  })
})

It’s trivial to test this with a real component.

import { mountHook } from "cypress/react";
import { useCounter } from "./useCounter";

it("increments the count", () => {
  mountHook(() => useCounter()).then((result) => {
    expect(result.current.count).to.equal(0);
    result.current.increment();
    expect(result.current.count).to.equal(1);
    result.current.increment();
    expect(result.current.count).to.equal(2);
  });
});

In addition to more thoroughly exercising the hook in a fashion that more closely resembles production usage, by using `mount` the test also acts like documentation, illustrating how to use the hook, rather than just asserting it does something.

React - unmount Removed

In the alpha and beta, we exposed an unmount function. This became less necessary over time – Cypress handles unmounting and cleaning up after each test for you, so it’s rare you need to manually unmount a component.

React provides an idiomatic way of unmounting components (it changed slightly with React 18 – this example shows how to migrate React 16 and React 17 tests, but the same concept carries over to React 18).

Before (Cypress 10)

import { unmount } from "cypress/react";

it("calls the prop", () => {
  cy.mount(<Comp onUnmount={cy.stub().as("onUnmount")} />);
  cy.contains("My component");

  unmount();

  // the component is gone from the DOM
  cy.contains("My component").should("not.exist");
  cy.get("@onUnmount").should("have.been.calledOnce");
});

After (Cypress 11)

import { getContainerEl } from "cypress/react";
import ReactDom from "react-dom";

it("calls the prop", () => {
  cy.mount(<Comp onUnmount={cy.stub().as("onUnmount")} />);
  cy.contains("My component");

  cy.then(() => ReactDom.unmountComponentAtNode(getContainerEl()));

  // the component is gone from the DOM
  cy.contains("My component").should("not.exist");
  cy.get("@onUnmount").should("have.been.calledOnce");
});

getContainerEl will always refer to the root node at which Cypress mounts the component under test. It’s defined in the @cypress/mount-utils library, which can be used to build adapters for libraries we don’t officially support, and also exported from cypress/react for convenience, which comes with Cypress when you install it from npm.

You could also author a custom command:

Cypress.Commands.add(‘unmount’, () => {
  return cy.then(() => ReactDom.unmountComponentAtNode(getContainerEl()))
})

In practice, we’ve found this is only useful when testing component teardown behavior, and recommend relying on Cypress to unmount and clean up after your tests where possible.

Vue - mountCallback Removed

During the alpha and beta, Cypress exposed a mountCallback function for the Vue mounting adapter. It was a slightly more concise version of mount. In practice, we found it rarely useful and it often led to confusion (when to use mount vs. mountCallback?). In addition, no parallel existed for our React, Svelte, or Angular mounting adapters.

To keep our API consistent and the surface area small, we’ve removed mountCallback. Migration is trivial:

Before (Cypress 10)

import { mountCallback } from ‘cypress/vue’

beforeEach(mountCallback(MessageList))

it('shows no messages', () => {
  getItems().should('not.exist')
})

After (Cypress 11)

beforeEach(() => cy.mount(MessageList))

it('shows no messages', () => {
  getItems().should('not.exist')
})

Angular - Providers Priority

There is one breaking change for angular users in regards to providers. In v10 we took any providers passed as part of the MountConfig and overrode the component providers via the TestBed.overrideComponent API.

In v11, providers passed as part of the MountConfig will be assigned at the module level using the TestBed.configureTestingModule API. This means that module-level providers (resolved from imports or @Injectable({providedIn: ‘root’ }) can be overridden but providers specified in @Component({ providers: [...] }) will not be overridden when using cy.mount(MyComponent, { providers: [...] }). To override component-level providers, use the TestBed.overrideComponent API.

To illustrate the change take the example below:

import { Component, Injectable } from '@angular/core';

@Injectable({})
export class ProviderA { message = 'Provider A: From Injectable' }

@Component({
  template: `<p>{{ providerA.message }}</p>`,
  providers: [
    {
      provide: ProviderA,
      useValue: { message: 'ProviderA: From Component Providers' },
    },
  ],
})
export class MyComponent {
  constructor(private providerA: ProviderA) {}
}

Before (Cypress 10)

it('should override provider', () => {
  cy.mount(MyComponent, {
    providers: [
      {
        provide: ProviderA,
        useValue: { message: 'ProviderA: From cy.mount Providers' },
      },
    ],
  });

  cy.contains('ProviderA: From cy.mount Providers');
});

After (Cypress 11)

it('should override provider', () => {
  TestBed.overrideComponent(MyComponent, {
    add: {
      providers: [
        {
          provide: ProviderA,
          useValue: { message: 'ProviderA: From cy.mount Providers' },
        },
      ],
    },
  });
 
  cy.mount(MyComponent);
    
  cy.contains('ProviderA: From cy.mount Providers');
});

Vite Dev Server

There is one minor change to our @cypress/vite-dev-server package. It is possible to provide an inline dev server configuration like this:

import { defineConfig } from "cypress";

export default defineConfig({
  component: {
    devServer: {
      framework: "react",
      bundler: "vite",
      // optionally pass in vite config
      viteConfig: {
        // ... custom vite config ...
      },
    },
  },
});

Previously, this would be merged with any vite.config file. This sometimes led to confusing behavior, since there are multiple places to define the configuration. In Cypress 11, if you provide an inline viteConfig in your configuration file, your vite.config will not be merged automatically. The inline viteConfig will be considered the source of truth.

If you want to merge the two configurations, you can simply import and merge them manually.

import { defineConfig } from "cypress";
import viteConfig from "./vite.config";

export default defineConfig({
  component: {
    devServer: {
      framework: "react",
      bundler: "vite",
      // optionally pass in vite config
      viteConfig: {
        ...viteConfig,
        // ... overrides ...
      },
    },
  },
});

If you are using Vite 3, you could also use the `mergeConfig` method.

Conclusion

These are all of the breaking changes coming up in Cypress 11. The majority of test suites should be unaffected, and migration should be straightforward for those that are.

Stay tuned on our blog or follow us on Twitter for more updates about the release, plus Component Testing resources, webcasts, and events!