Test Jest React-Native Expo CRNA with Redux not ejected

How to get all tests working for Components, Redux Actions and Reducers for a Create React Native App (CRNA) using Expo (default) while not ejected?

Also uses Axios, Redux-Thunk async actions and React-Native Maps through Expo.

1 answer

  • answered 2018-01-16 14:49 Thomas Hagström

    Well after reading and re-reading the relevant documentation for Jest, Enzyme and Redux, as well as googling issues with specific NPM package versions I sorted this out.

    There's a lot of "moving parts" in that all NPM packages have to play nice together. I.E testing, mocking, redux and and your flavour of React.

    Here's what works at this time (2018-01-16).

    Setup

    Environment

    • OS X High Sierra
    • Visual Studio Code

    Project platform

    • Create React Native App (CRNA)
    • Expo 23.0.4
    • React 16.0.0-alpha.12
    • React-Native 0.50.3

    Testing framework

    • Jest ^22.0.6
    • Jest-CLI ^22.0.6
    • Jest-Enzyme ^4.0.2
    • Jest-Expo ^22.0.0
    • React-addons-test-utils ^15.6.2
    • React-DOM 16.0.0-beta.5

    package.json

    Working tests for Redux actions, reducers and components.

    {
    "name": "MyApp",
    "version": "0.0.1",
    "private": true,
    "author": "Thomas Hagström <thomas@crossplatform.se>",
    "devDependencies": {
    "axios-mock-adapter": "^1.10.0",
    "babel": "^6.3.26",
    "babel-eslint": "^8.2.1",
    "babel-jest": "^22.0.6",
    "babel-polyfill": "^6.16.0",
    "babel-preset-airbnb": "^1.0.1",
    "babel-preset-es2015": "^6.18.0",
    "babel-preset-react": "^6.16.0",
    "babel-preset-react-native": "1.9.0",
    "eslint": "^4.15.0",
    "eslint-config-airbnb": "^16.1.0",
    "eslint-plugin-import": "^2.8.0",
    "eslint-plugin-jsx-a11y": "^6.0.3",
    "eslint-plugin-react": "^7.5.1",
    "jest": "^22.0.6",
    "jest-cli": "^22.0.6",
    "jest-enzyme": "^4.0.2",
    "jest-expo": "^22.0.0",
    "react-addons-test-utils": "^15.6.2",
    "react-dom": "^16.0.0-beta.5",
    "react-native-mock": "^0.3.1",
    "react-native-scripts": "1.8.1",
    "react-test-renderer": "^16.0.0-alpha.12",
    "remotedev-rn-debugger": "^0.8.3"
    },
    "babel": {
    "presets": [
        "es2015",
        "react"
    ]
    },
    "main": "./node_modules/react-native-scripts/build/bin/crna-entry.js",
    "scripts": {
    "start": "react-native-scripts start",
    "eject": "react-native-scripts eject",
    "android": "react-native-scripts android",
    "ios": "react-native-scripts ios",
    "test": "node node_modules/jest/bin/jest.js --watch",
    "postinstall": "remotedev-debugger --hostname localhost --port 5678 --injectserver",
    "eslint": "./node_modules/.bin/eslint"
    },
    "remotedev": {
    "hostname": "localhost",
    "port": 5678
    },
    "jest": {
    "preset": "jest-expo",
    "transformIgnorePatterns": [
        "node_modules/(?!(react-native|jest-resolve|expo|lodash|enzyme|prop-types|react|jest-enzyme|enzyme|jest-expo|jest-serializer-enzyme|react-native-elements|react-native-google-places-autocomplete)/)"
    ],
    "setupFiles": [
        "./config/jest/globalFetch.js",
        "./config/enzyme/index.js"
    ]
    },
    "dependencies": {
    "@expo/vector-icons": "^6.2.2",
    "axios": "^0.17.1",
    "expo": "^23.0.4",
    "enzyme": "^3.3.0",
    "enzyme-adapter-react-16": "^1.1.1",
    "lodash": "^4.17.4",
    "prop-types": "^15.6.0",
    "react": "16.0.0-alpha.12",
    "react-native": "0.50.3",
    "react-native-elements": "^0.18.5",
    "react-native-google-places-autocomplete": "^1.3.6",
    "react-native-maps": "^0.18.0",
    "react-navigation": "^1.0.0-beta.23",
    "react-navigation-redux": "^0.1.0",
    "react-redux": "^5.0.6",
    "redux": "^3.7.2",
    "redux-logger": "^3.0.6",
    "redux-promise": "^0.5.3",
    "redux-thunk": "^2.2.0",
    "redux-mock-store": "^1.4.0",
    "remote-redux-devtools": "^0.5.12",
    "socketcluster-server": "^9.1.2"
    }
    }
    

    Enzyme global config

    The config script for Enzyme, see package.json below, looks like this.

    // config/enzyme/index.js
    import Enzyme from 'enzyme';
    import Adapter from 'enzyme-adapter-react-16';
    
    // Setup enzyme's react adapter
    Enzyme.configure({ adapter: new Adapter() });
    

    Examples

    Global Mocks

    Expo Kit

    In the root of my project I've placed mocks in a __mocks__ directory so they will automatically be picked up by Jest.

    This will solve cases where native mobile API calls are used - specifically ExpoKit SDK - and not just HTTP REST.

    // __mocks__/expo.js
    jest.mock('expo', () => {
    const expo = require.requireActual('expo');
    
    const positionMock = {
        latitude: 1,
        longitude: 1,
    };
    
    // Mock the expo library
    return {
        Location: {
            setApiKey: jest.fn(),
            getCurrentPositionAsync:
                options =>
                    new Promise(
                        resolve => resolve(options ? {
                            coords: positionMock,
                        } : null)
                        , null,
                    )
            ,
        },
        Constants: {
            manifest: {
                extra: { google: { maps: 'Your-API-KEY-HERE' } },
            },
        },
        Permissions: {
            LOCATION: 'location',
            askAsync: type => new Promise(resolve =>
                resolve(type === 'location' ?
                    { status: 'granted' }
                    : null)),
        },
        ...expo,
    };
    });
    

    Redux - Mock - Store

    To configure Redux with Thunk, so you don't have to do this before every (action) test. Meaning in your tests importing redux-mock-store will use the below implementation:

    // __mocks__/redux-mock-store.js
    import configureMockStore from 'redux-mock-store';
    import thunk from 'redux-thunk';
    
    const middlewares = [thunk];
    const mockStore = configureMockStore(middlewares);
    
    export default mockStore;
    

    Constants

    Used as redux action types.

    // src/Constants.js
    const MapConstants = {
    MAP_LOCATION_CHANGED: 'MAP REGION CHANGED',
    MAP_LOCATION_BUSY: 'MAP: GETTING LOCATION',
    MAP_LOCATION_SUCCESS: 'MAP: GET LOCATION SUCCESS',
    MAP_LOCATION_FAILED: 'MAP: GET LOCATION FAILED',
    };
    

    Redux Action Creators

    Here we used the above configuration in an action test.

    // src/Actions/__tests__/MapActions.test.js
    import configureMockStore from 'redux-mock-store';
    
    import { MapConstants } from '../../Constants';
    import {
        GetLocation
    } from '../MapActions';
    
    const store = configureMockStore();
    
    describe('map actions', () => {
        beforeEach(() => {
            store.clearActions();
        });
    
        test('GetLocation returns SUCCESS when done', async () => {
            const expectedPayload = { latitude: 1, longitude: 1 };
            const expectedActions = [
                { type: MapConstants.MAP_LOCATION_BUSY },
                { type: MapConstants.MAP_LOCATION_SUCCESS, payload: expectedPayload },
            ];
    
            // Dispatch action
            await store.dispatch(GetLocation());
    
            expect(store.getActions()).toMatchSnapshot();
            expect(store.getActions()).toEqual(expectedActions);
            });
    });
    

    Components

    I use a pure component and do my redux connect on a separate container.

    import React from 'react';
    import { shallow } from 'enzyme';
    
    import Map from '../Map';
    import { Colors } from '../../styles';
    
    // src/Components/__tests__/map.test.js
    
    function setup () {
        const props = {
            GetLocation: jest.fn(),
            LocationChanged: jest.fn(),
            map: {
                isBusy: false,
                hasError: false,
                errorMessage: null,
                location: null,
                region: {
                    latitude: 45.52220671242907,
                    longitude: -122.6653281029795,
                    latitudeDelta: 0.04864195044303443,
                    longitudeDelta: 0.040142817690068,
                },
            },
        };
    
        const enzymeWrapper = shallow(<Map {...props} />);
    
        return {
            props,
            enzymeWrapper,
        };
    }
    
    describe('components', () => {
        describe('Map', () => {
            it('should render self and subcomponents', () => {
                const { enzymeWrapper } = setup();
                expect(enzymeWrapper).toMatchSnapshot();
    
                const busyProps = enzymeWrapper.find('BusyIndicator').props();
                expect(busyProps.isBusy).toBe(false);
                expect(busyProps.loadingIndicatorColor).toEqual("#FFFFFF");
            });
    
            // TODO: Mock map functions
        });
    });
    

    Redux Reducer

    Ensure the reducer returns correct state and doesn't mutate it.

    import MapReducer from '../MapReducer';
    import { MapConstants } from '../../Constants';
    
    describe('MapReducer', () => {
        test('should return the initial state', () => {
            expect(MapReducer(undefined, {}))
                .toEqual({
                    isBusy: false,
                    hasError: false,
                    errorMessage: null,
                    location: null,
                    region: {
                        latitude: 45.52220671242907,
                        longitude: -122.6653281029795,
                        latitudeDelta: 0.04864195044303443,
                        longitudeDelta: 0.040142817690068,
                    },
                });
        });
    
        test(`should handle ${MapConstants.MAP_LOCATION_BUSY}`, () => {
            expect(MapReducer({}, {
                type: MapConstants.MAP_LOCATION_BUSY,
            }))
                .toEqual({
                    hasError: false,
                    errorMessage: null,
                    isBusy: true,
                    type: MapConstants.MAP_LOCATION_BUSY,
                });
        });
    
        test(`should handle ${MapConstants.MAP_LOCATION_SUCCESS}`, () => {
            const resultArray = ['test'];
            expect(MapReducer({}, {
                type: MapConstants.MAP_LOCATION_SUCCESS,
                payload: resultArray,
            }))
                .toEqual({
                    isBusy: false,
                    hasError: false,
                    errorMessage: null,
                    location: resultArray,
                    type: MapConstants.MAP_LOCATION_SUCCESS,
                });
        });
    
        test(`should handle ${MapConstants.MAP_LOCATION_FAILED}`, () => {
            const errorString = 'test error';
            expect(MapReducer({}, {
                type: MapConstants.MAP_LOCATION_FAILED,
                payload: errorString,
            }))
                .toEqual({
                    isBusy: false,
                    hasError: true,
                    errorMessage: errorString,
                    location: null,
                    type: MapConstants.MAP_LOCATION_FAILED,
                });
        });
    
        test(`should handle ${MapConstants.MAP_LOCATION_CHANGED}`, () => {
            const resultArray = ['test'];
            expect(MapReducer({}, {
                type: MapConstants.MAP_LOCATION_CHANGED,
                payload: resultArray,
            }))
                .toEqual({
                    isBusy: false,
                    hasError: false,
                    errorMessage: null,
                    region: resultArray,
                    type: MapConstants.MAP_LOCATION_CHANGED,
                });
        });
    });