TL;DR

Use Mocha, proxy calls to react-native or expo through proxyquire (write stubs), and use @babel/register and @babel/polyfill in mocha.opts so Node understand ECMAScript modules.

Situation

I developed an app in React Native, using Expo, which stores data with expo-sqlite and AsyncStorage, and has quit a complex core logic, reading and writing data. This article presents how I can test this logic alone, with classic JS tools (mocha). I do not want to test the interface nor React components. Just JS logic like in a Node application.

The file we want to test (the core logic), dataManager.js, is the following:

import { AsyncStorage } from 'react-native';
import { execSql } from './db';

export const init = async () => {
  const dbVersion = await AsyncStorage.getItem('db:version');
  if (!dbVersion) {
    await execSql('CREATE TABLE Data (number INTEGER, datetime TEXT);');
    await AsyncStorage.setItem('db:version', '1');
  }
};

export const writeData = (number, date) => {
  return execSql('INSERT INTO Data (number, datetime) VALUES (?, ?)', [number, date]);
};

export const readData = async () => {
  const response = await execSql('SELECT number, datetime FROM Data ORDER BY datetime ASC');
  return response.rows;
};
app/dataManager.js

With db.js being a simple wrapper around expo-sqlite:

import * as SQLite from 'expo-sqlite';

const db = SQLite.openDatabase('mydb.sqlite3');

export const execSql = (sql, args) => {
  return new Promise((resolve, reject) => {
    db.transaction((tx) => {
      tx.executeSql(sql, args, (_t, r) => resolve(r), (_t, e) => reject(e));
    });
  });
};
app/db.js

You can see that end dependencies are AsyncStorage from react-native and sqlite from expo-sqlite. Using Mocha, our specs file looks like following (#TDD 🤓):

const assert = require('assert');
const dataManager = require('./dataManager.js');

describe('dataManager', () => {
  it('read and write data', async () => {
    await dataManager.init();
    await dataManager.init(); // second call works

    await dataManager.writeData(1, '2019-12-01T20:00Z');
    await dataManager.writeData(4, '2019-12-01T21:00Z');
    await dataManager.writeData(10, '2019-12-02T08:00Z');

    const data = await dataManager.readData();

    assert(data.length === 3);
    assert(data.item(0).number === 1);
    assert(data.item(1).number === 4);
    assert(data.item(2).number === 10);
  });
});
test/dataManager.spec.js

ECMAScript modules in Node

However, if you try to execute mocha, you will get the following error:

import { AsyncStorage } from 'react-native';
       ^

SyntaxError: Unexpected token {

This is normal: we are now in a NodeJS environment, and we try to use React Native files, which use ECMAScript modules syntax (import … from …), whereas (for now*), NodeJS only recognizes commonjs module syntax (const module = require('module')). To solve this, we use Babel to transpile code on-the-fly.

npm install --save-dev @babel/register @babel/polyfill

echo "--require @babel/register
--require @babel/polyfill
--watch-extensions js,spec.js
test/*.spec.js" > mocha.opts

That way, Mocha will call Babel before executing tests, so Node understands the code.

Stub React Native and Expo: proxyquire ❤️

First, write a stub of the methods from react-native or expo that you use. For example, to emulate AsyncStorage from react-native, we can use the following basic code:

export const ReactNative = () => {
  const storageData = {};

  return {
    AsyncStorage: {
      setItem: (key, value) => (storageData[key] = `${value}`),
      getItem: (key) => storageData[key],
      removeItem: (key) => {
        delete storageData[key];
      },
    },
  };
};
test/react-spec-helpers.js

In tests, we want our code to use this method instead of the original one from react-native. To do this, we can use proxyquire to intercept and proxy calls to a node module, like react-native or expo-sqlite (or any other).

npm install --save-dev proxyquire

In our spec files, we create a proxied version of dataManager:

const proxyquire = require('proxyquire').noCallThru(); // noCallThru is required so proxyquire doesn't try to call react-native real modules, which won't correctly work in NodeJS env
const reactHelpers = require('./react-spec-helpers');

const dataManagerProxyfied = () => {
  return proxyquire('../app/dataManager.js', {
    'react-native': reactHelpers.ReactNative(),
  });
};
test/dataManager.spec.js

And then, simply use this object instead of the original dataManager in your specs file:

describe('dataManager', () => {
  let dataManager;

  beforeEach(() => {
    // Using beforeEach ensures you get a fresh AsyncStorage (for example) each time
    dataManager = dataManagerProxyfied();
  });

  it('read and write data', async () => {
    …
test/dataManager.spec.js

For the sqlite part, we will use sqlite3 node module: npm install --save-dev sqlite3. Then, in react-spec-helpers.js file, write a function which follows the same interface as our function execSql from db.js, but using sqlite3 module instead of expo-sqlite:

import sqlite3 from 'sqlite3';

export const DB = () => {
  const db = new sqlite3.Database(':memory:');

  return {
    execSql: (query, params) => {
      return new Promise((resolve, reject) => {
        db.all(query, params, (err, rows) => {
          if (err) {
            return reject(err);
          }

          resolve({
            rows: {
              length: rows.length,
              item: (i) => rows[i],
            },
          });
        });
      });
    },
  };
};
test/react-spec-helpers.js

Again, use proxyquire to leverage this implementation when dataManager.js wants to use db.js:

const dataManagerProxyfied = () => {
  return proxyquire('../app/data.js', {
    'react-native': reactHelpers.ReactNative(),
    './db': reactHelpers.DB(),
  });
};
test/dataManager.spec.js

That way, we have a real SQLite database and can write realistic tests.

Conclusion

We saw that it's possible to use classic JS tools like Mocha to unit test JS logic in a React Native app, like you would do for a NodeJS application, thanks to proxyquire and the right Babel options in mocha. Plus it's fast, no need to compile nor launch the React Native app.