Search code examples
typescriptunit-testingreact-hooksjestjsreact-testing-library

How to test custom hook?


My custom hook:

import { useAppDispatch } from "../../app/redux";
import { registrationFormSlice } from "../../entities/registration";

export interface IgetFields {
  label: string;
  valueKey: string;
  setValue: any;
  disabled?: boolean;
}

export const useFields = (): (() => IgetFields[][]) => {
  const dispatch = useAppDispatch();

  const { setEmail } =
    registrationFormSlice.actions;

  const getFields = (): IgetFields[][] => {
    return [
      [
        {
          label: "Email",
          valueKey: "email",
          setValue: (value: string) =>
            dispatch(setEmail({value, error:false, text:''})),
        },
      ],
    ];
  };
  return getFields;
};

My reducer:

import { initialState } from "./initialState";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { IRegistrationState } from "./models";

export const registrationFormSlice = createSlice({
  name: "registrationForm",
  initialState,
  reducers: {
  
    setEmail(
      state: IRegistrationState,
      action: PayloadAction<{ value: string; error: boolean; text: string }>
    ) {
      state.data.email.value = action.payload.value;
      state.data.email.error = action.payload.error;
      state.data.email.errorText = action.payload.text;
    },
  },
});

My Store:

import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { registrationFormSlice } from "./registration";

const rootReducer = combineReducers({
  registrationForm: registrationFormSlice.reducer,
});

export const setupStore = () => {
  return configureStore({
    reducer: rootReducer,
    devTools: process.env.NODE_ENV !== "production",
  });
};

export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore["dispatch"];

My test:

import { renderHook } from "@testing-library/react";
import { useFields } from "./data";
import * as useAppDispatch from "../../app/redux";

describe("useFields", () => {
  const useDispatchMock = jest.spyOn(useAppDispatch, "useAppDispatch");
  useDispatchMock.mockReturnValue(jest.fn());

  const setup = () => {
    const { result } = renderHook(() => useFields());

    return result.current();
  };

  it("Must be an array of arrays", () => {
    const getFields = setup();

    expect(Array.isArray(getFields)).toBeTruthy();
    expect(useDispatchMock).toBeCalled();
  });

  it("An array with index 0 must have certain properties and structure", () => {
    const getFields = setup()[0];

    expect(getFields.length).toBe(1);
    expect(typeof getFields[0].label).toBe("string");
    expect(getFields[0].valueKey).toBe("email");
    expect(typeof getFields[0].setValue).toBe("function");
  });
});

Everything works, the test passes, I don't like only the test coverage:

enter image description here

I do not know how to test this line of code

I tried to check with which argument my useDispatchMock is called:

import { renderHook } from '@testing-library/react';
import { Provider } from 'react-redux';
import { useFields } from './data';
import { setupStore } from '../../entities/store';


describe('useFields', () => {
  const store = setupStore();
  
  const setup = () => {
    const { result } = renderHook(useFields, {
      wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
    });
    return result.current();
  };

  it('Must be an array of arrays', () => {
    const getFields = setup();
    expect(Array.isArray(getFields)).toBeTruthy();
  });

  it('An array with index 0 must have certain properties and structure', () => {
    const getFields = setup()[0];
    expect(getFields).toHaveLength(1);
    expect(getFields[0]).toEqual(
      expect.objectContaining({
        label: expect.any(String),
        valueKey: expect.any(String),
        setValue: expect.any(Function),
      })
    );
  });

  test('should set email value correctly', () => {
    expect.assertions(1);
    const getFields = setup()[0];
    const firstField = getFields[0];
    store.subscribe(() => {
      expect(store.getState().regsitration.data).toEqual({
        email: {
          value: '[email protected]',
          error: false,
          errorText: '',
        },
      });
    });
    firstField.setValue('[email protected]');
  });
});

But I got an error:

enter image description here

Is my approach a bad one? What can I do to get rid of this issue? Any suggestion, link and information is welcome.

Thank you in advance!


Solution

  • Don't mock useAppDispatch which is a hook use the useDispatch hook from the react-redux package. An incorrect mock will break its original implementation. Instead, we can provide a mock store and test if the redux actions are dispatched and redux state is updated correctly.

    E.g.

    registration.ts:

    import { createSlice, PayloadAction } from '@reduxjs/toolkit';
    
    type IRegistrationState = {
      data: {
        email: {
          value: string;
          error: boolean;
          errorText: string | null;
        };
      };
    };
    
    export const registrationFormSlice = createSlice({
      name: 'registrationForm',
      initialState: {
        data: {
          email: {
            value: '',
            error: false,
            errorText: null,
          },
        },
      },
      reducers: {
        setEmail(state: IRegistrationState, action: PayloadAction<{ value: string; error: boolean; text: string }>) {
          state.data.email.value = action.payload.value;
          state.data.email.error = action.payload.error;
          state.data.email.errorText = action.payload.text;
        },
      },
    });
    

    store.ts:

    import { combineReducers, configureStore } from '@reduxjs/toolkit';
    import { useDispatch } from 'react-redux';
    import { registrationFormSlice } from './registration';
    
    const rootReducer = combineReducers({
      registrationForm: registrationFormSlice.reducer,
    });
    
    export const setupStore = () => {
      return configureStore({
        reducer: rootReducer,
        devTools: process.env.NODE_ENV !== 'production',
      });
    };
    
    export type RootState = ReturnType<typeof rootReducer>;
    export type AppStore = ReturnType<typeof setupStore>;
    export type AppDispatch = AppStore['dispatch'];
    export const useAppDispatch: () => AppDispatch = useDispatch;
    

    useFields.ts:

    import { useAppDispatch } from './store';
    import { registrationFormSlice } from './registration';
    
    export interface IgetFields {
      label: string;
      valueKey: string;
      setValue: any;
      disabled?: boolean;
    }
    
    export const useFields = (): (() => IgetFields[][]) => {
      const dispatch = useAppDispatch();
    
      const { setEmail } = registrationFormSlice.actions;
    
      const getFields = (): IgetFields[][] => {
        return [
          [
            {
              label: 'Email',
              valueKey: 'email',
              setValue: (value: string) => dispatch(setEmail({ value, error: false, text: '' })),
            },
          ],
        ];
      };
      return getFields;
    };
    

    useFields.test.tsx:

    import { renderHook } from '@testing-library/react-hooks';
    import React from 'react';
    import { Provider } from 'react-redux';
    import { setupStore } from './store';
    import { useFields } from './useFields';
    
    describe('useFields', () => {
      const store = setupStore();
      const setup = () => {
        const { result } = renderHook(useFields, {
          wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
        });
        return result.current();
      };
    
      it('Must be an array of arrays', () => {
        const getFields = setup();
        expect(Array.isArray(getFields)).toBeTruthy();
      });
    
      it('An array with index 0 must have certain properties and structure', () => {
        const getFields = setup()[0];
        expect(getFields).toHaveLength(1);
        expect(getFields[0]).toEqual(
          expect.objectContaining({
            label: expect.any(String),
            valueKey: expect.any(String),
            setValue: expect.any(Function),
          })
        );
      });
    
      test('should set email value correctly', () => {
        expect.assertions(1);
        const getFields = setup()[0];
        const firstField = getFields[0];
        store.subscribe(() => {
          expect(store.getState().registrationForm.data).toEqual({
            email: {
              value: '[email protected]',
              error: false,
              errorText: '',
            },
          });
        });
        firstField.setValue('[email protected]');
      });
    });
    

    Test result:

     PASS  stackoverflow/76031614/useFields.test.tsx (7.997 s)
      useFields
        ✓ Must be an array of arrays (14 ms)
        ✓ An array with index 0 must have certain properties and structure (3 ms)
        ✓ should set email value correctly (4 ms)
    
    -----------------|---------|----------|---------|---------|-------------------
    File             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
    -----------------|---------|----------|---------|---------|-------------------
    All files        |     100 |      100 |     100 |     100 |                   
     registration.ts |     100 |      100 |     100 |     100 |                   
     store.ts        |     100 |      100 |     100 |     100 |                   
     useFields.ts    |     100 |      100 |     100 |     100 |                   
    -----------------|---------|----------|---------|---------|-------------------
    Test Suites: 1 passed, 1 total
    Tests:       3 passed, 3 total
    Snapshots:   0 total
    Time:        8.462 s, estimated 10 s