Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Testing createAsyncThunk Redux Toolkit Jest

I am newbee with the redux toolkit library and especially when it comes to testing. I looked through the documentation and read a bunch of posts and articles in regards to this subject but still struggle. I build a simple todo app and included a couple of API requests to cover asynchronous cases. Testing those turned out to be a bit challenging though. I am hoping to get some advice and feedback on my code and what could be improved. I also wanted some opinions on whether testing the createAsyncThunk slice makes sense or not. NOTE: I am not interested in testing the API calls itself, and use mock data to recreate a successful request.

Constructive criticism is very helpful and would be highly appreciated

Please take a look of one of my slice file and test

postsSlice.ts

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { RootState } from "../../store";
import axios from "axios";

export type Post = {
  userId: number;
  id: number;
  title: string;
  body: string;
};

export type PostsState = {
  posts: Post[];
  loading: boolean;
  error: null | string;
};

export const initalPostState: PostsState = {
  posts: [],
  loading: false,
  error: null,
};

export const fetchAllPosts = createAsyncThunk(
  "posts/allPosts",
  async (data, { rejectWithValue }) => {
    try {
      const response = await axios.get(
        `https://jsonplaceholder.typicode.com/posts`
      );
      return (await response.data) as Post[];
    } catch (err) {
      if (!err.response) {
        throw err;
      }
      return rejectWithValue(err.response.data);
    }
  }
);

export const fetchSuccessful = fetchAllPosts.fulfilled;
export const fetchPending = fetchAllPosts.pending;
export const fetchFailed = fetchAllPosts.rejected;

const postsSlice = createSlice({
  name: "Posts",
  initialState: initalPostState,
  reducers: {},
  extraReducers: (builder) => {
    builder.addCase(fetchSuccessful, (state, { payload }) => {
      state.posts = payload;
      state.loading = false;
    });
    builder.addCase(fetchPending, (state, action) => {
      state.loading = true;
    });
    builder.addCase(fetchFailed, (state, action) => {
      state.error = action.error.message
        ? action.error.message
        : "Failed to load data";
      state.loading = false;
    });
  },
});

export const selectPosts = (state: RootState) => state.fetchedPosts;
export const fetchedPostsReducer = postsSlice.reducer;

Testing

postsSlice.test.ts

import {
  initalPostState,
  fetchPending,
  fetchFailed,
  selectPosts,
  fetchSuccessful,
  fetchedPostsReducer,
} from "./postsSlice";
import { Post, PostsState } from "./postsSlice";
import store, { RootState } from "../../store";

const appState = store.getState();

describe("postsSlice", () => {
  describe("Posts State, Posts Action and Selector", () => {
    it("should set loading state on true when API call is pending", async (done) => {
      // Arrange

      // Act
      const nextState: PostsState = await fetchedPostsReducer(
        initalPostState,
        fetchPending
      );
      // Assert
      const rootState: RootState = { ...appState, fetchedPosts: nextState };
      expect(selectPosts(rootState).loading).toBeTruthy();
      expect(selectPosts(rootState).error).toBeNull();
      done();
    });

    it("should set error state when API call is rejected", async (done) => {
      // Arrange
      const response = {
        message: "Network request failed",
        name: "error",
      };
      // Act
      const nextState: PostsState = await fetchedPostsReducer(
        initalPostState,
        fetchFailed(response, "")
      );
      // Assert
      const rootState: RootState = { ...appState, fetchedPosts: nextState };
      expect(selectPosts(rootState).loading).toBeFalsy();
      expect(selectPosts(rootState).error).not.toBeNull();
      expect(selectPosts(rootState).error).toEqual("Network request failed");
      done();
    });

    it("should update state when API call is successful", async (done) => {
      // Arrange
      const response: Post[] = [
        {
          userId: 1,
          id: 1,
          title:
            "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
          body:
            "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto",
        },
        {
          userId: 1,
          id: 2,
          title: "qui est esse",
          body:
            "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla",
        },
      ];
      // Act
      const nextState: PostsState = await fetchedPostsReducer(
        initalPostState,
        fetchSuccessful(response, "")
      );
      // Assert
      const rootState: RootState = { ...appState, fetchedPosts: nextState };
      expect(selectPosts(rootState).loading).toBeFalsy();
      expect(selectPosts(rootState).error).toBeNull();
      expect(selectPosts(rootState).posts).toEqual(
        expect.arrayContaining(response)
      );
      done();
    });
  });
});
like image 655
Maurice Oppenberger Avatar asked Jun 07 '20 23:06

Maurice Oppenberger


People also ask

How do I test a component with Redux?

The Redux team recommends using React Testing Library (RTL) to test React components that connect to Redux. React Testing Library is a simple and complete React DOM testing utility that encourages good testing practices. It uses ReactDOM's render function and act from react-dom/tests-utils.

How do I test mapStateToProps using jest?

Steps: extract each mapDispatchToProps property as a separate action creator function in another file. extract each mapStateToProps property as a separate selector function in another file. write tests for the selectors and action creators.


Video Answer


1 Answers

I answered already on the GitHub for the redux toolkit, but I will also post here since it was one of the many links I came to before experimenting with my own solution.

Explanation

Since createAsyncThunk returns a function for later execution, you can use this to your advantage. Instead of going through the hassle of testing an entire store's interaction with your thunks, you can test the thunks themselves in isolation away from a store.

Run your jest.mock calls to mock any API/hooks you may be using to access your server or local state, change what those resolve/return, and then execute the method you saved. Doing so gives you access to the promise / method inside your createAsyncThunk call with the argument you would normally call baked in.

Question specific

Instead of testing the store, you want to test that the thunks are dispatching the actions that the store relies on to set things like loading, the error messages you want to save, etc. This way you can create tests for your reducers instead, where you can recreate a brand new store with every test, and ensure that all your transforms via those reducers are correct.


The Thunk

// features/account/thunks.ts

import api from './api';                    // http calls to the API
import { actions } from './reducer';        // "actions" from a createSlice result
import { useRefreshToken } from './hooks';  // a `useSelector(a => a.account).auth?.refreshToken` result

// declare and code as normal
export const register = createAsyncThunk(
  'accounts/register',
  async (arg: IRegisterProps, { dispatch }) => {
    try {
      const data = await api.register(arg);
      dispatch(actions.authSuccess(data));
    } catch (err) {
      console.error('Unable to register', err);
    }
  }
);

// Using a hook to access state
export const refreshSession = createAsyncThunk(
  'accounts/refreshSession',
  async (_, { dispatch }) => {
    // or add `, getState` beside dispatch and do token = getState().accounts.auth.refreshToken;
    // If you use getState, your test will be more verbose though
    const token: string = useRefreshToken();
    try {
      const data = await api.refreshToken(token);
      dispatch(actions.tokenRefreshed(data));
    } catch (err) {
      console.error('Unable to refresh token', err);
    }
  }
);


The Test

// features/account/thunks.test.ts

import apiModule from './api';
import hookModule from './hooks';
import thunks from './thunks';

import { actions } from './reducer';
import { IRegisterProps } from './types';
import { AsyncThunkAction, Dispatch } from '@reduxjs/toolkit';
import { IAuthSuccess } from 'types/auth';

jest.mock('./api');
jest.mock('./hooks')

describe('Account Thunks', () => {
  let api: jest.Mocked<typeof apiModule>;
  let hooks: jest.Mocked<typeof hookModule>

  beforeAll(() => {
    api = apiModule as any;
    hooks = hookModule as any;
  });

  // Clean up after yourself.
  // Do you want bugs? Because that's how you get bugs.
  afterAll(() => {
    jest.unmock('./api');
    jest.unmock('./hooks');
  });

  describe('register', () => {

    // We're going to be using the same argument, so we're defining it here
    // The 3 types are <What's Returned, Argument, Thunk Config>
    let action: AsyncThunkAction<void, IRegisterProps, {}>;
    
    let dispatch: Dispatch;        // Create the "spy" properties
    let getState: () => unknown;

    let arg: IRegisterProps;
    let result: IAuthSuccess;

    beforeEach(() => {
      // initialize new spies
      dispatch = jest.fn();
      getState = jest.fn();

      api.register.mockClear();
      api.register.mockResolvedValue(result);

      arg = { email: '[email protected]', password: 'yeetmageet123' };
      result = { accessToken: 'access token', refreshToken: 'refresh token' };

      action = thunks.registerNewAccount(arg);
    });

    // Test that our thunk is calling the API using the arguments we expect
    it('calls the api correctly', async () => {
      await action(dispatch, getState, undefined);
      expect(api.register).toHaveBeenCalledWith(arg);
    });

    // Confirm that a success dispatches an action that we anticipate
    it('triggers auth success', async () => {
      const call = actions.authSuccess(result);
      await action(dispatch, getState, undefined);
      expect(dispatch).toHaveBeenCalledWith(call);
    });
  });

  describe('refreshSession', () => {
    // We're going to be using the same argument, so we're defining it here
    // The 3 types are <What's Returned, Argument, Thunk Config>
    let action: AsyncThunkAction<void, unknown, {}>;
    
    let dispatch: Dispatch;        // Create the "spy" properties
    let getState: () => unknown;

    let result: IAuthSuccess;
    let existingToken: string;

    beforeEach(() => {
      // initialize new spies
      dispatch = jest.fn();
      getState = jest.fn();

      existingToken = 'access-token-1';

      hooks.useRefreshToken.mockReturnValue(existingToken);

      api.refreshToken.mockClear();
      api.refreshToken.mockResolvedValue(result);

      result = { accessToken: 'access token', refreshToken: 'refresh token 2' };

      action = thunks.refreshSession();
    });

    it('does not call the api if the access token is falsy', async () => {
      hooks.useRefreshToken.mockReturnValue(undefined);
      await action(dispatch, getState, undefined);
      expect(api.refreshToken).not.toHaveBeenCalled();
    });

    it('uses a hook to access the token', async () => {
      await action(dispatch, getState, undefined);
      expect(hooks.useRefreshToken).toHaveBeenCalled();
    });

    // Test that our thunk is calling the API using the arguments we expect
    it('calls the api correctly', async () => {
      await action(dispatch, getState, undefined);
      expect(api.refreshToken).toHaveBeenCalledWith(existingToken);
    });

    // Confirm a successful action that we anticipate has been dispatched too
    it('triggers auth success', async () => {
      const call = actions.tokenRefreshed(result);
      await action(dispatch, getState, undefined);
      expect(dispatch).toHaveBeenCalledWith(call);
    });
  });
});


like image 175
Rachael Dawn Avatar answered Sep 17 '22 23:09

Rachael Dawn