Testing your Expo app with Jest

This blog is annotated with material from Steve Kinney’s Introduction to Testing course on Frontend Masters.

My degree is in Professional Writing, specifically technical writing from an ancient Greek rhetorical perspective—basically all that means is the first question I ask before starting anything is why?.

So, why do we test software? Junior Dev me would ask, hey, we already test our code thoroughly while we write it, why should we have to double-check our work? To which Senior Dev me would reply, that’s why you didn’t get into Michigan.

The answer is, without snark or condescension, according to Steve Kinney, so we can sleep at night. But here are a few more in case you sleep fine shipping untested code:

  • it's another way to document design
  • forces the engineer to deeply understand every line of the application and how integrations fit together
  • protect against unwanted changes
  • can actually enhance developer productivity by catching errors immediately and by not introducing bugs that will need to be fixed on production

Steve’s software testing principles

1. Writing tests isn’t hard
  • but it’s easy to write hard-to-test code

“The moment you know you need tests is when you get that existential dread in your heart when you go to change a piece of the code and you do not know what else you are going to break or what regressions you’re introducing.”

How do we refactor? First, surround it with tests. The goal of testing is to minimize that fear that you messed something up. Why do we test? So we can sleep at night.

2. Someone is always testing your code.
  • Hopefully it’s you
3. Your tests don’t pass because your code works. They pass because they didn’t fail.
4. No one has ever broken their code into too many, small, well-named, easy-to-test functions.

Types of testing

static

  • Type systems and linters to catch errors while you write
  • No written tests required, only configuration

unit

  • Units are any functional part of your program. They can be a functional component, class, or just a plain function
  • Tests prove the validity of a unit's logic and component API: given the same parameters, always return the same output and have no side-effects
  • For our purposes we'll lump snapshot testing into the unit test category because they both isolate and make assertions about basic functionality—in the case of snapshots that functionality is the render method

integration

  • Testing calls to external services & storage in a controlled environment
  • Ensures that component collaborations work correctly

End-to-end

  • Tests the whole system
  • Can be time-consuming and resource-intensive to get started and maintain but provides the closest test coverage to actual user activity

What do we test?

It’s easy to get dogmatic and philosophical about test coverage, but the fact of the matter is things will break and it’s our job to dictate what happens next.

Steve Kinney’s takeaways on errors and edge cases:

  • “Expect things to go wrong—and test for it.”
  • “Gracefully handle unreasonable inputs with custom error messages. Future you—and your users—will thank you.”
  • “Throw wild edge cases at your code during testing. If your tests pass, you’re cruising toward a production environment with a little more peace of mind.”

What do we do when things go wrong?

We have three options when things fail in our code and only one is a wrong answer.

  1. Fail gracefully
  2. Throw an error
  3. Give up

The Unhappy Path

If the happy path is everything working as expected, then the unhappy path is the one where everything breaks: invalid inputs, missing data, failed operations.

How?

Jest! Which Node test runner should you choose? “Honestly, it doesn’t really matter.“

The syntax is nearly identical between test runners and because Meta wrote React too, Jest works really well with it.

Background

Jest was originally built by Facebook in 2011 when chat was introduced (now Messenger). It was open-sourced in 2014, and maintained by Meta engineers until Christoph Nakazawa overhauled the project in 2016. “Since 2018, almost all the contributions made to Jest have been made by open source contributors outside of Meta.”

Installation

npx expo install jest-expo jest @types/jest --dev

npx expo install @testing-library/react-native --dev

Configuration

  • transformIgnorePatterns to transpile untranspiled modules
    • some RN and TS projects get published untranspiled and Jest doesn't know how to read those
[ "scripts": { "test": "jest --watchAll" }, "jest": { "preset": "jest-expo", "transformIgnorePatterns": [ "node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg))" ], "setupFilesAfterEnv": [ "./jest.setup.ts" ], } ]

Global mocks

// jest.setup.ts jest.mock("expo-font", () => { const module: typeof import("expo-font") = { ...jest.requireActual("expo-font"), isLoaded: jest.fn(() => true), }; return module; });

moduleNameMapper

Stub out resources and modules that don’t need to/can’t be tested

import type { Config } from "jest"; const config: Config = { moduleNameMapper: { "^image![a-zA-Z0-9$_-]+$": "GlobalImageStub", "^[./a-zA-Z0-9$_-]+\\.png$": "<rootDir>/RelativeImageStub.js", "module_name_(.*)": "<rootDir>/substituted_module_$1.js", "assets/(.*)": [ "<rootDir>/images/$1", "<rootDir>/photos/$1", "<rootDir>/recipes/$1", ], }, }; export default config;

Structuring tests

Triple-A pattern:

  • arrange - set up test
  • act - do something
  • assert - verify the result
import { createButton } from './button.js'; describe('createButton', () => { it('should create a button element', () => { const button = createButton(); expect(button).toBeInstanceOf(HTMLButtonElement); });

Prioritize test clarity over cleverness. Keep things simple rather than abstracted.

Interacting with the UI

Tests run in Node, Node isn’t a browser so it lacks browser/native APIs like the DOM. Jest has JSDOM built-in and Vitest is compatible with JSDOM or HappyDOM.

React Native Testing Library provides userEvent as a way to realistically simulate native events. It replaces the old fireEvent method which bubbled events up the component tree similar to the way events work in the DOM.

import { userEvent, render } from "@testing-library/react-native"; import Button from "../../components/Button"; const onPress = jest.fn(); const user = userEvent.setup(); describe("Button component", async () => { it("Renders correctly from props", async () => { const { getByRole } = render(<Button label="Test" onPress={onPress} />); const button = getByRole(“button"); await user.press(button) expect(onPress).toHaveBeenCalled(); }); });

Test doubles

Mocks

A mock is a replacement for a real function with a controlled implementation. We can’t send a bunch of fake calls to our database or auth server every time we run a test so instead we replace the actual module with a stub of one that returns exactly what we ask it for instead of the full implementation.

const mockInitialValue = { test: "" }; const mockSetValue = { test: "" }; const mockErrors = { test: "" }; jest.mock("../path/to/formik", () => ({ useFormikContext: jest.fn().mockImplementation(() => { return { setFieldValue: () => { return mockSetValue; }, values: mockInitialValue, //allowed if named ^mock{LowerCamel} errors: mockErrors, }; }), connect: (Component) => (props) => { return <Component {...props} />; }, }));

There may also be situations where a component is comprised of multiple other components that have been individually tested in isolation and so need only be tested as an integrated unit.

// activitiesList.test.tsx jest.mock("~/app/components/ApplicationsList", () => { const ApplicationsList = () => null; return ApplicationsList; }); jest.mock("~/app/components/FertilizersList", () => { const FertilizersList = () => null; return FertilizersList; }); jest.mock("~/app/components/IrrigationsList", () => { const IrrigationsList = () => null; return IrrigationsList; }); type ActivitiesListProps = React.ComponentProps<typeof ActivitiesList>; describe("<ActivitiesList />", () => { it("Renders a <TabView> correctly", () => { const props: ActivitiesListProps = {}; const tree = render(<ActivitiesList {...props} />).toJSON(); expect(tree).toMatchSnapshot(); expect(tree).toHaveTextContent("ApplicationsFertilizerIrrigation"); }); });

Spy

Wraps a function for the purposes of introspection. Useful for testing if and with what functions were called.

Mocking time/dates

It’s best to stop time and set it yourself then test using explicit values using useFakeTimers() and setSystemTime.

Don’t forget to reset after each/all with useRealTimers().

// dateMock.js const Date = global.Date; const mockDate = jest.fn(() => { return new Date("10/13/2022"); /*or whatever mocked date you desire */ }); mockDate.now = jest.fn(() => Date.now); global.Date = mockDate;

Snapshots

Snapshots allow developers to compare rendered components against a saved ‘snapshot’ which is just a tree of native nodes.

Here’s a basic example of rendering a react native component with and without props:

import { render } from "@testing-library/react-native"; import * as React from "react"; import PlantingItem from "../../components/PlantingItem"; describe("PlantingItem component", () => { it("Renders correctly from no props", async () => { const { toJSON } = render(<PlantingItem />); expect(toJSON()).toMatchSnapshot(); }); it("Renders correctly from props", async () => { const { toJSON } = render( <PlantingItem date="10/05/2022" index="1" id="1" crop="GRASS/ALFALFA" /> ); expect(toJSON()).toMatchSnapshot(); }); });

This will generate two snapshots. One with whatever state happens when I render an empty <PlantingItem /> and another render with date, index, and id as props.

Resets

Modern test runners isolate test suites to prevent interference between mocked dependencies so it’s less important to reset before each test these days.

  • Clear - clear call history for complex mocks
  • Reset - start over with fresh return values without rebuilding a mock
  • Restore - reinstate original functionality

Here’s an example of how those Jest functions work:

beforeAll(() => console.log("1 - beforeAll")); afterAll(() => console.log("1 - afterAll")); beforeEach(() => console.log("1 - beforeEach")); describe("Scoped / Nested block", () => { beforeEach(() => console.log("2 - beforeEach")); test("", () => console.log(1 - test")); test("", () => console.log(2 - test")); }); /* Output: 1 - beforeAll 1 - beforeEach 2 - beforeEach 1 - test 1 - beforeEach 2 - beforeEach 2 - test 1 - afterAll */

Mocking tRPC/API

Because you don’t want a bunch of fake calls hitting your network, we mock out the internal tRPC API.

jest.mock("~/utils/api", () => ({ api: { push: { update: { useMutation: { mutate: jest.fn(), }, }, getByUser: { invalidate: jest.fn(), useQuery: jest.fn(), }, }, farm: { update: { useMutation: jest.fn(), }, }, useContext: jest.fn(), }, })); const mockedApi = api as jest.Mocked<typeof api>; describe("FarmDetails", () => { it("Renders push token error state", () => { mockedApi.push.getByUser.useQuery.mockReturnValue({ data: null, isLoading: false, isError: true, error: new Error("Failed to fetch"), refetch: jest.fn(), } as any); const { toJSON } = render(<FarmDetails />); expect(toJSON()).toMatchSnapshot(); }); });