Split React Native Web Component Tests For Free

Execute RN component tests in parallel using cypress-split plugin.

Let's say you are using react-native-web to see your RN components in the browser. In my example, we are bundling the RN code using Next.js + Webpack and we have lots of components. We can write Cypress component tests and develop parts of our application in isolation with very quick feedback loop. This blog post shows how to run all these component tests in parallel for free using my plugin cypress-split.

🎁 You can find the source code shown in this blog post in the repo bahmutov/rn-examples. Check out the Actions tab to see the test runs.

The Switch component test

Let's confirm the RN Switch component is working as expected. We have an example page with bunch of Switch components, one of them flipping back and forth.

pages/switch/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React from 'react'
// https://reactnative.dev/docs/switch
import { StyleSheet, Switch, View } from 'react-native'
import Example from '../../shared/example'

function Divider() {
return <View style={styles.divider} />
}

export default function SwitchPage() {
const [checked, setChecked] = React.useState(true)

React.useEffect(() => {
const interval = setInterval(() => {
setChecked(!checked)
}, 2500)
return () => {
clearInterval(interval)
}
}, [checked])

return (
<Example title="Switch">
...
<View style={styles.row}>
<Switch
style={{ height: 32, width: 32 }}
thumbColor="#1DA1F2"
value={checked}
testID="Auto"
/>
</View>
</Example>
)
}

The Switch component creates several DIV elements on the page, and a hidden input type=checkbox. We can confirm the checkbox gets toggled.

pages/switch/switch.cy.js
1
2
3
4
5
6
7
8
import SwitchExample from '.'
it('switches', () => {
cy.mount(<SwitchExample />)
const selector = '[data-testid=Auto] :checkbox'
cy.get(selector).should('be.checked')
cy.get(selector).should('not.be.checked')
cy.get(selector).should('be.checked')
})

The component test imports the Switch example component, mounts it, and confirms the input type=checkbox changes the value. Let's open Cypress in component testing mode

1
$ npx cypress open --component --browser electron

The test shows lots of Switch components from the example page, we are observing the bottom one.

Beautiful.

Spy on the set value call

Let's write another test to confirm the Switch calls onValueChange prop. We can create an example component directly in the component spec file. The component calls an internal function to set the new state value:

pages/switch/value-change.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const TestSwitch = () => {
const [isEnabled, setIsEnabled] = useState(false)
const toggleSwitch = () => {
return setIsEnabled(!isEnabled)
}

return (
<View style={styles.container}>
<Switch
trackColor={{ false: '#767577', true: '#81b0ff' }}
thumbColor={isEnabled ? '#f5dd4b' : '#f4f3f4'}
ios_backgroundColor="#3e3e3e"
onValueChange={toggleSwitch}
value={isEnabled}
testID="switch"
/>
</View>
)
}

We want to spy on setIsEnabled calls. We can create a cy.stub Sinon.js function that returns whatever is passed to it and insert it into setIsEnabled(!isEnabled) call.

pages/switch/value-change.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React, { useState } from 'react'
import { View, Switch, StyleSheet } from 'react-native'

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
margin: '1rem',
},
})

it('calls the onValueChange callback', () => {
// create a stub function that returns the first argument
const enabledSpy = cy.stub().returnsArg(0).as('enabledSpy')
const TestSwitch = () => {
const [isEnabled, setIsEnabled] = useState(false)
const toggleSwitch = () => {
// when "setIsEnabled" is called, call the stub too
return setIsEnabled(enabledSpy(!isEnabled))
}

return (
<View style={styles.container}>
<Switch
trackColor={{ false: '#767577', true: '#81b0ff' }}
thumbColor={isEnabled ? '#f5dd4b' : '#f4f3f4'}
ios_backgroundColor="#3e3e3e"
onValueChange={toggleSwitch}
value={isEnabled}
testID="switch"
/>
</View>
)
}

cy.mount(<TestSwitch />)
// our switch has "input type=checkbox" inside with the actual value
const switchSelector = '[data-testid=switch] :checkbox'
// confirm the first values and the interaction
// that calls the function stub
cy.get(switchSelector).should('not.be.checked').click()
cy.get('@enabledSpy')
.should('have.been.calledWith', true)
.invoke('resetHistory')
cy.get(switchSelector).should('be.checked').click()
cy.get('@enabledSpy').should('have.been.calledWith', false)
})

The stub (or spy in this case), is called with expected values. First with true when we flip the switch to "on", then with false when we flip it back.

The test switch test with spy function

Component tests

Working on RN components while writing tests is super quick. We can cover a lot of components and go deeper into individual ones to make sure they work. At some point, we want to know what tests we have written. I have installed find-cypress-specs to list all E2E and component tests in the repository.

package.json
1
2
3
4
5
6
7
8
{
"scripts": {
"test-names": "find-cypress-specs --component --names"
},
"devDependencies": {
"find-cypress-specs": "^1.28.0"
}
}

Call test-names script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ npm run test-names

> [email protected] test-names
> find-cypress-specs --component --names

pages/examples.cy.js (2 tests, 1 pending)
├─ shows all examples
└⊙ navigates

shared/example.cy.js (1 test)
└─ shows example

pages/button/button.cy.js (1 test)
└─ shows buttons

pages/switch/switch.cy.js (1 test)
└─ switches

pages/switch/value-change.cy.js (1 test)
└─ calls the onValueChange callback

found 5 specs (6 tests, 1 pending)

Tip: use NPM flag --silent to remove the first two lines from the output and only print the test names

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ npm run test-names --silent
pages/examples.cy.js (2 tests, 1 pending)
├─ shows all examples
└⊙ navigates

shared/example.cy.js (1 test)
└─ shows example

pages/button/button.cy.js (1 test)
└─ shows buttons

pages/switch/switch.cy.js (1 test)
└─ switches

pages/switch/value-change.cy.js (1 test)
└─ calls the onValueChange callback

found 5 specs (6 tests, 1 pending)

Run component tests in parallel

If our component tests are realistic, they might take longer time to finish. An easy optimization is to run the Cypress tests in parallel using my plugin cypress-split. It supports component testing via --component flag.

Install the cypress-split plugin as a dev dependency

1
2
$ npm i -D cypress-split
+ [email protected]

Add it to the cypress.config.js file

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const { defineConfig } = require('cypress')
// https://github.com/bahmutov/cypress-split
const cypressSplit = require('cypress-split')

module.exports = defineConfig({
component: {
devServer: {
framework: 'next',
bundler: 'webpack',
},
setupNodeEvents(on, config) {
cypressSplit(on, config)
// IMPORTANT: return the config object
return config
},
},
})

We are ready to split. While cypress-split supports any CI, we can use the split GitHub Actions workflow from my bahmutov/cypress-workflows reusable GH workflows to let it configure everything.

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
name: ci
on: push
jobs:
component-tests:
# https://github.com/bahmutov/cypress-workflows
uses: bahmutov/cypress-workflows/.github/workflows/split.yml@v1
with:
# print the test names
before-run: 'npm run test-names --silent'
component: true
n: 2

This is the entire workflow file. It installs all NPM dependencies, caches them, including Cypress binary, then runs the component tests splitting the tests across 2 machines. If you have more tests ... increment the n parameter.

The workflow run with 2 machines executing component tests

The split jobs each output their spec and test summary

Cypress split GitHub Actions summary

See also