How to run React E2E tests purely with hooks

Eytan Manor
How to run React E2E tests purely with hooks - 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 { useDelayedEffect, useBobcat } 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, { useState, useMemo, useEffect } from 'react';
import { useAsyncEffect, useBobcat, BobcatProvider } 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 wanna 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',
        },
      },
    ],
  ],
};

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.