Welcome back! This is the 6th and final episode of our React-Native tutorial aimed at React developers. In this episode, we'll make our app a bit more responsive, we'll do React-Native testing with Expo on both Android and iOS devices. We'll also improve the developer experience with ESLint for code linting and we'll learn how to use Jest for React-Native unit testing.

To showcase how you can do these things, we'll use our Mobile Game which we've been building in the previous 5 episodes of this React-Native series.

Quick recap: In the previous episodes of our React-Native Tutorial Series, we built our React-Native game’s core logic, made our game enjoyable with music, sound effects and animations, and even added an option to save our results.

You can check the Github repo of the app here: https://github.com/RisingStack/colorblinder

In the tutorial we'll be going over the following agenda:


Testing your React-Native App with Expo


Testing Expo apps on a real device

To test your app on a real device while development, you can use the Expo app. First, download it - it’s available both on Google Play and the App Store.

Once you’re finished, run expo start in the project directory, ensure that the development machine and the mobile device are on the same network, and scan the QR code with your device. (Pro tip: on iOS, you can scan QR codes with the Camera app).


Testing Expo apps on an iOS simulator

If you don’t have a Mac, you can skip this section as you cannot simolate iOS without a Mac..

First, install Xcode and start the Simulators app. Then, kick-off by starting multiple simulators with the following screen sizes:

  • iPhone SE (4.0”, 1136x640)
  • iPhone 8 (4.7”, 1334x750)
  • iPhone 8 Plus (5.5”, 1920x1080)
  • iPhone Xs (5.8”, 2436x1125)

(If you are experiencing performance issues, you can test your app in smaller screen size batches, for example, first, you run SE and 8, then when you’re finished, you run the app on 8 Plus and Xs, too).

You can launch the devices needed from the top bar, then launch Expo from the Expo Developer Tools.

Install React Native Expo

You can install the Expo Client on every simulator by repeating the following steps:

  • Closing every simulator you are running
  • Open one simulator that currently does not have the Expo Client installed on it
  • Press i in the Expo packager terminal - it will search for an iOS simulator and install Expo Client on it.
  • Wait for it to install, then close the simulator if you don’t need it anymore

Repeat these steps until you have Expo Client on every simulator installed. Then, you can open the ColorBlinder app itself on every device by typing in the Expo URL of your app into Safari. The Expo URL will look something like exp://192.168.0.129:19000 - you can see yours in the Expo Developer Tools inside the browser, above the QR code.


Testing Expo apps on an Android emulator

If you don’t have an Android device at hand or want to test on a different device type, you’ll need an emulator. If you don’t already have an Android emulator running on your development machine, follow the steps described in the Expo docs to set up the Android Studio, SDK and the emulator.

Please note that even though the Expo docs don’t point this out, to make the adb command work on a Windows device, you’ll need to add the Android SDK build-tools directory to the PATH variable of your user variables. If you don’t know edit the PATH envvar, follow this tutorial. You can confirm that the variable is set up either by running echo %PATH% and checking if the directory is in the string, or running the adb command itself.

Once you have an Android emulator running on your machine, run expo start in the root directory of the project, open the Expo DevTools in your browser and click the “Run on Android device/emulator“ button above the QR code. If everything is set up properly, the Expo app will install on the device and it will load our app.


Making the Sizing a Bit More Responsive

As you could see, the app currently breaks on some screen sizes and does not scale well at all. Lucky for us, React-Native provides us a bunch of tools to make an app look great on every device, like

  • SafeAreaView to respect iPhone X’s notch and bottom bar,
  • the PixelRatio API that can be used to detect a device’s pixel density,
  • or the already used Dimensions API that we used to detect the width and height of the screen.

We could also use percentages instead of pixels - however, ems and other CSS sizing units are not yet available in React-Native.


Optimizing the screens

home screen before optimization react native unit testing jest

The home screen before optimization

game screen before optimization react native unit testing jest

The game screen before optimization

You can see that the texts are using the same size on every device - we should change that. Also, the spacing is odd because we added the spacing to the bottom bars without using the SafeAreaView - thus we added some unneeded spacing to the non-notched devices, too. The grid size also looks odd on the screenshot, but you should not experience anything like this.

First, let’s use the SafeAreaView to fix the spacing on notched and non-notched devices. Import it from “react-native” both in the Home/index.js and Game/index.js, then for the top container, change <View> to <SafeAreaView>. Then in the Home.js, add a <View style={{ flex: 1 }}> before the first and after the last child of the component tree. We can now delete the absolute positioning from the bottomContainer’s stylesheet:

bottomContainer: {
 marginBottom: "5%",
 marginHorizontal: "5%",
 flexDirection: "row"
},

If we reload the app, we’ll see that it looks well, but on iPhone X, the spacing from the bottom is way too big. We could fix that by toggling the bottom margin depending on the device size. I found a really handy utility that determines whether the app runs on an iPhone X[s/r]. Let’s just copy-paste this helper method into our utilities directory, export it in the index.js and import it in the stylesheet of the Home screen:

import { isIphoneX } from "../../utilities";

Then, you can just simply use it with a ternary in the stylesheet:

bottomContainer: {
 marginBottom: isIphoneX() ? 0 : "5%",
 marginHorizontal: "5%",
 flexDirection: "row"
},

The bottom bar will now render correctly on the home screen. Next off, we could continue with making the text size responsible as it plays a crucial role in the app UI and would have a significant effect on how the app looks.


Making the Text Size Responsive

As I mentioned already, we cannot use em - therefore we’ll need some helper functions that will calculate the font sizes based on the screen dimensions.

I found a very handy solution for this from the guys over Soluto (Method 3): it uses the screen’s width and height and scales it from a standard 5” 350x680 size to the display’s current resolution.

Create a file in the utilities, paste the code below into it, export the new utility in the utils/index.js, and import it in every stylesheet and the Header component. After that, wrap the scale() function on every image width/height and fontSize property in your project. For example, there was an image with the properties width: 40, change it to width: scale(40). You can also play around the numbers a little bit if you want to.

import { Dimensions } from "react-native";
const { width, height } = Dimensions.get("window");

//Guideline sizes are based on standard ~5" screen mobile device
const guidelineBaseWidth = 350;
const guidelineBaseHeight = 680;

export const scale = size => (width / guidelineBaseWidth) * size;
export const verticalScale = size => (height / guidelineBaseHeight) * size;

Now, our app looks great on all iPhones - let’s clean up the code!


Cleaning up the Code

Let’s clean up our Game screen a bit because our file is getting very long (it’s 310 lines!): first, extract the grid generator to a separate component.

Create a Grid.js file in the components directory, copy-paste the code below (it’s just the code we already had with some props, nothing new), and export it in the index.js:

import React from "react";
import { View, TouchableOpacity } from "react-native";

export const Grid = ({ size, diffTileIndex, diffTileColor, rgb, onPress }) =>
 Array(size)
   .fill()
   .map((val, columnIndex) => (
     <View style={{ flex: 1, flexDirection: "column" }} key={columnIndex}>
       {Array(size)
         .fill()
         .map((val, rowIndex) => (
           <TouchableOpacity
             key={`${rowIndex}.${columnIndex}`}
             style={{
               flex: 1,
               backgroundColor:
                 rowIndex == diffTileIndex[0] &&
                 columnIndex == diffTileIndex[1]
                   ? diffTileColor
                   : `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`,
               margin: 2
             }}
             onPress={() => onPress(rowIndex, columnIndex)}
           />
         ))}
     </View>
   ));

Then, delete the grid from the Game/index.js and add the new Grid component as it follows:

{gameState === "INGAME" ? (
 <Grid
   size={size}
   diffTileIndex={diffTileIndex}
   diffTileColor={diffTileColor}
   rgb={rgb}
   onPress={this.onTilePress}
 />
) : (
...

Next off, we could extract the shake animation because it takes up a bunch of space in our code. Create a new file: utilities/shakeAnimation.js. Copy-paste the code below and export it in the index.js.

import { Animated } from "react-native";

export const shakeAnimation = value =>
 Animated.sequence([
   Animated.timing(value, {
     toValue: 50,
     duration: 100
   }),
   Animated.timing(value, {
     toValue: -50,
     duration: 100
   }),
   Animated.timing(value, {
     toValue: 50,
     duration: 100
   }),
   Animated.timing(value, {
     toValue: -50,
     duration: 100
   }),
   Animated.timing(value, {
     toValue: 0,
     duration: 100
   })
 ]).start();

Then, import it in the Game screen, delete the cut-out code, and use the imported function for starting the animation of the grid. Pass in this.state.shakeAnimation as an argument for our function:

…
} else {
     // wrong tile
     shakeAnimation(this.state.shakeAnimation);
...

Last but not least, we could extract the bottom bar, too. It will require a bit of additional work - we’ll need to extract the styles and a helper function, too! So instead of creating a file, create a directory named “BottomBar” under components, and create an index.js and styles.js file. In the index.js, we’ll have a helper function that returns the bottom icon, and the code that’s been cut out from the Game/index.js:

import React from "react";
import { View, Text, Image, TouchableOpacity } from "react-native";
import styles from "./styles";

const getBottomIcon = gameState =>
 gameState === "INGAME"
   ? require("../../assets/icons/pause.png")
   : gameState === "PAUSED"
   ? require("../../assets/icons/play.png")
   : require("../../assets/icons/replay.png");

export const BottomBar = ({
 points,
 bestPoints,
 timeLeft,
 bestTime,
 onBottomBarPress,
 gameState
}) => (
 <View style={styles.bottomContainer}>
   <View style={styles.bottomSectionContainer}>
     <Text style={styles.counterCount}>{points}</Text>
     <Text style={styles.counterLabel}>points</Text>
     <View style={styles.bestContainer}>
       <Image
         source={require("../../assets/icons/trophy.png")}
         style={styles.bestIcon}
       />
       <Text style={styles.bestLabel}>{bestPoints}</Text>
     </View>
   </View>
   <View style={styles.bottomSectionContainer}>
     <TouchableOpacity
       style={{ alignItems: "center" }}
       onPress={onBottomBarPress}
     >
       <Image source={getBottomIcon(gameState)} style={styles.bottomIcon} />
     </TouchableOpacity>
   </View>
   <View style={styles.bottomSectionContainer}>
     <Text style={styles.counterCount}>{timeLeft}</Text>
     <Text style={styles.counterLabel}>seconds left</Text>
     <View style={styles.bestContainer}>
       <Image
         source={require("../../assets/icons/clock.png")}
         style={styles.bestIcon}
       />
       <Text style={styles.bestLabel}>{bestTime}</Text>
     </View>
   </View>
 </View>
);

And the stylesheet is also just the needed styles cut out from the Game/styles.js:

import { Dimensions, StyleSheet } from "react-native";
import { scale } from "../../utilities";

export default StyleSheet.create({
 bottomContainer: {
   flex: 1,
   width: Dimensions.get("window").width * 0.875,
   flexDirection: "row"
 },
 bottomSectionContainer: {
   flex: 1,
   marginTop: "auto",
   marginBottom: "auto"
 },
 bottomIcon: {
   width: scale(45),
   height: scale(45)
 },
 counterCount: {
   fontFamily: "dogbyte",
   textAlign: "center",
   color: "#eee",
   fontSize: scale(45)
 },
 counterLabel: {
   fontFamily: "dogbyte",
   textAlign: "center",
   color: "#bbb",
   fontSize: scale(20)
 },
 bestContainer: {
   marginTop: 10,
   flexDirection: "row",
   justifyContent: "center"
 },
 bestIcon: {
   width: scale(22),
   height: scale(22),
   marginRight: 5
 },
 bestLabel: {
   fontFamily: "dogbyte",
   color: "#bbb",
   fontSize: scale(22),
   marginTop: 2.5
 }
});

Now, delete any code left in the Game files that have been extracted, export the BottomBar in the components/index.js, import it in the screens/Game/index.js and replace the old code with the component as it follows:

<View style={{ flex: 2 }}>
 <BottomBar
   points={points}
   bestPoints={bestPoints}
   timeLeft={timeLeft}
   bestTime={bestTime}
   onBottomBarPress={this.onBottomBarPress}
   gameState={gameState}
 />
</View>

Now that our code is a bit cleaner and hopefully more understandable for you, we could continue with making our code more readable and consistent by adding ESLint to our project.


Initializing ESLint in React-Native/Expo Projects

If you don’t know already, ESLint is a pluggable linting utility for JavaScript and JSX. You may already have heard of Prettier, but do not mix them, because they both exist for a different reason.

ESLint checks for the logic and syntax of your code (or code quality), while Prettier checks for code stylistics (or formatting). You can integrate Prettier to ESLint too, but adding it to your editor via a plugin will do it for now.

First, install ESLint and some additional tools globally:

npm install --save-dev eslint eslint-config-airbnb eslint-plugin-import eslint-plugin-react eslint-plugin-jsx-a11y babel-eslint

When finished, initialize ESLint with the following command inside your project: eslint --init. Then, select:

  • Use a popular style guide
  • Airbnb
  • Press y if it asks if you use React
  • Pick JSON (if you choose a differing choice, the linter will behave the same way, but we’ll work inside the config file, and you’ll need to work around it a bit to make it work)

Then, restart your editor to make sure that the ESLint server starts in your editor, then open the .eslintrc.json in the root of the project and make sure it contains the following:

{
 "env": {
   "node": true,
   "browser": true,
   "es6": true
 },
 "parser": "babel-eslint",
 "extends": "airbnb"
}

Then, you can play around your code to shut the errors (there will be plenty of them), or simply disable the rules that annoy you. I don’t recommend going to the other extreme by disabling most of the rules since that would make ESLint useless.

You can, however, calmly disable rules like react/jsx-filename-extension that will throw you an error if you DARE to write JSX code inside a .js file, or global-require that will trigger even if you think about using require() inside your code. Don't get me wrong. I think that they are reasonable rules, but in this project, they are simply not handy.

You can disable ESLint rules in the .eslintrc.json:

"rules": {
  "react/jsx-filename-extension": [0],
  "global-require": [0]
}

For rules,

  • level 0 means disabling a rule,
  • level 1 means setting it to warning level,
  • and level 2 rules will throw an error.

You can read more about configuration in the docs.

Take your time fixing the issues, but before you start throwing out your computer already, be sure to check out the VSCode extension for ESLint.

It comes very handy when introducing ESLint to a previously non-linted project. For example, it can fix automatically fixable problems with just one click - and most of the issues (like spacing or bracket issues) are auto-fixable.


Automated React-Native Unit Testing with Jest

The only thing left before we can mark the project as a finished MVP is adding unit testing. Unit testing is a specialized form of automated testing that runs not only on your machine but in your CI too - so that failing builds don’t get into production.

There are several tools out there like Detox or Mocha, but I chose Jest because it’s ideal for React and React-Native testing. It has a ton of frontend testing features like snapshot testing that Mocha lacks.

If you aren’t familiar with testing yet, I don’t recommend learning it from this article as I will assume that you are already familiar with testing. We already have a very nice article about “Node.js unit testing” - so be sure to check it out to get familiar with some basic ideas and concepts.

Let’s get started with the basics: first, install Jest. With react-native init, you get Jest out of the box, but when using Expo, we need to install it directly. To do so, run yarn add jest-expo --dev or npm i jest-expo --save-dev depending on which package manager you prefer.

Then, let’s add the snippets below to the corresponding places in the package.json:

“scripts”: {
	…
	“test”: “jest”
},
“jest”: {
	“preset”: “jest-expo”
}

Then, install the test renderer library: yarn add react-test-renderer --dev or npm i react-test-renderer --save-dev. That’s it! ?

Now, let’s start by configuring Jest. Jest is a very powerful tool and comes with a handful of options, but for now, we will only add one option, the transformIgnorePatterns. (To learn more about other Jest config options, please head to the docs).

The transformIgnorePatterns option expects “an array of regexp pattern string that are matched against all source file paths before transformation”. We will pass in the following arguments in the package.json:

"jest": {
	"preset": "jest-expo",
	"transformIgnorePatterns": [
"node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|sentry-expo|native-base)"
]
}

This snippet will ensure that every module that we use is transpiled, otherwise Jest might throw syntax errors and make our related tests fail.

Now, that everything’s set up and configured correctly, let’s start by writing our first unit test. I will write a test for the Grid component by creating the file Grid.test.js inside the componentsHome directory, but you can add tests for any file by adding a filename.test.js next to it, and Jest will recognize these files as tests.

Our test will expect to have our Grid to have three children in the tree that gets rendered:

import React from 'react';
import renderer from 'react-test-renderer';

import { Grid } from './Grid';

describe('<Grid />', () => {
 it('has 1 child', () => {
   const tree = renderer
     .create(
       <Grid
         size={3}
         diffTileIndex={[1, 1]}
         diffTileColor="rgb(0, 0, 0)"
         rgb="rgb(10, 10, 10)"
         onPress={() => console.log('successful test!')}
       />,
     )
     .toJSON();
   expect(tree.length).toBe(3); // The length of the tree should be three because we want a 3x3 grid
 });
});

Now, run yarn test or npm test. You will see the test running, and if everything’s set up correctly, it will pass.

Congratulations, you have just created your first unit test in Expo! To learn more about Jest, head over to it’s amazing docs and take your time to read it and play around with it.


What other React-Native Topics Should we Cover?

Thanks for reading my React-Native tutorial series. If you missed the previous episodes, here's a quick rundown:

I'd like to create more content around React-Native, but I need some help with it! :)

It would be great if you could leave a few RN topics in the comment sections which are hard to understand or get-right.

PS: If you need a great team to build your app, reach out to us at RisingStack on our website, or just ping us at [email protected].

Cheers,
Dani