Testing React Native Components with Mocha

Testing React Native Components with Mocha

Testing for React Native components is broken

This week, I generated a react-native app with react-native v2.0.1. It went something like this:

The resulting Jest-based testing setup does not function.

TL;DR

You can get a nice Mocha testing setup working for React Native using a custom Mocha config and react-native-mock-render. See below for the modules you'll need to add and the configuration.

The problem

Using react-native-cli v2.0.1, I ran react-native init testproj to generate a clean React Native project. It spit out a project directory with React Native v0.57.3, Jest v23.6.0, and metro-react-native-babel-preset v0.48.1. I then added a simple test called App.test.js like so:

describe('a test test', () => {
  it('is true', () => {
    expect(true).toBe(true);
  });
});

However, running yarn test results in:

$ jest
 FAIL  ./App.test.js
  ● Test suite failed to run

    Couldn't find preset "module:metro-react-native-babel-preset" relative to directory "/Users/bgladwell/repos/testproj"

      at node_modules/babel-core/lib/transformation/file/options/option-manager.js:293:19
          at Array.map (<anonymous>)
      at OptionManager.resolvePresets (node_modules/babel-core/lib/transformation/file/options/option-manager.js:275:20)
      at OptionManager.mergePresets (node_modules/babel-core/lib/transformation/file/options/option-manager.js:264:10)
      at OptionManager.mergeOptions (node_modules/babel-core/lib/transformation/file/options/option-manager.js:249:14)
      at OptionManager.init (node_modules/babel-core/lib/transformation/file/options/option-manager.js:368:12)
      at File.initOptions (node_modules/babel-core/lib/transformation/file/index.js:212:65)
      at new File (node_modules/babel-core/lib/transformation/file/index.js:135:24)
      at Pipeline.transform (node_modules/babel-core/lib/transformation/pipeline.js:46:16)

Test Suites: 1 failed, 1 total
Tests:       0 total
Snapshots:   0 total
Time:        0.259s, estimated 1s
Ran all test suites.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

There is some problem with how Jest and Babel are (not) working together. In this case, Jest's out-of-the-box configuration is working against us. Dependencies for Jest and Metro are apparently causing some conflict between Babel 6 and Babel 7, but it's quite difficult to isolate which packages are causing the conflict.

There are open issues for React Native and Metro, but no definitive solutions.

A proposal in both issue threads is to change the babel preset used in .babelrc from metro-react-native-babel-preset to react-native. It doesn't surprise me that this works for some people in some situations, but I don't think it's a good idea. Here's why:

The react-native preset is short for babel-preset-react-native. As you might guess, this is a Babel preset package for working with react native code. What is less obvious is that babel-preset-react-native depends on Babel 6 and has been superseded by metro-react-native-babel-preset, which depends on Babel 7. So to regress back to babel-preset-react-native means forcing our project to continue to depend on Babel 6, something that the react-native maintainers have decided against. Also, we have to worry about more than Jest here; Metro (the React Native bundler) depends on Babel 7. It seems unwise to have our tests compiled with Babel 6 while our development and production code is compiled by Babel 7.

Not really sure how to fix Jest

There may be some way to resolve this issue using Jest. I'm just not sure how. And neither does anyone else, judging by the React Native and Metro issues.

But, I am able to get tests working using Mocha. Here's how:

Mocha

Let's add Mocha and Enzyme to our project so that we can test React components. We also add Chai for assertions. Of course, we'll have to add the Enzyme React 16 adapter. We will configure Enzyme to use the adapter below.

yarn add -D mocha chai enzyme enzyme enzyme-adapter-react-16

Next, update App.test.js so that it does something sorta like a real component test:

import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import { Text } from 'react-native';
import { expect } from 'chai';

import App from './App';

Enzyme.configure({ adapter: new Adapter() });

describe('<App>', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(<App />);
  });
  it('renders default Text', () => {
    expect(wrapper.find(Text)).to.have.lengthOf(3);
  });
});

Our test assumes that App.js renders a simple component with three <Text> component children, which is how react-native init generated it.

Ok, let's try to run our test:

node_modules/.bin/mocha App.test.js

No dice. We get this error:

...
App.test.js:1     
(function (exports, require, module, __filename, __dirname) { import { shallow } from 'enzyme';      
                                                              ^^^^^^                                 

SyntaxError: Unexpected token import
...

That makes sense. We need Babel to compile those import statements, but Babel is not yet involved. Let's change that:

If you check Babel's documentation for how to use Babel with Mocha, it says to do this:

mocha --require babel-register

The --require option loads a npm module at runtime - in this case it is telling Mocha to compile everything with Babel. But this is incorrect in our case! These instruction are out of date. babel-register is the Babel 6 version of what we want to do. We actually need to do:

mocha --require @babel/register

@babel/register is the Babel 7 version. Let's try it:

node_modules/.bin/mocha --require @babel/register  App.test.js

Oh no what's this? Another error:

module.js:491                                     
    throw err;                                    
    ^                                             

Error: Cannot find module 'Platform'
...

Mocking native modules

This error is telling us that that Node is not able to load Platform. This is because Platform is a native module provided by React Native - i.e. a non-JavaScript module written in Objective-C or Java. Happily, this problem is also solvable by mocking the native modules. If you Google around, most solutions will direct you to a package called react-native-mock. I'm sure this package worked at some point, but it is no longer maintained, and it does not work with React 16.

However, the good people at Root Insurance have forked the project and improved it. Lets use their module: react-native-mock-render.

yarn add -D react-native-mock-render

Now what happens if we also load react-native-mock-render with our tests?

node_modules/.bin/mocha --require @babel/register --require react-native-mock-render/mock App.test.js

OMG! IT WORKED!

Compiling node_modules

If the above is working for you, you're good to go. However, it wasn't enough for my project.

Let's add a fairly large dependency to our project: Native Base. This will bring in a host of other modules.

yarn add native-base

Now add a Native Base component to the App.js file that was generated by react-native init. It should look something like this:

import React, {Component} from 'react';           
import {Platform, StyleSheet, Text, View} from 'react-native';                                       
import { Spinner } from 'native-base';            

const instructions = Platform.select({            
  ios: 'Press Cmd+R to reload,\n' + 'Cmd+D or shake for dev menu',                                   
  android:                                        
    'Double tap R on your keyboard to reload,\n' +                                                   
    'Shake or press menu button for dev menu',    
});                                               

type Props = {};                                  
export default class App extends Component<Props> {                                                  
  render() {                                      
    return (                                      
      <View style={styles.container}>             
        <Spinner />                               
        <Text style={styles.welcome}>Welcome to React Native!</Text>                                 
        <Text style={styles.instructions}>To get started, edit App.js</Text>                         
        <Text style={styles.instructions}>{instructions}</Text>                                      
      </View>                                     
    );                                            
  }                                               
}                                                 

const styles = StyleSheet.create({                
  container: {                                    
    flex: 1,                                      
    justifyContent: 'center',                     
    alignItems: 'center',                         
    backgroundColor: '#F5FCFF',                   
  },                                              
  welcome: {                                      
    fontSize: 20,                                 
    textAlign: 'center',                          
    margin: 10,                                   
  },                                              
  instructions: {                                 
    textAlign: 'center',                          
    color: '#333333',                             
    marginBottom: 5,                              
  },                                              
});

All we added was
import { Spinner } from 'native-base';

and
<Spinner />

Now what happens when we run our test?

node_modules/.bin/mocha --require @babel/register --require react-native-mock-render/mock App.test.js

New errors!

...
node_modules/native-base-shoutem-theme/index.js:1                    
(function (exports, require, module, __filename, __dirname) { import connectStyle from "./src/connectStyle";                                                                                               
                                                              ^^^^^^                                 
                                                                                                     
SyntaxError: Unexpected token import
...

Wait, what? This looks like Babel 7 is no longer doing its job. Why is "import" an unexpected token?

The answer is that Babel 7 no longer compiles node_modules by default. This isn't necessarily bad. If you don't need to do it, compiling everything in node_modules can add a LOT of overhead to your tests.

But we apparently DO need Babel 7 to compile the modules under node_modules that ship with ES2015 assets. To make that happen, we're going to create a new Mocha configuration that we load at runtime. As a bonus, we can put some of our general test config stuff (like Enzyme's adapter config) in there too.

Create config/mocha.js

const Enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');

Enzyme.configure({ adapter: new Adapter() });

require('react-native-mock-render/mock');

require('@babel/register')({
  presets: [require('metro-react-native-babel-preset')],
  ignore: [
    function (filepath) {
      const packages = [
        'native-base-shoutem-theme',
      ];
      if (packages.some(p => filepath.startsWith(`${__dirname}/node_modules/${p}`))) {
        return false;
      }
      return filepath.startsWith(`${__dirname}/node_modules`);
    },
  ],
});

As you can see, at the top of the file we configure Enzyme with the React 16 adapter, so we can remove that stuff from our test file. We also pull in react-native-mock-render, so we can now omit that from the command line when running our tests.

Then, we require @babel/register (the same module that we were --requireing on the command line` and pass it a configuration object.

presets: [require('metro-react-native-babel-preset')]

This specifies the same Babel preset that is listed in the .babelrc file generated by native-react init. As you may recall, it is the new version of babel-preset-react-native that uses Babel 7.

Next comes the ignore array. You can find documentation for how this works here. In our case, we are providing a function that returns false for anything we do want Babel to compile, true for anything it should skip.

The function says to compile anything in the packages array, skip anything else under node_modules, compile everything else. In our case we want to compile native-base-shoutem-theme, the module with the error from above.

We could simply write the ignore array as an empty array, but this would mean Babel should compile everything, including node_modules. That seems unnecessarily slow. Using the ignore function above, we can simply add the name of any module that ships code with ES2015 import semantics.

Run the new config like this:

node_modules/.bin/mocha --require config/mocha.js App.test.js

And there you go! Working React Native component tests with Mocha and Babel 7.

Summary

We changed the files and commands we used throughout this post. Here are the final versions:

The example test:

import React from 'react';
import { shallow } from 'enzyme';
import { Text } from 'react-native';
import { expect } from 'chai';

import App from './App';

describe('<App>', () => {
  let wrapper;

  beforeEach(() => {
    wrapper = shallow(<App />);
  });

  it('renders default Text', () => {
    expect(wrapper.find(Text)).to.have.lengthOf(3);
  });
});

The Mocha configuration file:

const Enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');

Enzyme.configure({ adapter: new Adapter() });

require('react-native-mock-render/mock');
require('@babel/register')({
  presets: [require('metro-react-native-babel-preset')],
  ignore: [
    function (filepath) {
      const packages = [
        'native-base-shoutem-theme',
      ];
      if (packages.some(p => filepath.startsWith(`${__dirname}/node_modules/${p}`))) {
        return false;
      }
      return filepath.startsWith(`${__dirname}/node_modules`);
    },
  ],
});

We added the following modules to make all this work:

yarn add -D mocha chai enzyme enzyme enzyme-adapter-react-16 react-native-mock-render

To run a test:

node_modules/.bin/mocha --require config/mocha.js App.test.js

You can/should of course put that in a npm script and omit the node\_modules/.bin/ as well as change App.test.js to be something like **/*.test.js.

P.S.

Snapshot testing with Mocha seems to work with with snap-shot-it and enzyme-to-json. So far, I'm quite happy with Mocha as my React Native test framework.