React Native Integration Tests

Coming from a Rails background, we are very familiar with testing our code. While writing our new React Native app, we found ourself missing a way to test it and ship with confidence. I’ve updated the sample app with the approach we are using for integration tests.

Test Levels

When thinking about what and how to test a React Native app, a few levels come to mind:

  • Unit: Testing some pure Javascript object and it’s methods. Just run in Javascript.
  • Component: Testing a React component in isolation. You’d want to check its reaction to various state and props. Maybe run just in Javascript with heavy stubbing or in the simulator.
  • Integration: Testing a single screen or workflow in the “real” app. Run in the simulator or on the device.

The approach shown here is the last one: integration testing. We did this one first because if you are only going to do one of the above, it is probably your best bet. By actually testing out what the user does, you get the highest level of “don’t screw it up” coverage.

There are some tradeoffs in this choice. They mostly stem from the fact that it’s the slowest (runtime) approach. Because of that, to test many edges cases takes f-o-r-e-v-e-r to actually run the tests. Something lower-level without the simulator would be much faster.

Running Tests

In the sample app, you follow these steps:

  • Make sure you have the 9.0 simulators installed in XCode
  • Compile app for the test environment: npm run compile:test
  • Launch simulator and tests: npm test

Running npm test will launch the simulator and the robots take over.

Plumbing

The tests are written in Javascript using the mocha testing framework. This allows you to declare your cases much like rspec does in Ruby. It gives you hooks to run things before and after as well.

The simulator is run and controlled using Appium. This is some serious magic that implements the Selenium web testing framework but for iOS and Android.

The compile step is important because it compiles the iOS code with a slightly different environment.

The environment lets the iOS code knows to talk to a different port to get it’s code. The React Native packager needs to be running on that port so the test suite launches it automatically. This means you don’t have to recompile the jsbundle each time you make a JS change - jsut like on the simulator with Command+R. In the same way, this is a tremendous improvement to the development process.

The environment also lets the the JS code do a few things differently. For example, it talks to a different localhost API port than the development app. The test suite launches a small koa server on that port. This allows any given test to specify exactly what the server should return for any given API call so we can test the app in a known state.

When in test mode, the Root component also adds in a TestRunner component at the top. But giving Appium buttons, it allows the suite to reset the test each time and bootstrap the app. It gets it’s bootstrap commands from the koa server. It also hooks logging so that all the console.log calls are sent to the koa server so everything can be logged in the test terminal. In both these, it’s the koa server that is the “bridge” between the tests and the simulator.

Putting it all together:

  • npm test runs mocha
  • mocha spawns an Appium driver process (which launches the simulator)
  • mocha spawns a React Native Packager process
  • mocha spawns a koa server process
  • the test starts
  • the test clicks “ResetTest” to be sure to start over (which sends a message to the iOS code to delete some documents)
  • the test clicks “Bootstrap” to get setup instructions.
  • the test uses the Appium API (driver.findElementById('Log in').click()) to do stuff and see how it goes

Bootstrap

The concept of “Bootstrap” might warrant a little more explanation.

The sample app has been set up to use the flux pattern and url-based routing. These two things allow a test to put the world in the state it needs to check the behavior.

When writing Appium tests on our Objective-C app, to test an acount management feature, it would log in, tap the sidebar, tap the gear, then tap “Change Password” or whatever. Then the test really starts. This time really adds up.

It gets easier with flux and routes. For example, because everything in our React Native app is based on events from the dispatcher, we don’t have to actually log in. We can just dispatch the LOGIN_USER action with the right properties.

Then, because it’s based on URLs, we don’t have to navigate to the spot we want. We can just dispatch the LAUNCH_ROUTE_PATH with the appropriate URL and go right to the screen under test.

All of this saves a tremendous amount of time and headache when things change.

So how does this work? The test says what it wants to do to set up the world. That might look like this: yield bootstrap().login().nav("dashboard/follows/friend").launch(driver);. This registers actions (login, navigate) with the koa server.

Then when the test clicks “Bootstrap” on the app, it fetches a particular url form the koa server to get its intructions. In this case, it will dispatch the LOGIN_USER action, followed by the LAUNCH_ROUTE_PATH action. Any arbitrary action could be set, but these are the most common.

Example

This is what it looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  // name the test something relevant
  it("should create a new post", function* (driver, done) {

    // stub the fetch request to get the initial lists of posts with commonly used json
    server.get("/api/posts/tester", fixtures.home());
    // stub creation and set expectations of endpoint to create new post
    server.post("/api/posts",
      {id: 100, content: 'new post here', username: 'tester'}, // return this content
      {content: 'new post here'}                               // expect this content
    );
    // automatically log the test user in
    yield bootstrap().login().launch(driver);

    // tap the upper right to create a new post
    yield driver.elementById('+').click();
    // make sure the screen when there
    yield driver.elementById('New Post');
    // type in some stuff
    yield driver.execute("target.frontMostApp().keyboard().typeString('new post here')");
    // tap the submit button
    yield driver.elementById('Submit').click();

    // check that we are back on the dashboard
    yield driver.elementById('Dashboard');
    // make sure the new post is there
    yield driver.elementById('new post here');

    // all done!
    done();
  });

Here it is running:

I find all the yield stuff kind of annoying but it also prevents the christmas tree of doom situation.

Test On!

Now that we’ve written the integration tests, we can run them on a CI service like Travis.

I set up a .travis.yml file in the sample app and it’s green!

Well, that’s it. This is a very new space and we didn’t see a great way out there to do this kind of testing in a painless-as-possible kind of way. Hopefully, what we’ve done here can be useful for you in your own journey towards React Native apps that work as expected.

Copyright © 2017 Brian Leonard