How to run React E2E tests purely with hooks
Tested with React-Native and Firebase Test Lab
Every invention starts with a need. I’ve been working on a personal app for quiet a while now, and as part of the process I hand it out to few people, so they can test it (most of them were overseas). One of the major complaints that I got was that the map component didn’t load. On most devices it did, but in many others it didn’t.
This issue had to be addressed, obviously, if I wanted to take my app seriously. Virtual devices using Android emulator didn’t seem to reproduce the issue, so I had to get a hold on real devices. I made a list of devices that didn’t support the app component, of what I had encountered thus far, and I started to look for people around me with these devices. Few challenges arouse:
- It was HARD to find people around me with these devices.
- It was HARD to convince these people to give me their phones for a short while, for debugging purposes.
- It was HARD to split my time…
I’ve been roaming around the internet, looking for a solution. I’ve found few platforms that provide a way to interact with a collection of real devices using their API, and the one that stood out the most was Firebase Test Lab. It had a large collection of devices to interact with, and a free daily quota.
Perfect! I was really excited to start testing my app with Test Lab. Oh, there’s one thing though - it doesn’t really work with React Native :( what a pity.
One of the methods to use Test Lab is by recording a script that essentially guides a robot on how to use the app (known as Robo). The script can be recorded directly from Android Studio, and it relies heavily on the view XML to fetch elements and attributes. Because React-Native wraps everything with a JavaScript shell, it fails to work as intended (for the most part).
My Eureka Moment 💡
I realized that for my specific needs, all I had to do was to navigate to the map screen with a real back-end. It didn’t matter who navigated to the map, a person, a robot, or a script, I just wanted to reproduce the issue. Since my knowledge revolves mainly around JavaScript, I’ve built a solution purely with React hooks, one that could navigate the app and test a desired outcome.
Introducing Bobcat 😺😼
Bobcat is a library for testing navigation flows in React. Its API is heavily inspired by classic
testing frameworks like Mocha and Jest; it has a similar
describe()
/ it()
type of syntax. Let’s have a look at a simple example script:
import { useState } from 'react'
import { useBobcat, useDelayedEffect } from 'react-bobcat'
import MyButton from './components/MyButton'
import { useSignOut } from './services/auth'
export default () => {
const { scope, flow, trap, pass, assert } = useBobcat()
scope('MyApp', () => {
const signOut = useSignOut()
before(async () => {
await signOut()
})
flow('Clicking a button', () => {
// MyButton is a React component
trap(MyButton, ({ buttonRef, textRef }) => {
const [buttonClicked, setButtonClicked] = useState(false)
useDelayedEffect(
() => () => {
// buttonRef is referencing a native HTML button element
buttonRef.current.click()
setButtonClicked(true)
},
1000,
[true]
)
useDelayedEffect(
() => {
if (!buttonClicked) return
return () => {
assert(textRef.current.innerText, 'Clicked!')
pass() // Go to the next scope/flow
}
},
1000,
[buttonClicked]
)
})
})
scope('Another nested scope', () => {
flow('Another flow A', () => {})
flow('Another flow B', () => {})
})
})
scope('You can also define additional external scopes', () => {
flow('Etc', () => {})
})
}
Note the comments in the code snippet, it should make things more clear. I used the
useDelayedEffect
hook and not an ordinary useEffect
hook because I wanted to be able to visually
observe the component, otherwise it would mount and unmount so quickly I wouldn’t be able to see it.
buttonRef
and textRef
are props that are provided directly from MyButton
component, which can
vary depends on your component and your needs. This is how MyButton
should look like:
import React, { useCallback, useRef, useState } from 'react'
import { useBobcat } from 'bobcat'
const MyButton = () => {
const { useTrap } = useBobcat()
const buttonRef = useRef()
const textRef = useRef()
const [text, setText] = useState('')
const onClick = useCallback(() => {
setText('Clicked!')
}, [true])
useTrap(MyButton, {
buttonRef,
textRef
})
return (
<div>
<button ref={buttonRef} onClick={onClick}>
Click me
</button>
<span ref={textRef}>{text}</span>
</div>
)
}
export default MyButton
The useTrap
hook would redirect the script to the trap which is defined under the active flow, so
its behavior will change according to the test that you wrote.
You’ve probably noticed by now that I used the useBobcat
hook to retrieve the test utils. This
signifies that there should be a higher order BobcatProvider
somewhere at the root-level
component. Why at the root-level? Because the higher you provide it at the hierarchy, the more
control you should have over the app. Since essentially we want to test all the components in our
app, it should be defined AS HIGH AS POSSIBLE, like so:
import React from 'react'
import BobcatRunner from './BobcatRunner'
import Navigator from './Navigator'
const App = () => {
return (
<BobcatRunner>
<Navigator />
</BobcatRunner>
)
}
export default App
The BobcatRunner
is a component that calls the BobcatProvider
internally. It’s also responsible
for resetting the app whenever a flow is finished, so it can begin a session, with the new traps
defined underneath it. This is how it should look like:
import React, { useEffect, useMemo, useState } from 'react'
import { BobcatProvider, useAsyncEffect, useBobcat } from 'react-bobcat'
import useScopes from './scopes'
const DONE_ROUTE = '__DONE__'
const _BobcatRunner = ({ children }) => {
const { run } = useBobcat()
const [route, setRoute] = useState('')
useScopes()
const running = useMemo(
() =>
run({
onPass({ route, date, payload }) {
console.log(
[`[PASS] (${date.toISOString()}) ${route.join(' -> ')}`, payload && payload.message]
.filter(Boolean)
.join('\n')
)
},
onFail({ route, date, payload }) {
console.error(
[`[FAIL] (${date.toISOString()}) ${route.join(' -> ')}`, payload && payload.message]
.filter(Boolean)
.join('\n')
)
}
}),
[true]
)
useAsyncEffect(
function* () {
if (route === DONE_ROUTE) return
const { value, done } = yield running.next()
setRoute(done ? DONE_ROUTE : value)
},
[route]
)
if (!route) {
return null
}
return <React.Fragment key={route}>{children}</React.Fragment>
}
const BobcatRunner = props => {
return (
<BobcatProvider>
<_BobcatRunner {...props} />
</BobcatProvider>
)
}
export default BobcatRunner
For the most part this component should be pretty clear, but the thing I want to focus on is the
run()
function and how it’s used asynchronously. run()
is an
async-generator,
that is being yielded each time we resolve or reject a test flow. The yielded result is a unique
route that is generated based on the given descriptions in our test-suite, so one possible route
could be MyApp -> Clicking a button
. Since the route is unique, it can be used to re-render the
app and reset its state, thus the key
prop.
Here’s how an actual test run of my early-prototyped app looks like:
Reducing Bundle Size
Bobcat is built for development or testing purposes, so one shall ask — “if it’s built into the internals of my app, how can I avoid it in production?”.
Nicely said. Bobcat provides a mock-up module under react-bobcat/mock
. If used correctly with
Babel, we can redirect some import
statements into different, much more reduced in size dummy
functions. Here’s an example babel.config.js
(aka .babelrc
):
module.exports = {
plugins: [
[
'module-resolver',
{
alias: {
'react-bobcat': process.env.NODE_ENV === 'test' ? 'react-bobcat' : 'react-bobcat/mock',
'my-bobcat-runner':
process.env.NODE_ENV === 'test' ? './BobcatRunner' : './components/Fragment'
}
}
]
]
}
Installation
The source is available via GitHub. Alternatively you can install Bobcat via NPM:
npm install react-bobcat
or Yarn:
yarn add react-bobcat
Be sure to install React@16.8 or greater.
Call for Contributors
The app mentioned in this article is work in progress. It’s an amazing social project that uses the absolute latest dev-stack and has many cool libraries and modules like the one above. If you’re looking for a serious tech challenge, or looking to make a change in the social field, contact me at emanor6@gmail.com.
Join our newsletter
Want to hear from us when there's something new?
Sign up and stay up to date!
*By subscribing, you agree with Beehiiv’s Terms of Service and Privacy Policy.