Redux-toolkit: Testing createAsyncThunk with multiple dispatch calls

Created on 11 Apr 2020  路  6Comments  路  Source: reduxjs/redux-toolkit

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

Most helpful comment

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 :slightly_smiling_face:. 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: '[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 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);
    });
  });
});


All 6 comments

Not sure I have a good answer here. To some extent, I'm not sure I see a lot of value in that test in the first place, for a couple reasons:

  • Testing these thunks is really more testing the createAsyncThunk API itself than your own code
  • Asserting the _entire_ contents of the actions doesn't really get you much benefit anyway.

If you really do feel like you need to test these thunks, perhaps something like:

expect(actions[0].type).toBe(requestSomething1.pending.type);
expect(actions[1].type).toBe(requestSomething1.fulfilled.type);
expect(actions[1].payload).toBe(mockedResponsePayload);

But yeah, I don't see much benefit in trying to write tests like this overall.

So. You are saying I should not test thunks like this?
image

I mean I want to test this thunk to make sure that when the action is called all other data should be fetched too because this is important

I'm not sure I see much benefit in trying to test it in isolation, anyway.

Perhaps consider testing it in conjunction with an actual store instance, and verify that the expected data actually ends up in the store?

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 :slightly_smiling_face:. 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: '[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 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);
    });
  });
});


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

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

Was this page helpful?
0 / 5 - 0 ratings