Testing XState machines in your React Native app

Simone D'Avico
WellD Tech
Published in
6 min readJul 15, 2021

--

This article shows how to test the payment authorisation flow we implemented with XState and React Navigation. We will first test the business logic, and then add some integration-style tests for the navigation screens.

This is the third article of a 3 part series — check out the other parts:

In the first article of this series, we designed a state machine for the payment authorisation flow of a mobile banking app:

The payment authorisation state machine
The payment authorisation state machine

In the second article, we integrated the state machine with React Navigation. The result is a navigation flow that is completely driven by the app state; we watch the state machine's current state for changes and navigate to the corresponding screen.

The storyboard for the payment authorisation flow
The storyboard for the payment authorisation flow

As a bonus, we also split the biometric factor check in its own machine, to keep the code readable and the logic easier to follow.

At this point, we want to write some tests to ensure it all works as expected. First, we will write some tests for the state machine; after that, we will test that the machine is well integrated with the navigation flow.

Approaches to state machine testing

Given a current state, when some sequence of events occurs, the system under test should be in a certain state and/or exhibit a specific output.

To test the state machine, we will follow the guidelines outlined in the XState docs. We can approach these tests in different ways:

  1. Testing effects: we assert that given one or more events some side-effect will be executed. This test can be useful to ensure that effects are fired at the desired time, regardless of what are the states of the machine;
  2. Testing pure logic: we assert that a specific state is reached, given an initial state and an event. This kind of test can be useful to ensure we implemented transitions properly;
  3. Testing services: we assert that the machine eventually reaches an expected state, given an initial state and a sequence of events. I find these tests to be especially useful when the machine implements time-driven logic, such as a timeout.

Setup

The setup for the tests involves mocking the machine services. We do not want to call the implementation of the real services in the tests, as they probably call remote/device’s APIs. Luckily, XState allows configuring a machine instance with a particular set of services. We can exploit this dependency injection mechanism to provide mocks for our tests:

machine = paymentAuthorizationMachine.withConfig({
services: {
checkPrerequisites: /* mock */,
fetchPaymentDetails: /* mock */,
authorizePayment: /* mock */,
}
});

Now we can start writing the actual test cases.

Testing effects

Let’s start by checking that effects are fired as we expect. The strategy is the following:

  • we override the service mocks by invoking withConfig, obtaining a new machine instance;
  • we interpret the machine, getting back a service, and we start the service;
  • we send one or more events, and assert our services were invoked as expected.

The payment authorisation machine service will check some prerequisites at startup (e.g., a biometric factor must be enrolled in the device); if those are met, the flow will start and the machine will fetch the payment details; otherwise, the machine will end up in a final error state. Let’s write a test case for this scenario. Code speaks louder than words:

it(
"should finish with an error state if biometric factor is not enrolled",
async () => {
// arrange
const checkPrerequisites = jest.fn(async () => false);
const machine = paymentAuthorizationMachine.withConfig({
services: {
checkPrerequisites,
...
}
});
const paymentAuthService = interpret(machine);
paymentAuthService.start();
await waitFor(() =>
expect(checkPrerequisites).toHaveBeenCalled()
);

// assert
expect(
paymentAuthService.state.matches("prerequisitesNotMet")
).toBeTrue();
});

We configure the machine with a mock that will fail the prerequisites check; we start the service and ensure our mock gets invoked at machine startup; afterwards, we assert to be in the expected final state.

I am using waitFor from @testing-library/react-native to wait for the async service invocation, but you can also use Jest’s done callback.

Testing pure logic

The setup for this kind of test is the same as the previous one, but this time we actually send an event for the service. For example, the user can dismiss the authorisation modal at all times, and we want to ensure that this is also possible during the initial prerequisites check:

it(
"should finish with a dismissed state if the user dismisses during prerequisites check",
async () => {
// arrange
const machine = paymentAuthorizationMachine.withConfig({
services: {
...
}
});
const paymentAuthService = interpret(machine);
paymentAuthService.start();
// act
paymentAuthService.send("DISMISS");
// assert
expect(
paymentAuthService.state.matches("authorizationDismissed")
).toBeTrue();
});

Testing timers

After successfully fetching the payment details, the user has a limited time to authorise the transaction. After the AUTHORIZATION_EXPIRES delay expires, the machine will transition to a final error state. The expiration time is computed by subtracting the current time from the expiration time returned by the payment details service.

To test the expiration works as expected we can return a stubbed expiration date from the payment details service, wait for the required amount of time for the authorisation to expire, and check that we are in the desired state:

it(
"should finish with an error state if the authorization expires",
async () => {
// arrange
const checkPrerequisites = jest.fn(async () => false);
// make the authorization expire after 1 second
const fetchPaymentDetails = jest.fn(
async () => ({ expiresOn: addSeconds(new Date(), 1) })
);
const machine = paymentAuthorizationMachine.withConfig({
services: {
checkPrerequisites,
fetchPaymentDetails,
...
}
});
const paymentAuthService = interpret(machine);
paymentAuthService.start();
jest.runAllTimers(); // assert
await waitFor(() =>
expect(
paymentAuthService.state.matches("authorizationExpired")
).toBeTrue();
);
});

These two test cases definitely do not test the machine exhaustively but should give an idea of the approach to use. We can now take a look at how to test the navigation.

Testing navigation

Even though we *extensively* tested the state machine and we are confident it works, we still need to make sure it is well integrated with the navigation, i.e. when the machine status changes, the navigation changes accordingly.

To do so, we write integration-style tests that render the whole payment authorization flow navigation stack. We will use testing-library API to simulate user interactions and check what gets rendered on screen. React Native Testing Library docs have a step by step instructions for setting up React Navigation tests.

N.B: The implementation I shared in the previous blog post is not super easy to test, because we create and configure the machine directly in the PaymentAuthorizationScreen component. We should refactor it to stub the external services more easily; one way to do so is to receive the services as props. I will leave this as an exercise to the reader :)

Let’s write a test that ensures we see a loading indicator while the prerequisites are being checked and the authorisation is being fetched:

describe("Payment Authorisation Navigation Flow", () => {
it("should render a loading indicator when opened", async () => {
//arrange
const services = { /* machine services stubs */}
const component = (
<NavigationContainer>
<PaymentAuthorizationScreen {...services} />
</NavigationContainer>

);
const { queryByRole } = render(component); // assert
expect(queryByRole("progressbar")).toBeTruthy();
});
});

We can also test that we see the correct screen when the authorisation is declined:

describe("Payment Authorisation Navigation Flow", () => {
it("should render a declined message when user declines", async () => {
// arrange
const services = { /* machine services stubs */}
const component = (
<NavigationContainer>
<PaymentAuthorizationScreen {...services} />
</NavigationContainer>

);
const { getByText, queryByText } = render(component); // wait for authorization fetch
await waitFor(() =>
expect(queryByText('DISMISS')).toBeTruthy()
);
// act
fireEvent.press(getByText("DISMISS"));
// assert
await waitFor(() =>
expect(
queryByText("Payment authorization has been declined")
).toBeTruthy()
);
});
});

We can test the whole flow by stubbing the services as needed, and interacting with the screens as a real user would. Neat!

Wrapping up

In this article, we added tests to the payment authorisation flow. We were able to test the business logic in isolation from the view layer. XState API allowed us to easily configure each test scenario by configuring stubbed services on the machine. In addition, we made sure the business logic is well integrated with the app screen, by writing integration tests that resemble user interactions with the app.

Further reading

--

--

Simone D'Avico
WellD Tech

Software Engineer with a passion for programming languages