How to run React E2E tests purely with hooks
Tags: react
Published: 2021-07-29 10:00:04

> This article was published on Monday, February 10, 2020 by [Eytan Manor](https://twitter.com/eytan_manor) @ [The Guild Blog](https://the-guild.dev/)

## 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](https://firebase.google.com/docs/test-lab/?gclid=CjwKCAiA1fnxBRBBEiwAVUouUvVyHY0_v_JuSIUIV8q_29Q_1oNF2qlqY3L_6U172P4BbnkL4J90-RoC2YgQAvD_BwE).
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](https://firebase.google.com/docs/test-lab/android/robo-ux-test)).
The script can be recorded directly from [Android Studio](https://developer.android.com/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](https://mochajs.org) and [Jest](https://jestjs.io); it has a similar
`describe()` / `it()` type of syntax. Let's have a look at a simple example script:

```javascript
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:

```javascript
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:

```javascript
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:

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for-await...of),
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.

![](https://the-guild.dev/medium/a95ef5c7a9025fdca2be4b9bc63903ee.png "Rendering phase at a glance")

Here's how an actual test run of my early-prototyped app looks like:

https://youtu.be/sFM6iibYT-0

## 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`):

```javascript
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](https://github.com/DAB0mB/react-bobcat). Alternatively you can
install Bobcat via NPM:

```sh
npm install react-bobcat
```

or Yarn:

```sh
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](mailto:emanor6@gmail.com).