Writing Tests for React Native

I've got 99 problems and testing RN was one

Teemu Tiilikainen

 

- 8+ years of consulting

- Fixing IT recruitment with TALENTED (talented.fi)

- Launched multiple native/hybrid apps with React Native

- TDD advocate, Agile Manifesto is my bible 

Issues back then

- No idea how to test these things

- Lots of Android functionality missing

- Is there a convention for styling components?

- Android in overall quite buggy

Time goes by...

- I got into a project via Columbia Road

- First as a fullstack developer, after personnel changes I took over the application development

- Hybrid application with React Native

- Lots of native functionality (camera, fingerprints etc)

- Huge amount of users and no room for error

Project state

export default class AAWebView extends Component {
    render {
        return (
            <WebView
                ...
                onNavigationStateChange={this.handleNavigationStateChange}
            />
        );
    }

    handleNavigationStateChange ({nativeEvent}) {
        if (isNotSomething(nativeEvent)) {
            this.parseTitle(nativeEvent.title);
        } else if (notSomethingElse(nativeEvent)) {
            return webviewUtil(nativeEvent);
        }
    }

    parseTitle (title) {
        var regex = '/3462""#(/aSDMF;asnf/#(=SLAF' +
                    'ASfjlkn.ä''''p99090'''kl'j23"#(/))==))(/ginfa' +
                    '/#"(ADfsa------568-5(%_85-68-568(=€%/4079';
        if (new Regex(regex).test(title)) {
            titleCacheUtil(this.props.setTitle, title.match(new Regex(regex))[1]);
        }
    }
}

Project state

- Code really dependant on website it used

- Modified/extended React Native modules

- Only iOS had been considered

- No tests at all :(((

How to test these things?

TDD with React Components

describe('HelloWorld', () => {
    it('prints text with name', () => {
        const name = 'Teemu';
        const component = render(<HelloWorld name={name} />);
        expect(component.text()).to.equal(`Hi ${name}`);
    });
});

----

import React from ‘react’;

const HelloWorld = ({name}) => (
 <div>{`Hi ${name}`}</div>
);

export default HelloWorld;

React Native === React

describe('HelloWorld', () => {
    it('prints text with name');
});

----

import React from ‘react’;
import {Text, View} from 'react-native';

const HelloWorld = ({name}) => (
    <View>
        <Text>{`Hi ${name}`}</Text>
    </View>
);

export default HelloWorld;

How it works?

Problems with Native Modules

- Native modules instead of DOM elements

- One cannot use native modules in test runner

- React Native has many environmental dependencies that can be hard to simulate without a device

- Makes running tests on CI server impossible

 

¯\_(ツ)_/¯

React Native Mock

- A fully mocked and test-friendly version of React Native

- Mocks RN components as normal React components

- Test like it was React, use any libraries (Enzyme!)

 

Setup Test Environment

- Mocha + chai + sinon + enzyme is my goto-setup

- Jest is an option nowadays, not back then

Setup Test Environment

// package.json

{
  "name": "ReactNativeTesting",
  "version": "0.0.1",
  "scripts": {
    "start": "react-native start",
    "test": "mocha --require react-native-mock/mock.js 
            --compilers js:babel-core/register --recursive src"
  },
  "dependencies": {
    "react": "15.2.1",
    "react-native": "0.30.0"
  },
  "devDependencies": {
    "babel-core": "6.11.4",
    "babel-preset-react-native": "1.9.0",
    "chai": "3.5.0",
    "enzyme": "2.4.1",
    "mocha": "2.5.3",
    "react-addons-test-utils": "15.2.1",
    "react-dom": "15.2.1",
    "react-native-mock": "0.2.5",
    "sinon": "1.17.5",
    "sinon-chai": "2.8.0"
  }
}

Setup Test Environment

// .babelrc

{
    "presets": [
        "react-native"
    ]
}

Setup Test Environment

// testutils/index.js

import React from 'react';
import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
chai.use(sinonChai);
import { shallow } from 'enzyme';
 
global.React = React;
global.expect = chai.expect;
global.sinon = sinon;
global.shallow = shallow;

First Tests

// TestComponent.spec.js

import './testutils';
import TestComponent from './TestComponent';
 
describe('TestComponent', function () {
  let component;
  let textInput;
  const defaultText = 'World';
  const defaultState = {text: defaultText};
 
  beforeEach(function () {
    component = shallow(<TestComponent />);
    textInput = component.find('TextInput');
  });
 
  it('has default state', function () {
    expect(component.state()).to.eql(defaultState);
  });
 
  it('renders default text', function () {
    const expectedText = `Hello ${defaultText}!`;
    expect(component.find('Text').children().text()).to.eql(expectedText);
  });
 
  it('renders input field with placeholder', function () {
    const expectedPlaceholder = 'write something';
    expect(textInput).to.be.present();
    expect(textInput.prop('placeholder')).to.eql(expectedPlaceholder);
  });
 
  ...
});

First Tests

// TestComponent.spec.js

import './testutils';
import TestComponent from './TestComponent';
 
describe('TestComponent', function () {
  
  ...

  describe('when text changes', function () {
    const newTextValue = 'teemu';
 
    beforeEach(function () {
      textInput.simulate('changeText', newTextValue);
    });
 
    it('updates component state', function () {
      expect(component.state().text).to.eql(newTextValue);
    });
 
    it('renders updated text', function () {
      const expectedText = `Hello ${newTextValue}!`;
      expect(component.find('Text').children().text()).to.eql(expectedText);
    });
  });
});

First Component

// TestComponent.js

import React, { Component } from 'react';
import { Text, TextInput, View } from 'react-native';
 
export default class TestComponent extends Component {
  state = {
    text: 'World'
  };
 
  render () {
    const { text } = this.state;
    return (
      <View>
        <Text>{`Hello ${text}!`}</Text>
        <TextInput
          placeholder={"write something"}
          onChangeText={this.handleTextChange}
          value={text}
          />
      </View>
    );
  }
 
  handleTextChange = (text) => {
    this.setState({text});
  };
}

First Results

  TestComponent
    ✓ has default state
    ✓ renders default text
    ✓ renders input field with placeholder
    when text changes
      ✓ updates component state
      ✓ renders updated text
 
  5 passing (7ms)

Pain in the ASS parts

Images

// TestComponent.js

...
return (
      <View>
        <Image source={require('./react.png')} />
        <Text>{`Hello ${text}!`}</Text>
...
TestComponent
    1) "before each" hook for "has default state"
 
  0 passing (439ms)
  1 failing
 
  1) TestComponent "before each" hook for "has default state":
     SyntaxError: ~/ReactNativeTesting/src/react.png: Unexpected character '�' (1:0)
  > 1 | �PNG
      | ^
    2 | 
    3 | 
    4 | IHDR�M���tEXtSoftwareAdobe ImageReadyq�e<�5IDATx���y��S�?�{f�n�[�P�R(

Images

// mocha-setup.js

const m = require('module');
const originalLoader = m._load;
 
m._load = function hookedLoader(request, parent, isMain) {
  if (request.match(/.jpeg|.jpg|.png$/)) {
    return { uri: request };
  }
 
  return originalLoader(request, parent, isMain);
};
// package.json

...
"scripts": {
  "start": "react-native start",
  "test": "mocha --require mocha-setup.js --require react-native-mock/mock.js 
           --compilers js:babel-core/register --recursive src"
},
...

Images

TestComponent
    ✓ has default state
    ✓ renders default text
    ✓ renders input field with placeholder
    when text changes
      ✓ updates component state
      ✓ renders updated text
 
  5 passing (384ms)

Rewiring ES6 modules

// .babelrc
{
  "presets": [
    "react-native"
  ],
  "env": {
    "test": {
      "plugins": [
        "rewire"
      ]
    }
  }
}
// package.json
...
"scripts": {
  "start": "react-native start",
  "test": "BABEL_ENV=test mocha --require mocha-setup.js ...."
},
...

Rewiring ES6 modules

// TestComponent.spec.js
import TestComponent, { __RewireAPI__ as ReactNativeTestingAPI } from './TestComponent';
 
describe('TestComponent', function () {
  ...
  let capitalizeWordStub;
  ...
  beforeEach(function () {
    capitalizeWordStub = sinon.stub();
    ReactNativeTestingAPI.__Rewire__('capitalizeWord', capitalizeWordStub);
    component = shallow(<TestComponent />);
    textInput = component.find('TextInput');
  });
 
  ...
 
  describe('when text changes', function () {
    const newTextValue = 'teemu';
    const expectedTextValue = 'Teemu';
 
    beforeEach(function () {
      capitalizeWordStub.withArgs(newTextValue).returns(expectedTextValue);
      textInput.simulate('changeText', newTextValue);
    });
 
    it('uses capitalizeWord utility', function () {
      expect(capitalizeWordStub).to.have.been.calledWithExactly(newTextValue);
    });
...

Rewiring ES6 modules

// TestComponent.js

import { capitalizeWord } from './WordUtil';

...
  handleTextChange (text) {
    text = capitalizeWord(text);
    this.setState({text});
  }
...

Incompatible React Native Modules

Incompatible React Native Modules

// TestComponent.js
...
import { capitalizeWord } from './WordUtil';
import Share from 'react-native-share';
 
export default class TestComponent extends Component {
...
~/ReactNativeTesting/node_modules/react-native-share/index.js:1
(function (exports, require, module, __filename, __dirname) { import React from 'react';
                                                              ^^^^^^
SyntaxError: Unexpected token import
    at Object.exports.runInThisContext (vm.js:53:16)

Incompatible React Native Modules

Incompatible React Native Modules

// mocha-setup.js

const m = require('module');
const originalLoader = m._load;
 
m._load = function hookedLoader(request, parent, isMain) {
  if (request.match(/.jpeg|.jpg|.png$/)) {
    return { uri: request };
  }
 
  return originalLoader(request, parent, isMain);
};
 
require("babel-register")({
  ignore: /node_modules\/(?!react-native-share)/
});

Incompatible React Native Modules

TestComponent
    ✓ has default state
    ✓ renders default text
    ✓ renders input field with placeholder
    when text changes
      ✓ uses capitalizeWord utility
      ✓ updates component state
      ✓ renders updated text
 
  6 passing (421ms)

So what happened with the app?

- 3+ months of development

- Both iOS and Android app launched successfully

- Great test coverage

- First ecommerce app in Finland where customer could actually buy stuff

- +10k downloads in couple of weeks

- Other features: bar code scanner, fingerprint login

One minus though...

React Native today

- Each release is huge step forward (especially with fiber)

- Prepare for breaking changes

- Always use the latest

- Jest has pretty good support

 

See: github.com/varmais/react-native-unit-tests

 

Thanks!

Writing Tests for React Native

By Teemu Tiilikainen

Writing Tests for React Native

  • 135

More from Teemu Tiilikainen