DEV Community

Cover image for How to run React E2E tests purely with hooks
TheGuildBot for The Guild

Posted on • Updated on • Originally published at the-guild.dev

How to run React E2E tests purely with hooks

This article was published on Monday, February 10, 2020 by Eytan Manor @ The Guild Blog

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', () => {})
  })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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

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'
        }
      }
    ]
  ]
}
Enter fullscreen mode Exit fullscreen mode

Installation

The source is available via GitHub. Alternatively you can
install Bobcat via NPM:

npm install react-bobcat
Enter fullscreen mode Exit fullscreen mode

or Yarn:

yarn add react-bobcat
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)