THE FINNTERNET

Expo Starter: Linting, Formatting, & Testing

9/6/2021

This Post is part of the Expo Starter series.

  1. Initial Setup
  2. Logging & Monitoring
  3. Linting, Formatting, & Testing (you are here)
  4. Navigation
  5. Push Notifications
  6. Responsive, Cross-Platforms Styling
  7. Progressive Web App (PWA)
  8. Desktop Support

Setup Jest

For unit testing, we will be using Jest and react-native-testing-library.

Install Dependencies

yarn add -D jest-expo ts-jest@^26 @types/jest react-test-renderer @types/react-test-renderer \
@testing-library/react-native @testing-library/jest-native jest-coverage-badges

The version of ts-jest has to match the version of jest included with jest-expo. You can check this with npm list jest.

Create Jest Setup

This file will be used to setup some global imports that we want for all our tests. In this case, we will load our environmental variables.

// jest.setup.js
require('dotenv').config({ path: `envs/.env.${process.env.NODE_ENV}` });

Create Test Environment

Jest sets NODE_ENV to test so we will create a test environment file. You can just duplicate .env.development and change NAME to test. Don't forget to add this file to your .gitignore.

// envs/.env.test
NAME=test
...
// .gitignore
...
envs/.env.test

Update Jest Config

This file should already exist and contain our module mappings.

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  preset: 'jest-expo',
  setupFiles: ['<rootDir>/jest.setup.js'],
  setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
  globals: {
    'ts-jest': {
      tsconfig: {
        jsx: 'react',
      },
    },
  },
  transform: {
    '^.+\\.js$': '<rootDir>/node_modules/react-native/jest/preprocessor.js',
    '^.+\\.tsx?$': 'ts-jest',
  },
  testMatch: ['**/?(*.)+(spec|test).ts?(x)'],
  collectCoverageFrom: [
    '**/*.{ts,tsx}',
    '!**/coverage/**',
    '!**/node_modules/**',
    '!**/babel.config.js',
    '!**/jest.setup.js',
  ],
  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'],
  // By default, all files inside `node_modules` are not transformed. But some 3rd party
  // modules are published as untranspiled, Jest will not understand the code in these modules.
  // To overcome this, exclude these modules in the ignore pattern.
  transformIgnorePatterns: [
    '/.*)',
  ],
  coverageReporters: ['json-summary', 'text', 'lcov'],
  moduleNameMapper: {
    '^@/api/(.*)': '<rootDir>/src/api/$1',
    '^@/assets/(.*)': '<rootDir>/src/assets/$1',
    '^@/components/(.*)': '<rootDir>/src/components/$1',
    '^@/locales/(.*)': '<rootDir>/locales/$1',
    '^@/navigators/(.*)': '<rootDir>/src/navigators/$1',
    '^@/screens/(.*)': '<rootDir>/src/screens/$1',
    '^@/services/(.*)': '<rootDir>/src/services/$1',
    '^@/styles/(.*)': '<rootDir>/src/styles/$1',
    '^@/utilities/(.*)': '<rootDir>/src/utilities/$1',
  },
};

Add Scripts

// package.json
{
  ...
  "scripts": {
    ...
    "test": "jest",
    "test:dev": "jest --watch --changedSince=origin/master",
    "test:debug": "jest -o --watch",
    "test:updateSnapshots": "jest -u",
    "test:ci": "jest --coverage",
    "test:badges": "npm run test:ci  && jest-coverage-badges --input coverage/coverage-summary.json --output __badges__"
  },
  ...
}

Update .gitignore

// .gitignore
...
# testing
coverage/

First Tests

Before we write our tests, we have to do two things. First, delete the tests in backend/test/ since we don't want to deal with testing the CDK code right now. Second, rename src/utilities/test.ts to src/utilities/testUtility.ts and fix the import in App.tsx. The old filename would be picked up as a test.

For our first tests we will make sure App.tsx matches a snapshot and also check to make sure that process.env.NODE_ENV and process.env.NAME successfully load and are displayed. In order to do this, we have to also mock the imports that we don't care about right now.

// src/utilities/App.test.tsx
import React from 'react';
import { render } from '@testing-library/react-native';
import App from './App';

jest.mock('aws-amplify');
jest.mock('@/services/sentry');
jest.mock('@/api/amplify');
jest.mock('@/services/location');
jest.mock('@/services/analytics');
jest.mock('@/utilities/testUtility');

describe('<App />', () => {
  it('renders correctly', () => {
    const tree = render(<App />).toJSON();
    expect(tree).toMatchSnapshot();
  });

  it('displays process.env.NODE_ENV', () => {
    const { getByText } = render(<App />);
    const text = getByText(new RegExp('^process\.env\.NODE_ENV: [a-zA-z0-9]+'));
    expect(text).not.toBeNull();
  });

  it('displays process.env.NAME', () => {
    const { getByText } = render(<App />);
    const text = getByText(new RegExp('^process\.env\.NAME: [a-zA-z0-9]+'));
    expect(text).not.toBeNull();
  });
});

You can put your tests in a __tests__ directory (or elsewhere) if you want but I prefer to co-locate my tests with the file being tested.

npm run test

If the output is correct but your snapshot is failing because it's matching against an old, incorrect snapshot, run npm run test:updateSnapshots to update it.

We can also run npm run test:badges to run our tests, generate a coverage report, and create those cool badges people show in their repo README.

Setup Storybook

I ran into issues setting up Storybook. I will update this section when I get it working.

Setup Detox

Detox is even more painful to get setup with Expo. I managed to get it working by making an EAS build for iOS that doesn't have the dev client. This isn't a great solution though so I will keep investigating and update this section when I make progress.

Setup Cypress

We will use Cypress to do end to end testing on the web app.

Install Dependencies

yarn add -D start-server-and-test cypress

Add Scripts

// package.json
{
  ...
  "scripts": {
    ...
    "cypress": "cypress open",
    "cypress:headless": "cypress run",
    "e2e": "start-server-and-test start http://localhost:3000 cypress",
    "e2e:headless": "start-server-and-test start http://localhost:3000 cypress:headless",
  }
  ...
}

Configure Cypress

// cypress.json
{
    "baseUrl": "http://localhost:19006/"
}
yarn cypress
// cypress/tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "lib": [
            "es5",
            "dom"
        ],
        "types": [
            "cypress"
        ]
    },
    "include": [
        "**/*.ts",
        "**/*.js"
    ]
}

First E2E Test

Cypress is a super powerful testing framework but we will just keep our first test super simple and check that the app loads and we can see the text process.env.NODE_ENV.

/// <reference types="cypress" />

describe('App', () => {
  it('loads', () => {
    cy.visit('/')
    cy.contains('process.env.NODE_ENV')
      .should('be.visible')
  });
});

We can test it in the terminal using yarn cypress:headless.

Setup Linting & Formatting

Install ESLint

ESLint and it's numerous plugins will handle our linting.

yarn add -D eslint

Install Plugins

Airbnb Config

This installs common ESLint defaults

% npx install-peerdeps --dev eslint-config-airbnb

JSON

yarn add -D eslint-plugin-json

TypeScript

yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-import-resolver-typescript

Babel

yarn add -D babel-eslint

Prettier

For formatting we will be using Prettier and integrating it into ESLint.

yarn add -D prettier eslint-config-prettier eslint-plugin-prettier

Jest

yarn add -D eslint-plugin-jest

Testing Library

yarn add -D eslint-plugin-testing-library

Cypress

yarn add -D eslint-plugin-cypress

Detox

yarn add -D eslint-plugin-detox

Configure ESLint

This config is something you will update as you go along. Feel free to re-enable rules, disable others, etc.

// .eslintrc.js
module.exports = {
  root: true,
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module',
    ecmaFeatures: {
      jsx: true,
    },
  },
  settings: {
    react: {
      version: 'detect',
    },
    'import/resolver': {
      typescript: {},
    },
  },
  env: {
    browser: true,
    es6: true,
    node: true,
    'jest/globals': true,
  },
  plugins: [
    '@typescript-eslint',
    'react',
    'react-hooks',
    'testing-library',
    'cypress',
    'detox',
    'import',
    'prettier',
    'jest',
    'detox',
  ],
  extends: [
    'airbnb',
    'airbnb/hooks',
    '-eslint/recommended',
    'plugin:react/recommended',
    'plugin:import/errors',
    'plugin:import/warnings',
    'plugin:import/typescript',
    'plugin:json/recommended',
    'plugin:jsx-a11y/recommended',
    'plugin:react-hooks/recommended',
    'plugin:testing-library/react',
    'plugin:cypress/recommended',
    'plugin:jest/recommended',
    'plugin:jest/style',
    'plugin:prettier/recommended',
  ],
  overrides: [
    {
      files: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'],
      extends: ['plugin:testing-library/react'],
      rules: {
        'testing-library/prefer-screen-queries': 0,
      },
    },
  ],
  rules: {
    'prettier/prettier': [
      'error',
      {
        singleQuote: true,
        trailingComma: 'all',
        arrowParens: 'avoid',
        endOfLine: 'auto',
        printWidth: 120,
      },
      {
        usePrettierrc: false,
      },
    ],
    'react/react-in-jsx-scope': 'off',
    'jsx-a11y/anchor-is-valid': [
      'error',
      {
        components: ['Link'],
        specialLink: ['hrefLeft', 'hrefRight'],
        aspects: ['invalidHref', 'preferButton'],
      },
    ],
    'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }],
    'react/jsx-one-expression-per-line': 0,
    'react/jsx-props-no-spreading': 0,
    'no-use-before-define': 'off',
    '@typescript-eslint/no-use-before-define': 0,
    '@typescript-eslint/ban-types': 0,
    '@typescript-eslint/no-unused-vars': 0,
    'no-console': 0,
    '@typescript-eslint/ban-ts-comment': 0,
    'import/no-extraneous-dependencies': 0,
    'import/extensions': 0,
    'lines-between-class-members': 0,
    '@typescript-eslint/no-empty-function': 0,
    '@typescript-eslint/no-var-requires': 0,
    'import/prefer-default-export': 0,
    'class-methods-use-this': 0,
    'testing-library/no-debug': 0,
    'no-case-declarations': 0,
    'no-new': 0,
  },
};

Setup Commitlint

Commitlint ensures that our commits follow the Conventional Commit specification.

yarn add -D @commitlint/{cli,config-conventional}
// .commitlintrc.js
module.exports = { 
    extends: ['@commitlint/config-conventional'],
    rules: {
        'header-max-length': [0, "always", 72],
        'body-max-line-length': [0, "always", 100],
        'footer-max-line-length': [0, 'always', 100]
    } 
};

Ensure Commited Code is Linted & Formatted

Husky & lint-staged work together to check that our files are properly linted and formatted before commiting.

Install Husky & Lint-Staged

npx mrm@2 lint-staged

Configure Lint-Staged

On commit, The Husky pre-commit hook will trigger lint-staged. Lint-staged will run eslint --fix against all staged files that match the given glob pattern. If there are no errors, the files are staged and the commit goes through.

// .lintstagedrc.js
module.exports = {
  "*.{js,jsx,ts,tsx,json,css,scss,md,mdx}": ["eslint --fix"],
};

Install CommitLint Husky Hook

This hook will call commitlint to check that the commit message is properly formatted before allowing the commit to go through.

npx husky add .husky/commit-msg 'npx --no-install commitlint --edit'

Test Linting & Formatting

Let's add a script to run eslint,

// package.json
{
  "scripts": {
    ...
    "lint": "eslint . --fix"
  }
}

Originally we had ~300 problems but thankfully ESLint fixed most of them. We still have 31 to fix (the warnings are optional). You will probably have less because I am going to put the updated ESLint config above but I'll leave it up to you to fix the rest or disable some of the rules if you think they are too strict.

Fix TypeScript Config

You will probably see some TypeScript errors now. Some may be from poor typing in dependencies and can be ts-ignored but the Jest and Cypress ones can be fixed with TypeScript configs.

// tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "baseUrl": "./",
    "paths": {
      "@/api/*": ["src/api/*"],
      "@/assets/*": ["src/assets/*"],
      "@/components/*": ["src/components/*"],
      "@/locales/*": ["locales/*"],
      "@/navigators/*": ["src/navigators/*"],
      "@/screens/*": ["src/screens/*"],
      "@/services/*": ["src/services/*"],
      "@/styles/*": ["src/styles/*"],
      "@/utilities/*": ["src/utilities/*"],
    }
  },
  "exclude": [
    "**/*.test.js",
    "**/*.test.ts",
    "**/*.test.tsx",
    "cypress/*"
  ]
}
// tsconfig.test.json
{
  "extends": "./tsconfig.json",
    "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": ["jest"],
    "module": "commonjs",
    "emitDecoratorMetadata": true,
    "allowJs": true
  },
  "include": [
    "**/*.test.js",
    "**/*.test.ts",
    "**/*.test.tsx",
    "**/*.spec.js",
    "**/*.spec.ts",
    "**/*.spec.tsx",
  ],
  "exclude": [
    "cypress/*"
  ]
}

Test Commit Checks

Okay, now that we have fixed all the ESLint and TypeScript problems, we should be good to commit our changes.

% git add -A
% git commit -m "feat: setup linting & formatting"
✔ Preparing...
✔ Running tasks...
✔ Applying modifications...
✔ Cleaning up...
[linting-formatting-and-testing f49a574] feat: setup linting & formatting
Chris' bitmoji smiling

Hi, how's it going? I’m Chris Finn, a full-stack developer from Boston, MA. I specialize in UI and UX design, cross-platform app development, and eating calzone. I am also a sucker for automation and security (in a past life I was a cybersecurity researcher).