image credit: Austin Neill

How to Write Unit Tests for Asynchronous Redux Thunks in Five Easy Steps

last updated: Aug 15th, 2019

Redux React

Suppose we have a basic asynchronous login thunk, such as in the code snippet below.

loginThunk.ts

import { Dispatch } from 'redux';import { loginRequest, loginFailure, loginSuccess } from 'actions/sessionActions';import { login } from 'api/api';import { log } from 'util/log';export const loginThunk = (username: string, password: string) => async (dispatch: Dispatch) => {    log.debug(`Login attempt for user ${username}`);    dispatch(loginRequest());    try {        const { userId, sessionId } = await login(username, password);        log.info(`Login succeeded: ${userId} in session ${sessionId}`);        dispatch(loginSuccess({ userId, sessionId }));    }    catch (err) {        log.error(`Login failed: ${err}`);        dispatch(loginFailure(err));    }};

The thunk takes a username and a password; it returns an asynchronous function that takes the Redux store dispatch method. This is the same basic pattern that all redux-thunk thunks have.

This might look intimidating to test because of the indirection and async keyword. But I'll show you how you can easily test the behaviour of thunks like this.

Step 1: Analyze dependencies

The first thing I'd do is create an empty shell of a test file for this method:

loginThunk.test.ts

describe('loginThunk', () => {});

How I approach filling in the tests depends on whether I'm writing the tests first or the code first. In this case, the code is already given. So I look at what dependencies the thunk has and create tests that mimic the possible outcomes of using those dependencies.

Specifically, in our loginThunk, we have a dependency on log and login.

Notice, we also have a dependency on Dispatch (from Redux) and loginRequest, loginFailure, and loginSuccess from 'sessionActions' (another file from the same project).

We're not going to test Dispatch here because it is from a third-party package and we can assume it has already been tested.

Maintaining your own testing of third-party components can be a useful strategy, but only when done in isolation. In other words, don't mix with tests for your own code.

As for the three action creators: loginRequest, loginFailure, and loginSuccess... I am treating them as part of the system under test here. Perhaps that means this is really an integration test. The distiction isn't really that important for this code.

Step 2: Define expected behaviours

The login API call is the important piece in this thunk. We want to test the behaviour of the thunk for each possible outcome from calling login().

Suppose for this exercise that the API layer guarantees to either return a valid result or throw an Error. Those two outcomes are the things we need to validate. Namely, does the login thunk correctly report success when the login method succeeds, and does it report failure when the login method fails? We might also want to verify that the thunk reports that the login attempt began (because presumably, somewhere in the UI, we expect a busy indicator like a spinner to start spinning)

In the next snippet, I've filled in the test conditions and names only. Right now it almost reads like a requirements document.

loginThunk.test.ts

describe('loginThunk', () => {    it('dispatches a login request');    describe('when login succeeds', () => {        it('dispatches success');    });    describe('when login fails', () => {        it('dispatches failure');    });});

I find that working in this way makes it easier to ensure that all the requirements are covered -- because repeated context-switching between requirements mode and implementation mode makes it difficult to focus. It's much easier to focus on all the use cases / behaviours and brain dump all the requirements at once; and then switch to implementing the tests.

In addition to being easier to focus, it's literally easier to see all the requirements because there are no lengthy implementations taking up lines in your IDE.

Although most IDEs will let you collapse blocks. Look in the left margin.

Step 3: Mock dependencies

Unit tests are supposed to be light and fast. We don't want to call the a real login API method. So we'll use Jest's mocking feature to provide a fake method. Aside from being faster and not requiring real resources, we can control the return value from the mock implementation, which allows us to validate all possible branches through our code.

Mocking the login dependency

Here we will mock the login API method:

// import { login } from 'api/api'; // Don't do thisconst login: jest.Mock = require('api/api').login; // Do thisjest.mock('api/api', () => ({    login: jest.fn(),}));

It's important to not import the method as you normally would in a normal TypeScript file because we want the type signature to be jest.Mock rather than the real signature.

Having the mock be of type jest.Mock means we'll get proper IDE integration (e.g. Intellisense). This is much easier to work with.

So I'm using the older require() syntax, which confers an any type and then we coerce to type jest.Mock.

And then the rest of the code snippet sets up the mock through Jest.

Mocking the log dependency

The log calls are harmless in this case -- they just log to the console. But perhaps future code might write them to a database or send them to a monitoring service, etc. Plus, it's really annoying to see log messages interspersed with unit test output.

So let's go ahead and mock these out too.

jest.mock('util/log', () => ({    log: {        debug: jest.fn(),        info: jest.fn(),        error: jest.fn(),    },}));

Notice that we didn't need to import or require anything for the log method. It has no return value and is assumed to never throw an Error; it's purely "fire and forget". So we aren't going to be testing anything relating to its behaviour.

Setting up the mock behaviour

We can start filling in the test implemenations now that we've got the expected behaviours defined and the mocks are ready to go. As you can see below, I've configured the mock behaviour for each context.

Mocha allows the use of context() as a synonym for describe() but it appears that Jest/Jasmine doesn't, which is unfortunate.

describe('loginThunk', () => {    it('dispatches a login request');    describe('when login succeeds', () => {        beforeEach(() => {            login.mockResolvedValue({ userId: 'foo', sessionId: 'bar' });        });        it('dispatches success');    });    describe('when login fails', () => {        const error = new Error('FAIL!');        beforeEach(() => {            login.mockRejectedValue(error);        });        it('dispatches failure');    });});

We use beforeEach within each context block to ensure that every test in that block gets the correct mock implementation.

Notice that I don't explicitly set a mock implementation for the first test. I did that because that test tests something that happens before the login method is called. Essentially, I don't care what the mock implementation is -- as long as it is some mock implementation. But keep in mind that this could cause you trouble depending on your implementation (e.g. you could see failed tests if you didn't have exception handling and your mock throws an Error)

Step 4: Implement the tests

Our thunk is promise based. So be sure to use an async function for the implementation and await for the call to the thunk.

Test One: Dispatching the login request

    it('dispatches a login request', async () => {        const dispatch = jest.fn();        await loginThunk('username', 'password')(dispatch);        expect(dispatch).toHaveBeenCalledWith(loginRequest());    });

First we use Jest to create a spy for the dispatch function. This will let us easily determine what actions were dispatched.

loginThunk('username', 'password') returns the thunk action, which has the signature (dispatch: Dispatch) => Promise<void>. Then the test passes in the dispatch spy and waits for the promise to resolve or reject.

Once the promise has resolved (i.e. once the thunk has completed) the test then validates that the dispatch method received a call with a loginRequest action.

Test Two: When login succeeds

Next, fill in the second test.

    describe('when login succeeds', () => {        beforeEach(() => {            login.mockResolvedValue({ userId: 'foo', sessionId: 'bar' });        });        it('dispatches success', async () => {            const dispatch = jest.fn();            await loginThunk('username', 'password')(dispatch);            expect(dispatch).toHaveBeenLastCalledWith(loginSuccess({ userId: 'foo', sessionId: 'bar' }));        });    });

The only difference, aside from the beforeEach setup, is that we're testing for a different action at the end. Make sure that loginSuccess is dispatched with the correct values from the (mock) API response.

Test Three: When login fails

Finally, implement the last unit test.

    describe('when login fails', () => {        const error = new Error('FAIL!');        beforeEach(() => {            login.mockRejectedValue(error);        });        it('dispatches failure', async () => {            const dispatch = jest.fn();            await loginThunk('username', 'password')(dispatch);            expect(dispatch).toHaveBeenLastCalledWith(loginFailure(error));        });    });

Here we're making sure that the loginFailure action was dispatched with the error.

Step Five: Run, fix errors, and repeat

Saving your file will kick off a new test run if you've already got Jest running.

Otherwise, bring up a terminal window

Press Ctrl backtick in VSCode (or Ctrl Shift backtick for a new terminal window)

and run the tests

> npm run test

Your output should look like this (assuming you're working with code that was already implemented):

PASS  src/thunks/loginThunk.test.ts  loginThunkdispatches a login request (7ms)    when login succeedsdispatches success (1ms)    when login failsdispatches failure (1ms)Test Suites: 1 passed, 1 totalTests:       3 passed, 3 totalSnapshots:   0 totalTime:        2.412sRan all test suites matching /loginThunk/i.Watch Usage: Press w to show more.

Step 6: (if necessary) Implement the code

Yeah... I know I said "Five Easy Steps". For this example we already had the production code fully implemented.

However, when doing TDD, you typically would write the tests before the production code. In that case, you'd expect to have all failing tests at this point. So now you'd go implement the production code and iterate until all the tests pass.

Bonus Content: Download the code

Want to play with the code from the article?

Too lazy to copy-paste the content?

No Worries! I've got your back.

The full source code including thunk, test, and dependencies is available for download exclusively by mailing-list members. Also as a list member you'll get articles about modern web development topics like React, Redux, TypeScript, etc. emailed to you occasionally.

Level up Your React + Redux + TypeScript

for free with articles, tutorials, sample code, and Q&A.