3

I have a pretty simple use case - I have a global app context where I'm trying to store data fetched from an endpoint. My goal is to load this data into the context on app load and I'm going about it using the useReducer hook. I settled on the solution of calling an action getIssuerDetails() that dispatches various states throughout the method and invokes the issuerApi service to actually call the API (it's a simple Axios GET wrapper). This action is called from a useEffect within the Provider and is called on mount as shown below.

I'm having some trouble wrapping my head around how to properly test that 1) my AppProvider actually gets populated with the data fetched within the useEffect and 2) my child components within my AppProvider are being populated correctly with the data passed down from the provider. Am I approaching this data fetching portion correctly? I can either make the actual API call within my App component on mount and then dispatch actions to update the global state OR I keep my solution of fetching my data from within the useEffect of the provider.

I know I'm not supposed to be testing implementation details but I'm having a hard time separating out what data/methods I should mock and which ones I should allow to execute on their own. Any help would be greatly appreciated.

AppContext.tsx

import { createContext, FC, useEffect, useContext, useReducer, useRef } from 'react';
import { getIssuerDetails } from './issuer/actions';
import { appStateReducer } from './global/reducer';
import { combineReducers } from '@utils/utils';
import { GlobalAppStateType } from './global/types';

/**
 * Our initial global app state. It just stores a bunch
 * of defaults before the data is populated.
 */

export const defaultInitialState = {
    issuerDetails: {
        loading: false,
        error: null,
        data: {
            issuerId: -1,
            issuerName: '',
            ipoDate: '',
            ticker: '',
        },
    },
};


export type AppStateContextProps = {
    state: GlobalAppStateType;
};

export type AppDispatchContextProps = {
    dispatch: React.Dispatch<any>;
};

export const AppStateContext = createContext<AppStateContextProps>({
    state: defaultInitialState,
});

export const AppDispatchContext = createContext<AppDispatchContextProps>({
    dispatch: () => null,
});

/**
 *
 * @param
 * @returns
 */
export const mainReducer = combineReducers({
    appState: appStateReducer,
});

export type AppProviderProps = {
    mockInitialState?: GlobalAppStateType;
    mockDispatch?: React.Dispatch<any>;
};

/**
 * Our main application provider that wraps our whole app
 * @param mockInitialState - mainly used when testing if we want to alter the data stored in our
 * context initially
 * @param children - The child components that will gain access to the app state and dispatch values
 */
export const AppProvider: FC<AppProviderProps> = ({ mockInitialState, mockDispatch, children }) => {
    const [state, dispatch] = useReducer(mainReducer, mockInitialState ? mockInitialState : defaultInitialState);

    const nState = mockInitialState ? mockInitialState : state;
    const nDispatch = mockDispatch ? mockDispatch : dispatch;

    // Ref that acts as a flag to aid in cleaning up our async data calls
    const isCanceled = useRef(false);

    useEffect(() => {
        async function fetchData() {
            // Await the API request to get issuer details
            if (!isCanceled.current) {
                await getIssuerDetails(nDispatch);
            }
        }
        fetchData();

        return () => {
            isCanceled.current = true;
        };
    }, [nDispatch]);

    return (
        <AppStateContext.Provider value={{ state: nState }}>
            <AppDispatchContext.Provider value={{ dispatch: nDispatch }}>{children}</AppDispatchContext.Provider>
        </AppStateContext.Provider>
    );
};

/**
 * Custom hook that gives us access to the global
 * app state
 */
export const useAppState = () => {
    const appStateContext = useContext(AppStateContext);
    if (appStateContext === undefined) {
        throw new Error('useAppState must be used within a AppProvider');
    }
    return appStateContext;
};

/**
 * Custom hook that gives us access to the global
 * app dispatch method to be able to update our global state
 */
export const useAppDispatch = () => {
    const appDispatchContext = useContext(AppDispatchContext);
    if (appDispatchContext === undefined) {
        throw new Error('useAppDispatch must be used within a AppProvider');
    }
    return appDispatchContext;
};

AppReducer.ts

Note: Code still needs to be cleaned up here but it's functioning at the moment.

import * as T from '@context/global/types';

export const appStateReducer = (state: T.GlobalAppStateType, action: T.GLOBAL_ACTION_TYPES) => {
    let stateCopy;
    switch (action.type) {
        case T.REQUEST_ISSUER_DETAILS:
            stateCopy = { ...state };
            stateCopy.issuerDetails.loading = true;
            return stateCopy;
        case T.GET_ISSUER_DETAILS_SUCCESS:
            stateCopy = { ...state };
            stateCopy.issuerDetails.loading = false;
            stateCopy.issuerDetails.data = action.payload;
            return stateCopy;
        case T.GET_ISSUER_DETAILS_FAILURE:
            stateCopy = { ...state };
            stateCopy.issuerDetails.loading = false;
            stateCopy.issuerDetails.error = action.payload;
            return stateCopy;
        default:
            return state;
    }
};

getIssuerDetails()

export const getIssuerDetails = async (dispatch: React.Dispatch<any>) => {
    dispatch({ type: GlobalState.REQUEST_ISSUER_DETAILS, payload: null });
    try {
        // Fetch the issuer details
        const response = await issuerApi.getIssuerDetails(TEST_ISSUER_ID);

        if (response) {
            /***************************************************************
             * React Testing Library gives me an error on the line below: 
             * An update to AppProvider inside a test was not wrapped in act(...)
             ***************************************************************/
            dispatch({ type: GlobalState.GET_ISSUER_DETAILS_SUCCESS, payload: response });
            return response;
        }

        // No response
        dispatch({
            type: GlobalState.GET_ISSUER_DETAILS_FAILURE,
            error: { message: 'Could not fetch issuer details.' },
        });
    } catch (error) {
        dispatch({ type: GlobalState.GET_ISSUER_DETAILS_FAILURE, error });
    }
};

dashboard.test.tsx

import { render, screen, cleanup, act } from '@testing-library/react';
import { AppProvider, AppStateContext } from '@context/appContext';
import { GlobalAppStateType } from '@context/global/types';

afterEach(() => {
    cleanup();
    jest.clearAllMocks();
});

describe('Dashboard page', () => {
    it('should render the page correctly', async () => {
        act(() => {
            render(
                <AppProvider>
                    <Dashboard />
                </AppProvider>
            );
        });

        expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent('Stock Transfer');
    });
});

1 Answers1

0

I won't dive into the code specifically since there is too much you want to test all at once.

From what I could gather, you are trying to do an Integration Test and not a Unitary Test anymore. No problem there, you just need to define where you want to draw the line. For me, it's pretty clear that the line lies in the issuerApi.getIssuerDetails call, from which you could easily mock to manipulate the data how you want.

1) my AppProvider actually gets populated with the data fetched within the useEffect and 2) my child components within my AppProvider are being populated correctly with the data passed down from the provider.

Well, I would advise you to make a simple mock component that uses the hook and displays the data after fetching. You could make a simple assertion for that, no need for an actual component (<Dashboard />).

Am I approaching this data fetching portion correctly?

It all depends on how you want to structure it but ideally the AppProvider should be thin and lay those data fetching and treatments inside a service just for that. This would provide a better way to unit test the components and smoother code maintenance.

ryok90
  • 92
  • 2