Skip to content

Commit

Permalink
feat(component-testing): breaking: Add React rerender functionality (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
agg23 committed Apr 20, 2021
1 parent 5fb5b41 commit ee8b918
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 64 deletions.
2 changes: 2 additions & 0 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5471,6 +5471,8 @@ declare namespace Cypress {
interface LogConfig extends Timeoutable {
/** The JQuery element for the command. This will highlight the command in the main window when debugging */
$el: JQuery
/** The scope of the log entry. If child, will appear nested below parents, prefixed with '-' */
type: 'parent' | 'child'
/** Allows the name of the command to be overwritten */
name: string
/** Override *name* for display purposes only */
Expand Down
5 changes: 0 additions & 5 deletions npm/react/cypress/component/basic/re-render/README.md

This file was deleted.

Binary file not shown.
46 changes: 0 additions & 46 deletions npm/react/cypress/component/basic/re-render/spec.js

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// <reference types="cypress" />
import React from 'react'
import { mount } from '@cypress/react'

it('should properly handle swapping components', () => {
const Component1 = ({ input }) => {
return <div>{input}</div>
}

const Component2 = ({ differentProp }) => {
return <div style={{ backgroundColor: 'blue' }}>{differentProp}</div>
}

mount(<Component1 input="0" />).then(({ rerender }) => {
rerender(<Component2 differentProp="1" />).get('body').should('contain', '1').should('not.contain', '0')
})
})
67 changes: 67 additions & 0 deletions npm/react/cypress/component/basic/rerender/effects.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/// <reference types="cypress" />
import React, { useLayoutEffect, useEffect } from 'react'
import { mount } from '@cypress/react'

it('should not run unmount effect cleanup when rerendering', () => {
const layoutEffectCleanup = cy.stub()
const effectCleanup = cy.stub()

const Component = ({ input }) => {
useLayoutEffect(() => {
return layoutEffectCleanup
}, [input])

useEffect(() => {
return effectCleanup
}, [])

return <div>{input}</div>
}

mount(<Component input="0" />).then(({ rerender }) => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)

rerender(<Component input="0" />).then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)
})

rerender(<Component input="1" />).then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(1)
expect(effectCleanup).to.have.been.callCount(0)
})
})
})

it('should run unmount effect cleanup when unmounting', () => {
const layoutEffectCleanup = cy.stub()
const effectCleanup = cy.stub()

const Component = ({ input }) => {
useLayoutEffect(() => {
return layoutEffectCleanup
}, [])

useEffect(() => {
return effectCleanup
}, [])

return <div>{input}</div>
}

mount(<Component input="0" />).then(({ rerender, unmount }) => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)

rerender(<Component input="1" />).then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(0)
expect(effectCleanup).to.have.been.callCount(0)
})

unmount().then(() => {
expect(layoutEffectCleanup).to.have.been.callCount(1)
expect(effectCleanup).to.have.been.callCount(1)
})
})
})
13 changes: 13 additions & 0 deletions npm/react/cypress/component/basic/rerender/input-accumulator.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React, { useEffect, useState } from 'react'

export const InputAccumulator = ({ input }) => {
const [store, setStore] = useState([])

useEffect(() => {
setStore((prev) => [...prev, input])
}, [input])

return (<ul>
{store.map((v) => <li key={v}>{v}</li>)}
</ul>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// <reference types="cypress" />
import React from 'react'
import { mount } from '@cypress/react'
import { InputAccumulator } from './input-accumulator'

it('should rerender preserving input values', () => {
mount(<InputAccumulator input="initial" />).then(({ rerender }) => {
cy.get('li').eq(0).contains('initial')

rerender(<InputAccumulator input="Rerendered value" />)
cy.get('li:nth-child(1)').should('contain', 'initial')
cy.get('li:nth-child(2)').should('contain', 'Rerendered value')

rerender(<InputAccumulator input="Second rerendered value" />)

cy.get('li:nth-child(1)').should('contain', 'initial')
cy.get('li:nth-child(2)').should('contain', 'Rerendered value')
cy.get('li:nth-child(3)').should('contain', 'Second rerendered value')
})
})
62 changes: 49 additions & 13 deletions npm/react/src/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,22 @@ const injectStyles = (options: MountOptions) => {
* @example
```
import Hello from './hello.jsx'
import {mount} from '@cypress/react'
import { mount } from '@cypress/react'
it('works', () => {
mount(<Hello onClick={cy.stub()} />)
// use Cypress commands
cy.contains('Hello').click()
})
```
**/
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => _mount('mount', jsx, options)

/**
* @see `mount`
* @param type The type of mount executed
* @param rerenderKey If specified, use the provided key rather than generating a new one
*/
const _mount = (type: 'mount' | 'rerender', jsx: React.ReactNode, options: MountOptions = {}, rerenderKey?: string): globalThis.Cypress.Chainable<MountReturn> => {
// Get the display name property via the component constructor
// @ts-ignore FIXME
const componentName = getDisplayName(jsx.type, options.alias)
Expand All @@ -60,9 +67,9 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
)
}

const key =
const key = rerenderKey ??
// @ts-ignore provide unique key to the the wrapped component to make sure we are rerendering between tests
(Cypress?.mocha?.getRunner()?.test?.title || '') + Math.random()
(Cypress?.mocha?.getRunner()?.test?.title as string || '') + Math.random()
const props = {
key,
}
Expand All @@ -74,37 +81,48 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
)
// since we always surround the component with a fragment
// let's get back the original component
// @ts-ignore
const userComponent = reactComponent.props.children
const userComponent = (reactComponent.props as {
key: string
children: React.ReactNode
}).children

reactDomToUse.render(reactComponent, el)

if (options.log !== false) {
Cypress.log({
name: 'mount',
name: type,
type: 'parent',
message: [message],
$el: (el.children.item(0) as unknown) as JQuery<HTMLElement>,
consoleProps: () => {
return {
// @ts-ignore protect the use of jsx functional components use ReactNode
props: jsx.props,
description: 'Mounts React component',
description: type === 'mount' ? 'Mounts React component' : 'Rerenders mounted React component',
home: 'https://github.com/cypress-io/cypress',
}
},
}).snapshot('mounted').end()
}

return (
cy
.wrap(userComponent, { log: false })
// Separate alias and returned value. Alias returns the component only, and the thenable returns the additional functions
cy.wrap<React.ReactNode>(userComponent)
.as(displayName)
.then(() => {
return cy.wrap<MountReturn>({
component: userComponent,
rerender: (newComponent) => _mount('rerender', newComponent, options, key),
unmount,
}, { log: false })
})
// by waiting, we delaying test execution for the next tick of event loop
// and letting hooks and component lifecycle methods to execute mount
// https://github.com/bahmutov/cypress-react-unit-test/issues/200
.wait(0, { log: false })
)
})
// Bluebird types are terrible. I don't think the return type can be carried without this cast
}) as unknown as globalThis.Cypress.Chainable<MountReturn>
}

let initialInnerHtml = ''
Expand All @@ -129,7 +147,7 @@ Cypress.on('run:start', () => {
})
```
*/
export const unmount = (options = { log: true }) => {
export const unmount = (options = { log: true }): globalThis.Cypress.Chainable<JQuery<HTMLElement>> => {
return cy.then(() => {
const selector = `#${ROOT_ID}`

Expand Down Expand Up @@ -185,12 +203,13 @@ export const createMount = (defaultOptions: MountOptions) => {
}

/** @deprecated Should be removed in the next major version */
// TODO: Remove
export default mount

// I hope to get types and docs from functions imported from ./index one day
// but for now have to document methods in both places
// like this: import {mount} from './index'

// TODO: Clean up types
export interface ReactModule {
name: string
type: string
Expand Down Expand Up @@ -254,6 +273,23 @@ export interface MountReactComponentOptions {

export type MountOptions = Partial<StyleOptions & MountReactComponentOptions>

export interface MountReturn {
/**
* The component that was rendered.
*/
component: React.ReactNode
/**
* Rerenders the specified component with new props. This allows testing of components that store state (`setState`)
* or have asynchronous updates (`useEffect`, `useLayoutEffect`).
*/
rerender: (component: React.ReactNode) => globalThis.Cypress.Chainable<MountReturn>
/**
* Removes the mounted component.
* @see `unmount`
*/
unmount: () => globalThis.Cypress.Chainable<JQuery<HTMLElement>>
}

/**
* The `type` property from the transpiled JSX object.
* @example
Expand Down

4 comments on commit ee8b918

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on ee8b918 Apr 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the linux x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/7.2.0/circle-develop-ee8b918ea8ad9a4a4df501a541c9af8b8cd3c147/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on ee8b918 Apr 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppVeyor has built the win32 ia32 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/7.2.0/appveyor-develop-ee8b918ea8ad9a4a4df501a541c9af8b8cd3c147/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on ee8b918 Apr 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppVeyor has built the win32 x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/7.2.0/appveyor-develop-ee8b918ea8ad9a4a4df501a541c9af8b8cd3c147/cypress.tgz

@cypress-bot
Copy link
Contributor

@cypress-bot cypress-bot bot commented on ee8b918 Apr 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circle has built the darwin x64 version of the Test Runner.

Learn more about this pre-release platform-specific build at https://on.cypress.io/installing-cypress#Install-pre-release-version.

Run this command to install the pre-release locally:

npm install https://cdn.cypress.io/beta/npm/7.2.0/circle-develop-ee8b918ea8ad9a4a4df501a541c9af8b8cd3c147/cypress.tgz

Please sign in to comment.