The Finnternet

React-Native Linting & Testing

November 13, 2019

Intro

Okay bear with me on this one. It ended up being longer than I expected but I promise it will be worth it! Testing may not be the most sexy topic but having a good testing setup is key for worry free (or at least minimal worrying) development. The following setup may look like a lot of steps but its pretty quick and once you do it once (or twice if you setup a CI) you are good to go. All that’s left is to write the actual tests 🤢.

Initialize Project

npx react-native init lintingAndTesting --template react-native-template-typescript
cd lintingAndTesting

Launch emulators

react-native run-android
react-native run-ios

Setup git

git init
git add -A
git commit -m "fresh react-native init"

Linting

Install Dependencies

yarn add --dev @typescript-eslint/eslint-plugin @typescript-eslint/parser cross-env husky lint-staged eslint prettier eslint-config-prettier eslint-plugin-import eslint-plugin-jest eslint-plugin-jsx-a11y eslint-plugin-prettier eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-react-native eslint-plugin-detox

This covers most of the default use cases but if you are using other libraries such as styled-components, you should look to see what linting tools there are for those.

ESLint Config

ESLint is a static analysis tool that checks your code for specific programming patterns; usually best practices for a language or tool.

Delete the Current Config

Delete .eslintrc.js

Add New Eslint Config

I prefer to keep my configs in package.json so I can try to reduce the number of files in the directory but you can keep it as it’s own config file if you want.

// package.json

{
  ...
  "eslintConfig": {
    "root": true,
    "parser": "@typescript-eslint/parser",
    "parserOptions": {
      "ecmaFeatures": {
        "jsx": true
      }
    },
    "extends": [
      "eslint:recommended",
      "plugin:@typescript-eslint/recommended",
      "prettier/@typescript-eslint",
      "prettier/react",
      "plugin:react/recommended",
      "plugin:react-native/all",
      "plugin:jsx-a11y/strict",
      "plugin:import/errors",
      "plugin:import/warnings",
      "plugin:import/typescript",
      "plugin:jest/recommended",
      "@react-native-community"
    ],
    "plugins": [
      "react-hooks",
      "detox"
    ],
    "rules": {
      "react/jsx-filename-extension": [
        1,
        {
          "extensions": [
            ".js",
            ".jsx",
            ".ts",
            ".tsx"
          ]
        }
      ],
      "react/jsx-fragments": [
        1,
        "syntax"
      ],
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "warn"
    },
    "env": {
      "jest": true,
      "detox/detox": true,
      "react-native/react-native": true
    },
    "settings": {
      "react": {
        "version": "detect"
      }
    }
  },
  ...
}

Start with this basic setup & disable rules that you don’t want as you encounter them.

Prettier Config

Prettier is a code formatter that keeps your code formatted according to a specific configuration.

Delete the Current Config

Delete .prettierrc.js

Add New Prettier Config

I prefer to keep my configs in package.json so I can try to reduce the number of files in the directory but you can keep it as it’s own config file if you want.

// package.json

{
  ...
  "prettier": {
    "bracketSpacing": true,
    "jsxBracketSameLine": true,
    "singleQuote": true,
    "tabWidth": 2,
    "trailingComma": "all"
  },
  ...
}

This is the prettier config I use but you can customize it as you see fit.

Testing

Code Coverage

You can have jest compile a code coverage report which will give you an estimate of how well your tests cover your application and what spots you could use more focus in.

// package.json

{
  "scripts": {
    ...
    "test": "jest --coverage",
    ...
  }
}

Snapshot Testing

Snapshot testing is a super easy way to test that your components don’t accidently visually change. The idea is that you run the snapshot test & on first run, it will create a snapshot, a JSON file that represents the component’s elements, and on subsequent runs it will ensure that the component matches the snapshot. If not, it will fail. If this was an intended change than you simply update the snapshot to match the new output.

// __tests__/App.tsx

it('renders correctly', () => {
  const tree = renderer.create(<App />).toJSON();
  expect(tree).toMatchSnapshot();
});
yarn test

# If you want to update a failing snapshot
# yarn test -u

Detox

Detox is a tool from Wix that allows you to easily write end to end tests for your application.

Install iOS simulator utilities

brew tap wix/brew
brew install applesimutils

Install the Detox cli

npm install -g detox-cli

Add Detox to your project

yarn add --dev detox

Android Setup

// android/settings.gradle

...

include ':detox'
project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox')
// android/build.grade

buildscript {
	ext {
   		...
      	minSdkVersion = 18
      	...
      	kotlinVersion = '1.3.10'
   }
   ...
   dependencies {
   		...
      	classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
   }
}
// android/app/build.gradle

dependencies {
  ...
  implementation "androidx.annotation:annotation:1.1.0"
  androidTestImplementation 'androidx.annotation:annotation:1.0.0'
  androidTestImplementation(project(path: ":detox"))
  androidTestImplementation 'junit:junit:4.12'
}
// android/app/build.gradle

android {
  ...
  
  defaultConfig {
      ...
      testBuildType System.getProperty('testBuildType', 'debug')  // This will later be used to control the test apk build type
      testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
  }
}

Add Detox config to package.json

// package.json

{
  ...
  "detox": {
    "configurations": {
      "ios.sim.debug": {
        "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/<APP_NAME>.app",
        "build": "xcodebuild -workspace ios/<APP_NAME>.xcworkspace -scheme <APP_NAME> -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
        "type": "ios.simulator",
        "device": {
          "type": "iPhone 11 Pro"
        }
      },
      "ios.sim.release": {
        "binaryPath": "ios/build/Build/Products/Release-iphonesimulator/<APP_NAME>.app",
        "build": "export RCT_NO_LAUNCH_PACKAGER=true && xcodebuild -workspace ios/<APP_NAME>.xcworkspace -UseNewBuildSystem=NO -scheme APP_NAME -configuration Release -sdk iphonesimulator -derivedDataPath ios/build -quiet",
        "type": "ios.simulator",
        "device": {
          "type": "iPhone 11 Pro"
        }
      },
      "android.emu.debug": {
        "binaryPath": "android/app/build/outputs/apk/debug/app-debug.apk",
        "build": "cd android ; ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug ; cd -",
        "type": "android.emulator",
        "device": {
          "avdName": "Pixel_3_API_29"
        }
      },
      "android.emu.release": {
        "binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
        "build": "cd android ; ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release ; cd -",
        "type": "android.emulator",
        "device": {
          "avdName": "Pixel_3_API_29"
        }
      }
    }
  },
  ...
}

Initialize for Jest

yarn run detox init -r jest

Add Scripts

// package.json

{
  "scripts": {
    ...
    "e2e:ios-debug": "detox build --configuration ios.sim.debug && detox test --configuration ios.sim.debug",
    "e2e:ios-release": "detox build --configuration ios.sim.release && detox test --configuration ios.sim.release",
    "e2e:android-debug": "detox build --configuration android.emu.debug && detox test --configuration android.emu.debug -l verbose",
    "e2e:android-release": "detox build --configuration android.emu.release && detox test --configuration android.emu.release -l verbose"
    ...
  }
}

Add a testID

Add a testID to the “Learn More” Text element.

// App.tsx

<Text style={styles.sectionTitle} testID="learnMore">
	Learn More
</Text>

Update Test

Write a test to make sure we can find our Text element.

// e2e/firstTest.spec.js

describe('App', () => {
  beforeEach(async () => {
    await device.reloadReactNative();
  });

  it('should show the learn more message', async () => {
    await expect(element(by.id('learnMore'))).toBeVisible();
  });
});

Modify Jest

E2E tests can take a while so you should set jest to ignore them by default.

// package.json

"jest": {
    ...
    "testPathIgnorePatterns": [
      "<rootDir>/e2e"
    ],
    ...
}

Run End to End Tests

yarn e2e:ios-debug
# yarn e2e:android-debug

There is currently an issue with Detox not connecting to the Android emulator so the Android tests won’t work. Some people have had luck by lowering the target sdk but for now it is an open issue and I will edit this post when I find a solution.

Git Hook

To ensure all your committed code is tested and formatted properly, we use husky and lint-staged to check everything pre-commit.

// package.json

{
  "scripts": {
    	...
    	"ts-compile-check": "tsc -p tsconfig.json --noEmit"
  	},
	...
	"husky": {
   		"hooks": {
      		"pre-commit": "yarn audit && yarn run ts-compile-check && lint-staged"
    	}
  	},
 	"lint-staged": {
    	"*.{js,jsx,ts,tsx}": [
      		"eslint --fix",
      		"prettier --fix",
      		"cross-env NODE_ENV=test jest --bail --findRelatedTests",
      		"git add"
    	]
  	}
  	...
}

You could also trigger your E2E tests with this hook but I think it is better to manually trigger them at certain points or, ideally, have your CI run them on every push.

Conclusion

With this setup, you have a solid testing foundation to build your app off of. Your linters will enforce best coding practices and consistent formatting. Jest and react-test-renderer for unit and integration testing. Last but not least, you can write simple end to end tests with Detox. As you add libraries and your app complexities go you will most likely have to start mocking some functionality here and there as well as striving to keep a high code coverage.

This setup works great for your local development but if your team grows beyond just yourself or you want to offload the more intense tests (like the end to end ones), a good improvement would be setting the same thing up in a CI that is triggered on every push to your git repository.

Checkout This Project’s Code On Github


Chris Finn

Personal blog by Chris Finn.
Musings and write-ups about coding stuff.
Checkout his work on github & get in touch