Testing

This tutorial will cover some (not all) testing strategies that you could (not should) adopt. Of course, as with everything, the best testing strategy will depend on your specific use case and needs.

Testing actions

Actions are relatively simple to test as they are essentially an immutable update to the store. We can therefore compare the prev and updated state to assert that our actions have had the effect against our store's state.

Given the following model under test.

import { action } from 'easy-peasy';

const todosModel = {
  items: {},
  add: action((state, payload) => {
    state.items[payload.id] = payload;
  }),
};

We could test it like so.

test('add todo action', async () => {
  // arrange
  const todo = { id: 1, text: 'foo' };
  const store = createStore(todosModel);

  // act
  store.getActions().add(todo);

  // assert
  expect(store.getState().items).toEqual({ [todo.id]: todo });
});

Testing thunks

Thunks are more complicated to test than actions as they can perform side effects, such as invoking network requests, and they can additionally dispatch other actions or thunks.

There are also 2 different strategies at testing thunks:

  1. Mock actions dispatched by your thunks and assert that the expected thunks were called with the expected payloads
  2. Allow thunks to execute naturally, asserting the state changes that may have occurred due to the actions being dispatched by your thunk

Each strategy has it's own merits and a pragmatic approach should be taken to deciding which strategy would provide the most value on a case by case basis.

Within either of these strategies your thunks may perform side effects such as making network requests. We highly recommend that you encapsulate these side effects within modules that are then exposed to your store via the injections configuration property of the store. Doing this will allow you to inject mocked versions of your services when you are testing your thunks.

Strategy 1: mocking actions

The createStore API contains a configuration property named mockActions, which if set to true, will ensure that any action that is dispatched will not be executed, and will instead be recorded - along with their payloads. You can then access the recorded actions via the getMockedActions function that is available on the store instance.

We took inspiration for this strategy from the awesome redux-mock-store(opens new window) package.

Given the following model under test:

import { action, thunk } from 'thunk';

const todosModel = {
  items: {},
  fetchedTodo: action((state, payload) => {
    state.items[payload.id] = payload;
  }),
  fetchById: thunk(async (actions, payload, { injections }) => {
    const { todosService } = injections;
    const todo = await todosService.fetchById(payload);
    actions.fetchedTodo(todo);
  }),
};

We could test the fetchById thunk like so:

import { createStore, actionName } from 'easy-peasy';

const createMockTodosService = (result) =>
  jest.fn(() => Promise.resolve({ json: () => Promise.resolve(response) }));

test('fetchById', async () => {
  // arrange
  const todo = { id: 1, text: 'Test my store' };
  const mockTodosService = {
    fetchById: jest.fn(() => Promise.resolve(todo)),
  };
  const store = createStore(todosModel, {
    injections: { todosService: mockTodosService },
    mockActions: true,
  });

  // act
  await store.getActions().fetchById(todo.id);

  // assert
  expect(mockTodosService.fetchById).toHaveBeenCalledWith(todo.id);
  expect(store.getMockedActions()).toEqual([
    { type: '@thunk.fetchById(start)', payload: todo.id },
    { type: '@action.fetchedTodo', payload: todo },
    { type: '@thunk.fetchById(success)', payload: todo.id },
    { type: '@thunk.fetchById', payload: todo.id },
  ]);
});

Strategy 2: executing naturally

Within the below tests we will not be mocking any actions. i.e. we will allow thunks to execute naturally. This means that any actions that are called within a thunk will be executed.

This provides more of an integration test as you are crossing boundaries, executing actions outside of your thunk.

You would then generally make two different types of assertions within this strategy:

  1. Were the mocked injections called as expected?
  2. Did the state get updated in the expected manner?
test('fetchById', async () => {
  // arrange
  const todo = { id: 1, text: 'Test my store' };
  const mockTodosService = {
    fetchById: jest.fn(() => Promise.resolve(todo)),
  };
  const store = createStore(todosModel, {
    injections: { todosService: mockTodosService },
  });

  // act
  await store.getActions().fetchById(todo.id);

  // assert
  expect(mockTodosService.fetchById).toHaveBeenCalledWith(todo.id);
  expect(store.getState()).toEqual({
    items: {
      1: todo,
    },
  });
});

Testing computed properties

Computed properties are simply the result of a derive process applied to existing state. Therefore one strategy would be to create versions of your store with the initialState defined. You could then verify that the expected values are derived by your computed properties.

Given the following model under test.

import { computed } from 'easy-peasy';

const todosModel = {
  items: {},
  count: computed((state) => Object.keys(state.items).length),
};

We could test it like so.

test('"count" is 0 when there are no items', async () => {
  // act
  const store = createStore(todosModel);

  // assert
  expect(store.getState().count).toEqual(0);
});

Utilising initialState

You can also utilise the initialState configuration property of stores in order to preload some initial state, which would allow you to wider testing of your computed properties.

test('"count" is 2 when there are 2 items', async () => {
  // act
  const store = createStore(todosModel, {
    // utilise initialState to preload our state
    initialState: {
      items: {
        1: 'foo',
        2: 'bar',
      },
    },
  });

  // assert
  expect(store.getState().count).toEqual(2);
});

Testing listeners

When testing your listeners there are two types of tests that you can perform.

  1. Do they execute when the configured target(s) execute?
  2. Does the listener perform the expected?

Testing if listeners execute in response to target(s)

For this case we recommend making use of the mockAction configuration value that is available on the createStore configuration. When this is set then any actions that are dispatched will not be executed, instead they will be recorded, along with their payloads.

You can utilise the getMockedActions function that is bound against your store instance to get the recorded actions, validating that they are what you expect.

This is perfect for us to establish that our listener was fired when a target executed.

We will be showing an actionOn listener within this test, however, this strategy would work equally well for a thunkOn listener.

Given the following model.

import { action, actionOn } from 'easy-peasy';

const model = {
  todos: [],
  logs: [],
  addTodo: action((state, payload) => {
    state.todos.push(payload);
  }),
  onTodoAdded: actionOn(
    (actions) => actions.addTodo,
    (state, target) => {
      state.logs.push(`Added todo: ${target.payload}`);
    },
  ),
};

We could test the onTodoAdded action.

test('listener gets dispatched when target fires', () => {
  // arrange
  const store = createStore(model, {
    mockActions: true,
  });

  // act
  store.getActions().addTodo('Write docs');

  // assert
  expect(store.getMockedActions()).toMatchObject([
    { type: '@action.addTodo', payload: 'Write docs' },
    {
      type: '@action.onTodoAdded',
      payload: {
        type: '@action.addTodo',
        payload: 'Write docs',
      },
    },
  ]);
});

Testing if the listener performs as expected

We may also want to test that our listeners perform the expected. It is possible to dispatch our listeners manually by using the store.getListeners() API.

When dispatch a listener action it is important to note that a very specific payload structure is expected. This payload becomes the target argument to the listener handler.

Below is an overview of the payload object that you need to provide when manually dispatching a listener action:

  • type (string)

    The type of the target action being responded to. e.g. "@actions.todos.addTodo"

  • payload (any)

    This will contain the same payload of the target action being responded to.

  • result (any | null)

    When listening to a thunk, if the thunk succeeded and returned a result, the result will be contained within this property.

  • error (Error | null)

    When listening to a thunk, if the thunk failed, this property will contain the Error.

  • resolvedTargets (Array<string>)

    An array containing a list of the resolved targets, resolved by the targetResolver function. This aids in performing target based logic within a listener handler.

You need not provide all the values if you know that your listener only uses some of them. You could instead only populate the parts of the target object that you expect your listener to be using.

For example, below we will manually dispatch a listener, providing only the payload.

store.getListeners().onTodoAdded({
  payload: 'Write docs on testing',
});

Once you take these rules into account, you could then follow a similar strategy to testing actions for actionOn listeners. Equally, you can follow a similar strategy to testing thunks for thunkOn listeners.

Below we will show an example of how you could test the onTodoAdded action that we described within the model above.

test('onTodoAdded adds a log entry', () => {
  // arrange
  const store = createStore(model);

  // act
  store.getListeners().onTodoAdded({
    payload: 'Test listeners',
  });

  // assert
  expect(store.getState().logs).toEqual(['Added todo: Test listeners']);
});

Testing components

When testing your components I strongly recommend the approach recommended by Kent C. Dodd's awesome Testing Javascript(opens new window) course, where you try to test the behaviour of your components using a natural DOM API, rather than reaching into the internals of your components.

He has published a very useful package by the name of @testing-library/react(opens new window) which allows us to follow this paradigm whilst providing very useful mechanisms by which to interact with the DOM created by our React components.

Imagine we were trying to test the following component.

function Counter() {
  const count = useStoreState((state) => state.count);
  const increment = useStoreActions((actions) => actions.increment);
  return (
    <div>
      Count: <span data-testid="count">{count}</span>
      <button type="button" onClick={increment}>
        +
      </button>
    </div>
  );
}

As you can see it is making use of our hooks to gain access to state and actions of our store.

We could adopt the following strategy to test it.

import { render } from '@testing-library/react';
import { createStore, StoreProvider } from 'easy-peasy';
import model from './model';

test('Counter', () => {
  // arrange
  const store = createStore(model);
  const app = (
    <StoreProvider store={store}>
      <ComponentUnderTest />
    </StoreProvider>
  );

  // act
  const { getByTestId, getByText } = render(app);

  // assert
  expect(getByTestId('count').textContent).toEqual('0');

  // act
  fireEvent.click(getByText('+'));

  // assert
  expect(getByTestId('count').textContent).toEqual('1');
});

As you can see we create a store instance in the context of our test and wrap the component under test with the StoreProvider. This allows our component to act against our store.

We then interact with our component using the DOM API exposed by the render.

This grants us great power in being able to test our components with a great degree of confidence that they will behave as expected.

Utilising initialState to predefine state

It is also to preload your store with some state by utilising the initialState configuration property of the store. This may help you test specific conditions of your component.

test('Counter', () => {
  // arrange
  const store = createStore(model, { initialState: initialStateForTest });

  // ...
});

Mocking calls to services

If your thunks make calls to external services we recommend encapsulating these services within a module and then exposing them to your store via the injection configuration property of the store. Doing this will allow you to easily inject mock versions of your services when testing them.

test('saving a todo', () => {
  // arrange
  const mockTodoService = {
    save: jest.fn(),
  };
  const store = createStore(model, {
    injections: {
      todoService: mockTodoService,
    },
  });

  // ...
});