Ryan Bourne
Photo of Ryan

10 min read

16th Jun, 2023

Zustand: Writing tests for your data stores

A guide to start introducing tests around your app's Zustand stores

As we've covered before, there are a range of frameworks and libraries that allow us to easily add state-management to our mobile apps. One of these choices is Zustand - an adaptable framework with a hook-based API that, through the implementation of a custom hook, generates a mutable data store. This is helpful, as we can now receive data from other sources, store it in a single location, and then retrieve it where needed. Whilst defined as a hook, when implemented across multiple components only a single instance is used - allowing us to update the store in one location, and have the changes be reflected across our component hierarchy.

In a previous example of:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { create } from 'zustand';

const useIncrementingStore = create((set) => ({
  value: 0,
  increment: () => set((state) => ({ value: state.value + 1 }))
}))

const ComponentOne = () => {
  const value = useIncrementingStore((state) => state.value);
  
  return <Text>{value}</Text>;
}

const ComponentTwo = () => {
  const increment = useIncrementingStore((state) => state.increment);
  
  return <TouchableOpacity onPress={increment}>{...}</TouchableOpacity>;
}

we create a hook of useIncrementingStore - this has the properties of 'value' and 'increment'. When 'increment' is called from ComponentTwo, the value of 'value' increases by one. This is then immediately reflected within ComponentOne, where the value is displayed in a Text element.

Whilst a basic example, it shows how easy it is for us to create and use a Zustand data store. As we mentioned in a previous article, these stores can be expanded with async functions, post-session persistence, and more.

However, we want to be able to assert that the behaviour of our data stores remains consistent as our projects grow. One way we can do this is by introducing some form of automated testing - primarily via unit testing.

"What is unit testing?!"

Unit testing is a process where small parts of a codebase (a 'unit') are tested, ideally in isolation from other units. Whilst this does not allow us to assert how these units act as part of a combined wider system, unit testing allows us to check that with a set of given inputs, a unit behaves as it is expected to.

For our example above, a potential unit test could be "every time increment is called, value increases by one". In this test, we can:

  • assert that the store's initial value is 0
  • call increment, and assert the new value is 1
  • call increment again, and assert that the new value is 2

With these tests in place, if the unit's behaviour was to change (for example, increment instead adding 2 instead of 1), the tests would fail - alerting us to the change in behaviour.

Again whilst a basic example, this theory is the same in more complex units with perhaps more inputs or different types of input: we want to assert that given a set of inputs, the unit behaves as we expect. We can then be made aware if the unit's behaviour ever changes, intentionally or otherwise!

For our Zustand store, this is helpful as we can assert how the functions within our store are expected to function, how they are expected to mutate the data; and if they ever change we can be made aware of this.

"How do I get started with my Zustand testing adventure?!"

As an initial note, in this article we'll discuss adding tests around Zustand in the context of a React Native app using TypeScript. Whilst the theoreticals will be somewhat the same, when implemented against other frameworks and environments there will be some variation. For example, there is a different Jest mock to use if you're not using TypeScript.

The requirements for adding tests around a Zustand store are just as straightforward as the other usages of Zustand we've seen so far.

Firstly, we'll want to add a small mock, as detailed here: https://docs.pmnd.rs/zustand/guides/testing.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {act} from "@testing-library/react-hooks";
import {create as actualCreate, StateCreator} from "zustand";

// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set<() => void>();

// when creating a store, we get its initial state, create a reset function and add it in the set
export const create =
  () =>
  <S>(createState: StateCreator<S>) => {
    const store = actualCreate(createState);
    const initialState = store.getState();
    storeResetFns.add(() => store.setState(initialState, true));

    return store;
  };

// Reset all stores after each test run
beforeEach(() => {
  act(() => storeResetFns.forEach(resetFn => resetFn()));
});

This takes our Zustand store, and upon creation will:

  • create our Zustand store as normal - the functionality & behaviour is not actually changed
  • create a function, that when called will reset our store to it's initial state
  • and finally add this function to storeResetFns

Then, before each test is run, the beforeEach function is called. This will iterate through storeResetFns, calling the function to set our stores back to their initial state. This is beneficial when writing tests, so that for every test we're starting with a consistent initial state.

With this mock added to our __mocks__/zustand.ts file, we can start to write tests around our Zustand store!

Taking our useIncrementingStore Zustand hook, we can easily write tests to assert the initial value, and the value after increment has been called:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import {act, renderHook} from "@testing-library/react-hooks";
import {create} from "zustand";

interface UseIncrementingStoreType {
  value: number;
  increment: () => void;
}

const useIncrementingStore = create<UseIncrementingStoreType>()(set => ({
  value: 0,
  increment: () => set(state => ({value: state.value + 1})),
}));

describe("useIncrementingStore", () => {
  it("value's initial value is 0", () => {
    const {result} = renderHook(() => useIncrementingStore());
    expect(result.current.value).toEqual(0);
  });

  it("every time increment is called, value increases by one", () => {
    const {result} = renderHook(() => useIncrementingStore());
    expect(result.current.value).toEqual(0);

    act(() => result.current.increment());
    expect(result.current.value).toEqual(1);
    act(() => result.current.increment());
    expect(result.current.value).toEqual(2);
  });
});

Let's break this down. First of all, we define our Zustand store hook, useIncrementingStore. With the interface UseIncrementingStoreType defining the contents of our hook, we can create a hook with properties 'value' (which is initially 0) and 'increment' (a function that can read & mutate state to take the current 'value' and add 1 to it).

Then, we define our tests. Our initial test uses "@testing-library/react-hooks"'s renderHook function. This executes our Zustand hook, and returns a 'result' property. The current state of the Zustand hook can be accessed via result.current. With this, we can then check the current value of 'value', and assert that by default, it is 0.

Our second test builds upon the first. We execute the hook, assert the starting value, and then call increment twice. After each call, we assert that the value increases by one.

If we were to then change the behaviour of our Zustand store, perhaps changing increment to add two instead of one, these tests would then fail.

This shows us how straightforward writing tests around a Zustand store can be! A further expansion would be to write tests around a component that uses a Zustand store, such as

1
2
3
4
5
6
7
8
9
10
const ComponentTwo = () => {
  const increment = useIncrementingStore((state) => state.increment);
  const value = useIncrementingStore((state) => state.value);
  
  return (
    <TouchableOpacity onPress={increment}>
      <Text>{value}</Text>
    </TouchableOpacity>
  );
}

We can write a test for this component that asserts when the TouchableOpacity is pressed, the 'value' displayed updates.

We don't need to do anything to make our Zustand store work in our jest environment. There's no need to wrap our components in providers, or add any additional boilerplate - we can just get on with writing the tests!

In conclusion...

Unit testing is an incredibly important part of building software, guaranteeing expected behaviour and safeguarding against unexpected changes. Using Zustand for our state management does not mean we have to compromise on this process, either by not writing tests or by adding additional complex and unwieldy boilerplate code. We can not only write tests directly around our Zustand store, but easily write tests around components and hooks that ingest data from them.

To read more about Zustand, check out their Github repo here.

Next

Looking for more to read? Check out our other posts here!

Want to check out our other projects? Find them here!

Got an idea for a new app to improve our world? Say hi here!

Found this post helpful? Follow the author on LinkedIn!