Skip to content
Logo Theodo

Effective Strategies for Testing React Native Apps: Striking a Balance Between Testing UI and Business Logic

Mo Khazali9 min read

Logos for React and React Testing Library

Testing in software development is a contentious topic, with advocates on both ends of the spectrum. On one hand, there are those who argue against writing any tests at all, while on the other hand, there are companies that insist on 100% test coverage. I remember I went on a project where there was a pre-push step that would fail if a certain % of line coverage wasn’t there. While these extreme positions may be easier to communicate, the reality is that there are many more nuanced considerations when it comes to testing.

A Tweet by Theo about how he hasn't written a test in 2 years

It’s important to strike a balance between the two extremes and adopt a pragmatic approach to testing. In the context of React or React Native development, testing strategies will change greatly depending on the app architecture and design patterns used. In this article, we’ll explore different testing strategies for React Native and discuss how app architecture plays a crucial role in creating an effective, but not overkill, approach to testing.

The Problem with Writing Unit Tests for UIs

Writing unit tests that focus on UI elements, such as views, buttons, and text, is a common approach for testing UIs. However, this strategy can be problematic as it focuses on replicating the rendering environment of the UI elements, rather than testing the underlying business logic. This approach can have two pitfalls:

  1. Tests can be brittle and difficult to maintain. (Results in False Positives)
  2. They may not actually be testing the correct bits of the app to validate the business logic is working as expected. (Results in False Negatives)

Consider this example: suppose you want to test a screen with a form that has an email field and a submit button. If the field is left empty, pressing a button should render some validation text underneath, saying “You have not entered an email”.

import React, { useState } from "react";
import { View, TextInput, Text, Button } from "react-native";

const EmailForm = () => {
  const [email, setEmail] = useState("");
  const [error, setError] = useState(false);

  const handlePress = () => {
    if (!email) {
      setError(true);
    } else {
      // business logic for submitting form
    }
  };

  return (
    <View>
      <TextInput
        placeholder="Enter your email"
        value={email}
        onChangeText={setEmail}
      />
      {error ? (
        <Text style={{ color: "red" }}>You have not entered an email</Text>
      ) : null}
      <Button title="Submit" onPress={handlePress} testID="submit-button" />
    </View>
  );
};

export default EmailForm;

The common approach to testing this component would be to write a test that renders the component and simulates a press of the button. It would then try to assert that text is being shown on the screen. However, such tests are only verifying that the UI elements are rendered correctly, and not whether the underlying business logic works as intended. Sometimes, you’ll end up creating incorrect tests. Take the following test example:

import React from "react";
import { render, fireEvent, screen } from "@testing-library/react-native";
import EmailForm from "./EmailForm";

describe("EmailForm component", () => {
  it("displays an error message when email field is empty", () => {
    render(<EmailForm />);

    const submitButton = screen.getByTestId("submit-button");

    fireEvent.press(submitButton);

    const errorMessage = screen.getByText("You have not entered an email");
    expect(errorMessage).toBeTruthy();
  });
});

Now let’s assume you introduce a bug where the error state is set to true by default. The error text would be shown on initial render regardless, and the test above wouldn’t actually catch a bug if the handlePress had a regression.

This is a rather simple example - expand it to a much more complex component with loads of logic inside it, and a lot of these cases can come up where tests are not adequately testing the logic, rather they’re testing UI rendering, attempting to cover business logic underneath as a secondary effect.

To compound this, there is a lot of wasted effort in replicating the components for testing purposes. These tests end up becoming very complex and difficult to maintain as the UI elements change over time, with the challenge of ensuring that the tests accurately represent the behaviour of the app.

This is not a new problem and has been around since the inception of graphical user interfaces (GUIs). Let’s look at how engineers have dealt with this from before the React era.

Humble Views

Martin Fowler talks about this in his article about GUI Architectures:

Some platforms provide no hooks to enable us to run automated tests against UI controls. Even those that do often make it difficult, with complex setup, special frameworks, and slow-running tests.

To mitigate this problem, move as much as logic as possible out of the hard-to-test element and into other more friendly parts of the code base. By making untestable objects humble, we reduce the chances that they harbor evil bugs.

Extracting Logic from UI in React

Model-View-Presenter with React Counterparts

In a previous article titled One Hook Per Screen: a simple architecture for scalable React Native apps, we proposed a similar UI architecture that roughly resembled the Model-View-Presenter (MVP) architecture. With this approach, you define a presenter layer that extracts all of the logic from your views, handling user events, data population, and local view logic. The view receives data and functions from the presenter layer and contains minimal or no logic within it. All of the logic is handled by the presenter, which manipulates the view.

This approach is not only beneficial from a Separation of Concerns perspective, but it also simplifies testing. By testing the presenter, you test most of the risk of the UI without having to touch the hard-to-test UI elements.

In React, this approach involves extracting all of the logic from your screens/components into a hook and then only testing this hook. The hook serves as the presenter layer, simplifying testing by eliminating the need to test actual UI elements and their complexities. You’re only testing the business logic that lives in the hook, making testing simpler and more efficient.

Let’s take the same example as above:

import React, { useState } from "react";
import { View, TextInput, Text, Button } from "react-native";

export const useEmailForm = () => {
  const [email, setEmail] = useState("");
  const [error, setError] = useState(false);

  const handlePress = () => {
    if (!email) {
      setError(true);
    } else {
      // business logic for submitting form
    }
  };

  return {
    email,
    setEmail,
    error,
    handlePress,
  };
};

export const EmailForm = () => {
  const { email, setEmail, error, handlePress } = useEmailForm();

  return (
    <View>
      <TextInput
        placeholder="Enter your email"
        value={email}
        onChangeText={setEmail}
      />
      {error ? (
        <Text style={{ color: "red" }}>You have not entered an email</Text>
      ) : null}
      <Button title="Submit" onPress={handlePress} testID="submit-button" />
    </View>
  );
};

We’ve extracted the business logic into a custom hook called useEmailForm and separated the view and business logic concerns. The useEmailForm hook returns an object containing the email, setEmail, error, and handlePress variables, which are used in the EmailForm component.

Now, let’s write a test for the useEmailForm hook:

import { renderHook, act } from "@testing-library/react-hooks";
import { useEmailForm } from "./useEmailForm";

describe("useEmailForm", () => {
  it("sets error to true when handlePress is called with no email", () => {
    const { result } = renderHook(() => useEmailForm());
    const { handlePress } = result.current;

    expect(result.current.error).toBe(false);

    act(() => {
      handlePress();
    });

    expect(result.current.error).toBe(true);
  });
});

In this test, we use the renderHook function from @testing-library/react-hooks to render the useEmailForm hook. We then extract the handlePress function from the result.current object returned by renderHook. We expect the initial value of error to be false, call handlePress by passing no email, and expect the value of error to become true. The act function from @testing-library/react-hooks is used to update the hook state and ensure that React has finished the update before the subsequent assertions are made.

Aside: Pure Functions

It’s noteworthy that in places where you can put your logic inside a pure function you should strive to do so. Despite the fact that React Testing Library lets you test hooks in isolation, you still need to use functions like act to handle the rerender/update cycle React hooks have. Testing a pure function removes this complexity and you’d be effectively writing Jest tests.

Tradeoff

There is a tradeoff: there will be some risk by not testing the actual view itself if you’ve not connected your UI elements to the presenter correctly. However, this is usually not where the largest surface area for bugs and regressions exists, and you’ll usually find the most value in testing business logic.

The Role of End-to-End Tests

To counteract some of the risk in regressions existing within UI elements (and the mapping of the presenter layer to the elements), we use E2E tests to make sure that our key user flows are not affected by a bug.

Recently, Maestro has become a popular option for this. Maestro is an E2E mobile testing solution that simplifies the process much more than equivalents like Detox and Appium. It also has a cloud solution that makes it simple to set up in CIs. By incorporating Maestro into our testing strategy, we can ensure that our application is working as expected across all devices and scenarios, and we safeguard against UI view regressions in the main user flows of the application.

Summary

To summarise, we want to find a pragmatic approach that strikes a balance between writing no tests and insisting on 100% test coverage. Our optimal strategy involves moving as much logic as possible out of hard-to-test components/screens and into easier to test parts of the code base (creating Humble Views). With the proposed architecture, developers can focus on writing tests that verify business logic, reducing the efforts required to deal with hard-to-replicate UI components. To offset the risk of bugs in the UI elements, we use E2E tests to cover the main app flows and make sure there aren’t major regressions on the UI elements.

Feel free to reach out

Feel free to reach out to me on Twitter @mo__javad. 🙂

Liked this article?