Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to test react useContext useReducer dispatch in component

hope someone can point me the right direction with this. Basically I've created a react app which makes use of hooks, specifically useContext, useEffect and useReducer. My problem is that I can't seem to get tests to detect click or dispatch events of the related component.

Stripped down version of my app can be found at : https://github.com/atlantisstorm/hooks-testing Tests relate to layout.test.js script.

I've tried various approaches, different ways of mocking dispatch, useContext, etc but no joy with it. Most recent version.

layout.test.js

import React from 'react';
import { render, fireEvent } from "@testing-library/react";
import Layout from './layout';
import App from './app';
import { Provider, initialState } from './context';

const dispatch = jest.fn();

const spy = jest
  .spyOn(React, 'useContext')
  .mockImplementation(() => ({
    state: initialState,
    dispatch: dispatch
}));

describe('Layout component', () => {
  it('starts with a count of 0', () => {
    const { getByTestId } = render(
      <App>
        <Provider>
          <Layout />
        </Provider>
      </App>
    );

    expect(dispatch).toHaveBeenCalledTimes(1);

    const refreshButton = getByTestId('fetch-button');

    fireEvent.click(refreshButton);

    expect(dispatch).toHaveBeenCalledTimes(3);
  });
});

layout.jsx

import React, { useContext, useEffect } from 'react';
import { Context } from "./context";

const Layout = () => {
  const { state, dispatch } = useContext(Context);
  const { displayThings, things } = state;

  const onClickDisplay = (event) => {
    // eslint-disable-next-line
    event.preventDefault;
    dispatch({ type: "DISPLAY_THINGS" });
  };

  useEffect(() => {
    dispatch({ type: "FETCH_THINGS" });
  }, [displayThings]);

  const btnText = displayThings ? "hide things" : "display things";
  return (
    <div>
        <button data-testid="fetch-button" onClick={onClickDisplay}>{btnText}</button>
        { displayThings ? 
            <p>We got some things!</p>
          :
            <p>No things to show!</p>
        }
        { displayThings && things.map((thing) =>
            <p>{ thing }</p>
        )}
    </div>
  )
}

export default Layout;

app.jsx

import React from 'react';
import Provider from "./context";
import Layout from './layout';
const App = () => {
  return (
    <Provider>
      <Layout />
    </Provider>
  )
}

export default App;

context.jsx

import React, { createContext, useReducer } from "react";
import { reducer } from "./reducer";

export const Context = createContext();

export const initialState = {
  displayThings: false,
  things: []
};

export const Provider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <Context.Provider value={{ state, dispatch }}>
      {children}
    </Context.Provider>
  );
};

export default Provider;

reducer.jsx

export const reducer = (state, action) => {
  switch (action.type) {
    case "DISPLAY_THINGS": {
      const displayThings = state.displayThings ? false : true; 
      return { ...state, displayThings };
    }

    case "FETCH_THINGS": {
      const things = state.displayThings ? [
          "thing one",
          "thing two"            
      ] : [];
      return { ...state, things };
    }

    default: {
      return state;
    }
  }
};

I'm sure the answer will be easy when I see it, but just trying to figure out I can detect the click event plus detect the 'dispatch' events? (I've already got separate test in the main app to properly test dispatch response/actions)

Thank you in advance.

EDIT Ok, I think I've got a reasonable, though not perfect, solution. First I just added optional testDispatch and testState props to the context.jsx module.

new context.jsx

import React, { createContext, useReducer } from "react";
import { reducer } from "./reducer";

export const Context = createContext();

export const initialState = {
  displayThings: false,
  things: []
};

export const Provider = ({ children, testDispatch, testState }) => {
  const [iState, iDispatch] = useReducer(reducer, initialState);

  const dispatch = testDispatch ? testDispatch : iDispatch;
  const state = testState ? testState : iState;
  return (
    <Context.Provider value={{ state, dispatch }}>
      {children}
    </Context.Provider>
  );
};

export default Provider;

Then in layout.test.jsx I just simply pass in mocked jest dispatch function plus state as necessary. Also removed the outer App wrapping as that seemed to prevent the props from being passed through.

new layout.test.jsx

import React from 'react';
import { render, fireEvent } from "@testing-library/react";
import Layout from './layout';
import { Provider } from './context';

describe('Layout component', () => {
  it('starts with a count of 0', () => {
    const dispatch = jest.fn();
    const state = {
      displayThings: false,
      things: []
    };
    const { getByTestId } = render(
      <Provider testDispatch={dispatch} testState={state}>
        <Layout />
      </Provider>
    );

    expect(dispatch).toHaveBeenCalledTimes(1);
    expect(dispatch).toHaveBeenNthCalledWith(1, { type: "FETCH_THINGS" });

    const refreshButton = getByTestId('fetch-things-button');
    fireEvent.click(refreshButton);

    expect(dispatch).toHaveBeenCalledTimes(2);
    // Important: The order of the calls should be this, but dispatch is reporting them 
    // the opposite way around in the this test, i.e. FETCH_THINGS, then DISPLAY_THINGS... 
    //expect(dispatch).toHaveBeenNthCalledWith(1, { type: "DISPLAY_THINGS" });
    //expect(dispatch).toHaveBeenNthCalledWith(2, { type: "FETCH_THINGS" });
   
    // ... so as dispatch behaves correctly outside of testing for the moment I'm just settling for
    // knowing that dispatch was at least called twice with the correct parameters.
    expect(dispatch).toHaveBeenCalledWith({ type: "DISPLAY_THINGS" });
    expect(dispatch).toHaveBeenCalledWith({ type: "FETCH_THINGS" });

  });
});

One little caveat though, as noted above, when the 'fetch-things-button' was fired, it reported the dispatch in the wrong order. :/ So I just settled for knowing the correct calls where triggered, but if anyone knows why the call order isn't as expected I would be pleased to know.

https://github.com/atlantisstorm/hooks-testing update to reflect the above if anyone is interested.

like image 209
Noelt Avatar asked Jul 28 '20 20:07

Noelt


People also ask

How do I use useReducer and useContext?

Instead of passing the props down through each component, React Context allows you to broadcast props to the components below. The useReducer hook is used for complex state manipulations and state transitions. … useReducer is a React hook function that accepts a reducer function, and an initial state.

What is Dispatch in useReducer in React?

Conclusion. The useReducer() hook in React lets you separate the state management from the rendering logic of the component. const [state, dispatch] = useReducer(reducer, initialState) accepts 2 argument: the reducer function and the initial state.

How do you use useContext outside a component?

To access a React context outside of the render function, we can use the useContext hook. We create the UserContext by calling the React. createContext method with a default context value. Then in the Users component, we call the useContext hook with UserContext to accxess the current value of UserContext .


Video Answer


1 Answers

A few months back I was also trying to write unit tests for reducer + context for an app. So, here's my solution to test useReducer and useContext.

FeaturesProvider.js

    import React, { createContext, useContext, useReducer } from 'react';

    import { featuresInitialState, featuresReducer } from '../reducers/featuresReducer';

    export const FeatureContext = createContext();

    const FeaturesProvider = ({ children }) => {
      const [state, dispatch] = useReducer(featuresReducer, featuresInitialState);

      return <FeatureContext.Provider value={{ state, dispatch }}>{children}</FeatureContext.Provider>;
    };

    export const useFeature = () => useContext(FeatureContext);

    export default FeaturesProvider;

FeaturesProvider.test.js

    import React from 'react';
    import { render } from '@testing-library/react';
    import { renderHook } from '@testing-library/react-hooks';
    import FeaturesProvider, { useFeature, FeatureContext } from './FeaturesProvider';

    const state = { features: [] };
    const dispatch = jest.fn();

    const wrapper = ({ children }) => (
      <FeatureContext.Provider value={{ state, dispatch }}>
        {children}
      </FeatureContext.Provider>
    );

    const mockUseContext = jest.fn().mockImplementation(() => ({ state, dispatch }));

    React.useContext = mockUseContext;

    describe('useFeature test', () => {
      test('should return present feature toggles  with its state and dispatch function', () => {
        render(<FeaturesProvider />);
        const { result } = renderHook(() => useFeature(), { wrapper });

        expect(result.current.state.features.length).toBe(0);
        expect(result.current).toEqual({ state, dispatch });
      });
    });

featuresReducer.js

    import ApplicationConfig from '../config/app-config';
    import actions from './actions';

    export const featuresInitialState = {
      features: [],
      environments: ApplicationConfig.ENVIRONMENTS,
      toastMessage: null
    };

    const { INITIALIZE_DATA, TOGGLE_FEATURE, ENABLE_OR_DISABLE_TOAST } = actions;

    export const featuresReducer = (state, { type, payload }) => {
      switch (type) {
        case INITIALIZE_DATA:
          return {
            ...state,
            [payload.name]: payload.data
          };

        case TOGGLE_FEATURE:
          return {
            ...state,
            features: state.features.map((feature) => (feature.featureToggleName === payload.featureToggleName
              ? {
                ...feature,
                environmentState:
                  { ...feature.environmentState, [payload.environment]: !feature.environmentState[payload.environment] }
              }
              : feature))
          };

        case ENABLE_OR_DISABLE_TOAST:
          return { ...state, toastMessage: payload.message };

        default:
          return { ...state };
      }
    };

featuresReducer.test.js

import { featuresReducer } from './featuresReducer';
import actions from './actions';

const { INITIALIZE_DATA, TOGGLE_FEATURE, ENABLE_OR_DISABLE_TOAST } = actions;

describe('Reducer function test', () => {
  test('should initialize data when INITIALIZE_DATA action is dispatched', () => {
    const featuresState = {
      features: []
    };

    const action = {
      type: INITIALIZE_DATA,
      payload: {
        name: 'features',
        data: [{
          featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState: { sit: true, replica: true, prod: false }
        }]
      }
    };

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual({
      features: [{
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState: { sit: true, replica: true, prod: false }
      }]
    });
  });

  test('should toggle the feature for the given feature and environemt when TOGGLE_FEATURE action is disptched', () => {
    const featuresState = {
      features: [{
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState: { sit: true, replica: true, prod: false }
      }, {
        featureId: '23458', featureName: 'WO photo download', featureToggleName: '23458_WOPhotoDownload', environmentState: { sit: true, replica: true, prod: false }
      }]
    };

    const action = {
      type: TOGGLE_FEATURE,
      payload: { featureToggleName: '23456_WOPhotoDownload', environment: 'sit' }
    };

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual({
      features: [{
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState: { sit: false, replica: true, prod: false }
      }, {
        featureId: '23458', featureName: 'WO photo download', featureToggleName: '23458_WOPhotoDownload', environmentState: { sit: true, replica: true, prod: false }
      }]
    });
  });

  test('should enable the toast message when ENABLE_OR_DISABLE_TOAST action is dispatched with the message as part of payload', () => {
    const featuresState = {
      toastMessage: null
    };

    const action = {
      type: ENABLE_OR_DISABLE_TOAST,
      payload: { message: 'Something went wrong!' }
    };

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual({ toastMessage: 'Something went wrong!' });
  });

  test('should disable the toast message when ENABLE_OR_DISABLE_TOAST action is dispatched with message as null as part of payload', () => {
    const featuresState = {
      toastMessage: null
    };

    const action = {
      type: ENABLE_OR_DISABLE_TOAST,
      payload: { message: null }
    };

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual({ toastMessage: null });
  });

  test('should return the current state when the action with no specific type is dispatched', () => {
    const featuresState = {
      features: [{
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState: { sit: false, replica: true, prod: false }
      }]
    };

    const action = {};

    const updatedState = featuresReducer(featuresState, action);

    expect(updatedState).toEqual({
      features: [{
        featureId: '23456', featureName: 'WO photo download', featureToggleName: '23456_WOPhotoDownload', environmentState: { sit: false, replica: true, prod: false }
      }]
    });
  });
});
like image 69
sjsouvik Avatar answered Nov 02 '22 05:11

sjsouvik