React Native Web Component Testing Using Cypress And Vite

Bundle React Native app using Vite to run on the web and write Cypress component tests.

If you can bundle your web projects using Vite, then you can write Cypress component tests against it. For example, I have shown how to test components from ArrowJS framework when bundled using Vite. In this blog post, I will describe a ReactNative project running inside the browser via react-native-web. We will use Vite to bundle the project, thus we will be able to write Cypress component tests!

🎁 You can find the full source for this blog post in the repo bahmutov/cypress-react-native-vite-example.

The application

First, let's get the application running. Our app has two screens

src/App.tsx
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
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Info } from './Info'
import { Home } from './Home'
const { Navigator, Screen } = createNativeStackNavigator()

export const App = () => {
return (
<SafeAreaProvider>
<NavigationContainer>
<Root />
</NavigationContainer>
</SafeAreaProvider>
)
}

const Root = () => {
return (
<Navigator>
<Screen name={'Home'} component={Home} />
<Screen name={'Info'} component={Info} />
</Navigator>
)
}

The Home and Info components look like this:

src/Home.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Button, Text, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { center, text } from './styles'

export const Home = () => {
console.log('Home useNavigation', useNavigation)
const navigation = useNavigation<any>()
return (
<View style={center}>
<Text style={text}>Home</Text>
<Button
title="Navigate to Info"
onPress={() => navigation.navigate('Info')}
testID="ToInfo"
/>
</View>
)
}
src/Info.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Button, Text, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { center, text } from './styles'

export const Info = () => {
const navigation = useNavigation<any>()
return (
<View style={center}>
<Text style={text}>Info</Text>
<Button
title="Navigate to Home"
onPress={() => navigation.navigate('Home')}
testID="ToHome"
/>
</View>
)
}

In my projects, I use the following dependencies

package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"dependencies": {
"@react-navigation/native": "^6.1.3",
"@react-navigation/native-stack": "^6.6.2",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-native-safe-area-context": "^4.3.1",
"react-native-screens": "^3.19.0",
"react-native-web": "^0.18.12"
},
"devDependencies": {
"@originjs/vite-plugin-commonjs": "^1.0.3",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/react-native": "^0.69.1",
"@vitejs/plugin-react": "^1.3.0",
"typescript": "^4.6.3",
"vite": "^4.1.0"
}
}

Bundle the app for the browser

We can bundle it using Vite using the following settings (for the current Vite config, see vite.config.ts)

vite.config.ts
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
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { viteCommonjs, esbuildCommonjs } from '@originjs/vite-plugin-commonjs'

// https://vitejs.dev/config/
export default defineConfig({
define: {
global: 'window',
},
optimizeDeps: {
include: ['@react-navigation/native'],
esbuildOptions: {
mainFields: ['module', 'main'],
resolveExtensions: ['.web.js', '.js', '.ts'],
plugins: [esbuildCommonjs(['@react-navigation/elements'])],
},
},
resolve: {
extensions: ['.web.tsx', '.web.jsx', '.web.js', '.tsx', '.ts', '.js'],
alias: {
'react-native': 'react-native-web',
},
},
plugins: [viteCommonjs(), react()],
build: {
commonjsOptions: {
transformMixedEsModules: true,
},
},
})

Start the app using yarn vite and it should be running at localhost:5173

React Native app running in the browser

Just two screens: "Home" and "Info" and the user navigation.

Cypress end-to-end test

If we can run the project in the browser, then we can write Cypress end-to-end tests. Here is my simple test that goes from one screen to another.

cypress/e2e/navigation.cy.ts
1
2
3
4
5
6
7
8
it('navigates', () => {
cy.visit('/')
cy.contains('[role=heading]', 'Home').should('be.visible')
cy.get('[role=button][data-testid=ToInfo]').click()
cy.contains('[role=heading]', 'Info').should('be.visible')
cy.get('[role=button][data-testid=ToHome]').click()
cy.contains('[role=heading]', 'Home').should('be.visible')
})

Cypress end-to-end test

Note: you might ask me why I prefer those clunky attribute selectors like [role=heading] and [role=button][data-testid=ToInfo]. I love these standard CSS selectors because I can check them or use them right from the browser's DevTools elements panel.

Using the same standard CSS selector in DevTools to confirm it finds the right element

Component testing

I have added Component testing to Cypress and picked "React" with "Vite". After all - our project has both 😉

cypress.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { defineConfig } = require('cypress')

module.exports = defineConfig({
e2e: {
// baseUrl, etc
baseUrl: 'http://127.0.0.1:5173/',
supportFile: false,
fixturesFolder: false,
},

component: {
fixturesFolder: false,
devServer: {
framework: 'react',
bundler: 'vite',
},
},
})

After setup, we have a component support file that adds cy.mount command

cypress/support/component.js
1
2
3
import { mount } from 'cypress/react18'

Cypress.Commands.add('mount', mount)

The components will be mounted in a little HTML page, also created by the configuration:

cypress/support/component-index.html
1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Components App</title>
</head>
<body>
<div data-cy-root></div>
</body>
</html>

Tip: you can include "global" application styles in this HTML file to be applied to every component, just like they would inside the full application.

Here are my first tests, playing with parts of React Native stack.

src/Misc.cy.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { Button, Text, View } from 'react-native'
import { center, text } from './styles'

it('shows just text', () => {
cy.mount(<Text>Home</Text>)
})

it('shows view with text', () => {
cy.mount(
<View style={center}>
<Text>Home</Text>
</View>,
)
})

it('shows styles', () => {
cy.mount(
<View style={center}>
<Text style={text}>Home</Text>
</View>,
)
})

Our first React Native component tests

We can pass stubs to confirm the component calls it on click event.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
it('clicks the button', () => {
cy.mount(
<View style={center}>
<Text style={text}>Home</Text>
<Button
title="Navigate to Info"
onPress={cy.stub().as('press')}
testID="ToHome"
/>
</View>,
)
cy.contains('Navigate to Info').click()
cy.get('@press').should('have.been.calledOnce')
})

Confirm the prop is called on click

Let's see how our Info component looks

src/Info.cy.tsx
1
2
3
4
5
6
7
8
9
10
11
import { NavigationContainer } from '@react-navigation/native'
import { Info } from './Info'

it('shows Info', () => {
cy.mount(
<NavigationContainer>
<Info />
</NavigationContainer>,
)
cy.contains('Navigate to Home')
})

Mounted Info component

Testing the navigation

Mounting individual tiny components is no fun. Let's see how they navigate instead.

src/Navigation.cy.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { Info } from './Info'
import { Home } from './Home'
const { Navigator, Screen } = createNativeStackNavigator()

it('navigates screen to screen', () => {
cy.mount(
<NavigationContainer>
<Navigator>
<Screen name={'Home'} component={Home} />
<Screen name={'Info'} component={Info} />
</Navigator>
</NavigationContainer>,
)
cy.contains('[role=button]', 'Navigate to Info').click()
cy.contains('[role=button]', 'Navigate to Home').click()
cy.contains('[role=button]', 'Navigate to Info').should('be.visible')
})

Navigation component test

The navigation test goes from one screen to another and back. Looks a lot like an end-to-end test, doesn't it? Yup - once mounted, the React Native (web) component runs like a mini web application. No shallow rendering - all children components should work and be tested by the test runner interactions.

Continuous integration

On every commit, we will run all tests (end-to-end and component) using GitHub Actions. Here is my workflow file using bahmutov/cypress-workflows reusable workflows. For the latest workflow files, see .github/workflows.

.github/workflows/ci.yml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name: ci
on: [push]
jobs:
e2e:
# use the reusable workflow to check out the code, install dependencies
# and run the Cypress tests
# https://github.com/bahmutov/cypress-workflows
uses: bahmutov/cypress-workflows/.github/workflows/standard.yml@v1
with:
start: npm run dev

component:
uses: bahmutov/cypress-workflows/.github/workflows/standard.yml@v1
with:
component: true

All tests finished successfully

One thing in favor of component tests: they are fast compared to end-to-end tests.

What about Expo.io?

Unfortunately, I could not figure out how use Expo own bundler with Cypress component testing 😢 I wish it were possible.

See also