Skip to content

Commit ee8b918

Browse files
authored
feat(component-testing): breaking: Add React rerender functionality (#16038)
1 parent 5fb5b41 commit ee8b918

File tree

9 files changed

+168
-64
lines changed

9 files changed

+168
-64
lines changed

cli/types/cypress.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -5471,6 +5471,8 @@ declare namespace Cypress {
54715471
interface LogConfig extends Timeoutable {
54725472
/** The JQuery element for the command. This will highlight the command in the main window when debugging */
54735473
$el: JQuery
5474+
/** The scope of the log entry. If child, will appear nested below parents, prefixed with '-' */
5475+
type: 'parent' | 'child'
54745476
/** Allows the name of the command to be overwritten */
54755477
name: string
54765478
/** Override *name* for display purposes only */

npm/react/cypress/component/basic/re-render/README.md

-5
This file was deleted.
Binary file not shown.

npm/react/cypress/component/basic/re-render/spec.js

-46
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/// <reference types="cypress" />
2+
import React from 'react'
3+
import { mount } from '@cypress/react'
4+
5+
it('should properly handle swapping components', () => {
6+
const Component1 = ({ input }) => {
7+
return <div>{input}</div>
8+
}
9+
10+
const Component2 = ({ differentProp }) => {
11+
return <div style={{ backgroundColor: 'blue' }}>{differentProp}</div>
12+
}
13+
14+
mount(<Component1 input="0" />).then(({ rerender }) => {
15+
rerender(<Component2 differentProp="1" />).get('body').should('contain', '1').should('not.contain', '0')
16+
})
17+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/// <reference types="cypress" />
2+
import React, { useLayoutEffect, useEffect } from 'react'
3+
import { mount } from '@cypress/react'
4+
5+
it('should not run unmount effect cleanup when rerendering', () => {
6+
const layoutEffectCleanup = cy.stub()
7+
const effectCleanup = cy.stub()
8+
9+
const Component = ({ input }) => {
10+
useLayoutEffect(() => {
11+
return layoutEffectCleanup
12+
}, [input])
13+
14+
useEffect(() => {
15+
return effectCleanup
16+
}, [])
17+
18+
return <div>{input}</div>
19+
}
20+
21+
mount(<Component input="0" />).then(({ rerender }) => {
22+
expect(layoutEffectCleanup).to.have.been.callCount(0)
23+
expect(effectCleanup).to.have.been.callCount(0)
24+
25+
rerender(<Component input="0" />).then(() => {
26+
expect(layoutEffectCleanup).to.have.been.callCount(0)
27+
expect(effectCleanup).to.have.been.callCount(0)
28+
})
29+
30+
rerender(<Component input="1" />).then(() => {
31+
expect(layoutEffectCleanup).to.have.been.callCount(1)
32+
expect(effectCleanup).to.have.been.callCount(0)
33+
})
34+
})
35+
})
36+
37+
it('should run unmount effect cleanup when unmounting', () => {
38+
const layoutEffectCleanup = cy.stub()
39+
const effectCleanup = cy.stub()
40+
41+
const Component = ({ input }) => {
42+
useLayoutEffect(() => {
43+
return layoutEffectCleanup
44+
}, [])
45+
46+
useEffect(() => {
47+
return effectCleanup
48+
}, [])
49+
50+
return <div>{input}</div>
51+
}
52+
53+
mount(<Component input="0" />).then(({ rerender, unmount }) => {
54+
expect(layoutEffectCleanup).to.have.been.callCount(0)
55+
expect(effectCleanup).to.have.been.callCount(0)
56+
57+
rerender(<Component input="1" />).then(() => {
58+
expect(layoutEffectCleanup).to.have.been.callCount(0)
59+
expect(effectCleanup).to.have.been.callCount(0)
60+
})
61+
62+
unmount().then(() => {
63+
expect(layoutEffectCleanup).to.have.been.callCount(1)
64+
expect(effectCleanup).to.have.been.callCount(1)
65+
})
66+
})
67+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import React, { useEffect, useState } from 'react'
2+
3+
export const InputAccumulator = ({ input }) => {
4+
const [store, setStore] = useState([])
5+
6+
useEffect(() => {
7+
setStore((prev) => [...prev, input])
8+
}, [input])
9+
10+
return (<ul>
11+
{store.map((v) => <li key={v}>{v}</li>)}
12+
</ul>)
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/// <reference types="cypress" />
2+
import React from 'react'
3+
import { mount } from '@cypress/react'
4+
import { InputAccumulator } from './input-accumulator'
5+
6+
it('should rerender preserving input values', () => {
7+
mount(<InputAccumulator input="initial" />).then(({ rerender }) => {
8+
cy.get('li').eq(0).contains('initial')
9+
10+
rerender(<InputAccumulator input="Rerendered value" />)
11+
cy.get('li:nth-child(1)').should('contain', 'initial')
12+
cy.get('li:nth-child(2)').should('contain', 'Rerendered value')
13+
14+
rerender(<InputAccumulator input="Second rerendered value" />)
15+
16+
cy.get('li:nth-child(1)').should('contain', 'initial')
17+
cy.get('li:nth-child(2)').should('contain', 'Rerendered value')
18+
cy.get('li:nth-child(3)').should('contain', 'Second rerendered value')
19+
})
20+
})

npm/react/src/mount.ts

+49-13
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,22 @@ const injectStyles = (options: MountOptions) => {
2828
* @example
2929
```
3030
import Hello from './hello.jsx'
31-
import {mount} from '@cypress/react'
31+
import { mount } from '@cypress/react'
3232
it('works', () => {
3333
mount(<Hello onClick={cy.stub()} />)
3434
// use Cypress commands
3535
cy.contains('Hello').click()
3636
})
3737
```
3838
**/
39-
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
39+
export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => _mount('mount', jsx, options)
40+
41+
/**
42+
* @see `mount`
43+
* @param type The type of mount executed
44+
* @param rerenderKey If specified, use the provided key rather than generating a new one
45+
*/
46+
const _mount = (type: 'mount' | 'rerender', jsx: React.ReactNode, options: MountOptions = {}, rerenderKey?: string): globalThis.Cypress.Chainable<MountReturn> => {
4047
// Get the display name property via the component constructor
4148
// @ts-ignore FIXME
4249
const componentName = getDisplayName(jsx.type, options.alias)
@@ -60,9 +67,9 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
6067
)
6168
}
6269

63-
const key =
70+
const key = rerenderKey ??
6471
// @ts-ignore provide unique key to the the wrapped component to make sure we are rerendering between tests
65-
(Cypress?.mocha?.getRunner()?.test?.title || '') + Math.random()
72+
(Cypress?.mocha?.getRunner()?.test?.title as string || '') + Math.random()
6673
const props = {
6774
key,
6875
}
@@ -74,37 +81,48 @@ export const mount = (jsx: React.ReactNode, options: MountOptions = {}) => {
7481
)
7582
// since we always surround the component with a fragment
7683
// let's get back the original component
77-
// @ts-ignore
78-
const userComponent = reactComponent.props.children
84+
const userComponent = (reactComponent.props as {
85+
key: string
86+
children: React.ReactNode
87+
}).children
7988

8089
reactDomToUse.render(reactComponent, el)
8190

8291
if (options.log !== false) {
8392
Cypress.log({
84-
name: 'mount',
93+
name: type,
94+
type: 'parent',
8595
message: [message],
8696
$el: (el.children.item(0) as unknown) as JQuery<HTMLElement>,
8797
consoleProps: () => {
8898
return {
8999
// @ts-ignore protect the use of jsx functional components use ReactNode
90100
props: jsx.props,
91-
description: 'Mounts React component',
101+
description: type === 'mount' ? 'Mounts React component' : 'Rerenders mounted React component',
92102
home: 'https://github.com/cypress-io/cypress',
93103
}
94104
},
95105
}).snapshot('mounted').end()
96106
}
97107

98108
return (
99-
cy
100-
.wrap(userComponent, { log: false })
109+
// Separate alias and returned value. Alias returns the component only, and the thenable returns the additional functions
110+
cy.wrap<React.ReactNode>(userComponent)
101111
.as(displayName)
112+
.then(() => {
113+
return cy.wrap<MountReturn>({
114+
component: userComponent,
115+
rerender: (newComponent) => _mount('rerender', newComponent, options, key),
116+
unmount,
117+
}, { log: false })
118+
})
102119
// by waiting, we delaying test execution for the next tick of event loop
103120
// and letting hooks and component lifecycle methods to execute mount
104121
// https://github.com/bahmutov/cypress-react-unit-test/issues/200
105122
.wait(0, { log: false })
106123
)
107-
})
124+
// Bluebird types are terrible. I don't think the return type can be carried without this cast
125+
}) as unknown as globalThis.Cypress.Chainable<MountReturn>
108126
}
109127

110128
let initialInnerHtml = ''
@@ -129,7 +147,7 @@ Cypress.on('run:start', () => {
129147
})
130148
```
131149
*/
132-
export const unmount = (options = { log: true }) => {
150+
export const unmount = (options = { log: true }): globalThis.Cypress.Chainable<JQuery<HTMLElement>> => {
133151
return cy.then(() => {
134152
const selector = `#${ROOT_ID}`
135153

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

187205
/** @deprecated Should be removed in the next major version */
206+
// TODO: Remove
188207
export default mount
189208

190209
// I hope to get types and docs from functions imported from ./index one day
191210
// but for now have to document methods in both places
192211
// like this: import {mount} from './index'
193-
212+
// TODO: Clean up types
194213
export interface ReactModule {
195214
name: string
196215
type: string
@@ -254,6 +273,23 @@ export interface MountReactComponentOptions {
254273

255274
export type MountOptions = Partial<StyleOptions & MountReactComponentOptions>
256275

276+
export interface MountReturn {
277+
/**
278+
* The component that was rendered.
279+
*/
280+
component: React.ReactNode
281+
/**
282+
* Rerenders the specified component with new props. This allows testing of components that store state (`setState`)
283+
* or have asynchronous updates (`useEffect`, `useLayoutEffect`).
284+
*/
285+
rerender: (component: React.ReactNode) => globalThis.Cypress.Chainable<MountReturn>
286+
/**
287+
* Removes the mounted component.
288+
* @see `unmount`
289+
*/
290+
unmount: () => globalThis.Cypress.Chainable<JQuery<HTMLElement>>
291+
}
292+
257293
/**
258294
* The `type` property from the transpiled JSX object.
259295
* @example

0 commit comments

Comments
 (0)