question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Testing createAsyncThunk with multiple dispatch calls

See original GitHub issue

Hello! Ive rewritten old thunks using createAsyncThunk helper.

So Im curious is it able to test like this

const requestSomething1 = createAsyncThunk('something1', (someArgs) => {
 // simple await/return thunk like in example
})

const requestSomething2 = createAsyncThunk('something2', (someArgs, {dispatch}) => {
  const someData = await api.get('url', someArgs)

  await dispatch(requestSomething1(someData.someParam))

  return someData 
})

Since the helper generates requestId on every action and pass it in the meta I can test requestSomething1 like this:


it('some description', async () => {
// some preparations  with redux-mock-store

const { meta: { requestId }} = await mockedStore.dispatch(requestSomething1(someArgs))

const actions = store.getActions()
const expectedActions = [
          requestSomething1.pending(requestId , someArgs),
          requestSomething1.fulfilled(mockedResponsePayload, requestId , someArgs)
          ]
expect(actions).toEqual(expectedActions)
})

So it impossible to do the same for requestSomething2 because I dont know the inner requestId.


const { meta: { requestId }} = await mockedStore.dispatch(requestSomething2(someArgs))

const actions = store.getActions()
const expectedActions = [
          requestSomething2.pending(requestId , someArgs),
          requestSomething1.pending(\* what is here? *\, someArgs),
          requestSomething1.fulfilled(mockedResponsePayload1, \* what is here? *\, someArgs),
          requestSomething2.fulfilled(mockedResponsePayload2, requestId , someArgs)
          ]
expect(actions).toEqual(expectedActions)

The first solution was to add the all requestIds into meta in the body of requestSomething2 but I can return only data and have no access to meta field. And we only have some customization of the payload with rejectWithValue but its for reject only.

Another option is adding the ids into result like return {data, requestIds: {request1: id, request2: id .... }} but I have felt its wrong in different cases

Issue Analytics

  • State:closed
  • Created 3 years ago
  • Reactions:2
  • Comments:8

github_iconTop GitHub Comments

35reactions
rachaeldawncommented, Apr 1, 2021

I realize this was closed, but this was one of the things I stumbled upon that solves the problem and this should help for anyone testing specific thunks.

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.

Hope this helps 🙂. I tried my best to provide as complete of an example as humanly possible for easier reference.


The API

Getting an HTTP Client

// shared/http.ts
// Note: this is actual production code
/**
 * An HTTP Client that does not assume this is an API call. Useful if we are interacting
 * with third party applications manually.
 */
export function useHTTPClient() {
  return axios.create({ headers: commonHeaders });
}

/**
 * An HTTP client that is targeted at the API, but does not use the auth system's header.
 * Useful for login / registration and other api tasks that do not require the user be
 * logged in to succeed.
 */
export function useAPIClientNoAuth(): AxiosInstance {
  // async thunks, we want failures to Promise.reject
  const instance = axios.create({ baseURL, headers: commonHeaders });
  instance.interceptors.response.use(r => r, errorResponseHandler);

  return instance;
}

/**
 * Returns a new axios instance drawing data from the Auth store. If there is no
 * auth data, nothing will be changed.
 */
export function useAPIClient(): AxiosInstance {
  let access: string;
  try {
	// getAccessToken retrieves the JWT needed to access an API
    access = authService.getAccessToken();
  } catch (err) {
    console.warn('Not authenticated, returning noauth');
    return useAPIClientNoAuth();
  }

  const headers: { [key: string]: string } = { ...commonHeaders };
  if (!!access) headers.Authorization = `Bearer ${access}`;

  const instance = axios.create({ headers, baseURL });

  instance.interceptors.response.use(r => r, errorResponseHandler);
  instance.interceptors.request.use(requestInterceptor, e => Promise.reject(e));

  return instance;
}

Use the API clients

// features/account/api.ts
export async function register(arg: IRegisterProps): Promise<IRegistrationSuccess> {
  const client = useAPIClientNoAuth();
  // ...
}

export async function refreshToken(arg: string): Promise<IAuthSuccess> {
  const client = useAPIClient();
  // ...
}

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: 'me@myemail.com', 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 that 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);
    });
  });
});


1reaction
rachaeldawncommented, Apr 1, 2021

How does the Api Look? I’m using axios for api calls and mine looks like this apiPatient.post(endpoint.BANKID_AUTH, pnr)

Updated the original code to make full usage more obvious

Read more comments on GitHub >

github_iconTop Results From Across the Web

Testing createAsyncThunk Redux Toolkit Jest - Stack Overflow
Run your jest.mock calls to mock any API/hooks you may be using to ... createAsyncThunk( 'accounts/refreshSession', async (_, { dispatch }) ...
Read more >
createAsyncThunk - Redux Toolkit
If you need to pass in multiple values, pass them together in an object when you dispatch the thunk, like dispatch(fetchUsers({status: 'active', ...
Read more >
redux-toolkit: test slice and actions - Fabio Marcoccia - Medium
It's signficant to see how the store changes when an action has been dispatched. For example, we have these actions: export const fetchUser...
Read more >
Unit Testing Redux Thunks with a Mock Dispatch Function
For this lesson we're testing a thunk created using Redux Toolkit's createAsyncThunk method. The test should continue work completely untouched even if we...
Read more >
Chaining Asynchronous Dispatches in Redux Toolkit - YouTube
In this video I cover how we can correctly dispatch one action after another, where the second dispatch is informed by the state...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found