How to use custom React hook to increase application testability

In my previous blog, I spoke about reducing coupling in a React app to improve testing. Now I will show you how a custom React hook can increase testability.

Note: I assume that you are comfortable with React hooks. If you aren’t, please have a look at the React documentation

First of all, I will show you some code that is not testable. In my side project, I use react-map-gl to create maps with Mapbox. Unfortunately, I can’t render the map with the testing library because this library only works in a web browser. I might have done something wrong but I haven’t found any solution to solve this problem.

export function MapPage() {
   const {mapId} = useParams<{ mapId: string }>();
   const [markers, setMarkers] = useState([]);
   const [isMarkerOpened, setIsMarkerOpened] = useState(false);


   useEffect(() => {
       setMarkers(getMarkers(mapId));
   }, [mapId]);


   const openMarkerPopup = () => setIsMarkerOpened(true);
   const closeMarkerPopup = () => setIsMarkerOpened(false);


   return (
       <>
           <ReactMapGL>
               {markers.map(
                   (marker) => <Marker
                       longitude={marker.longitude}
                       latitude={marker.latitude}
                   >
                       <MarkerIcon onClick={openMarkerPopup} />
                   </Marker>
               )}
           </ReactMapGL>
           <MarkerPopup isOpened={isMarkerOpened} onClose={closeMarkerPopup} />
       </>
   )
}

MapPage is in charge of loading map data depending on the mapId and rendering a map with its markers. I can’t test the MapBoard component because the ReactMapGL component can’t be rendered through the test tooling. That’s sad because I still want to check if I can open the marker popup when a user clicks on a marker.

React will help us to fix this issue! We need to refactor this component to extract the business logic into a hook. This way, the component will only be responsible for rendering things. Let’s begin by creating the hooks.

export function useMapPage(mapId, {defaultIsMarkerOpened} = {defaultIsMarkerOpened: false}) {
   const [markers, setMarkers] = useState([]);
   const [isMarkerOpened, setIsMarkerOpened] = useState(defaultIsMarkerOpened);


   useEffect(() => {
       setMarkers(getMarkers(mapId));
   }, [mapId]);


   const openMarkerPopup = () => setIsMarkerOpened(true);
   const closeMarkerPopup = () => setIsMarkerOpened(false);


   return {
       markers,
       isMarkerOpened,
       closeMarkerPopup,
       openMarkerPopup,
   }
}

The hook exposes two variables: markers which is an array of map’s markers and isMarkerOpened which is a boolean that indicates if the popup is opened or closed. It exposes two functions, openMarkerPopup and closeMarkerPopup that let us mutate the isMarkerOpened boolean.

Note: We could only expose setIsMarkerOpened but I think openMarkerPopup and closeMarkerPopup function names are clearer and match the component logic.

Now, we need to call the hook from the MapPage component and it will still work as before.

export function MapPage() {
   const {
       markers,
       isMarkerOpened,
       closeMarkerPopup,
       openMarkerPopup
   } = useMapPage(mapId);


   return (
       <>
           <ReactMapGL>
               {markers.map(
                   (marker) => <Marker
                       longitude={marker.longitude}
                       latitude={marker.latitude}
                   >
                       <MarkerIcon onClick={openMarkerPopup} />
                   </Marker>
               )}
           </ReactMapGL>
           <MarkerPopup isOpened={isMarkerOpened} onClose={closeMarkerPopup} />
       </>
   )
}

The MapPage is still untestable but we can start testing the hook to ensure hook logic matches business expectations. We can test if we can open a marker’s popup. That’s great because the testing library provides the renderHook helper that eases the hook testing.

Note: If you want to know how renderHook works you should have a look at this blog post written by Kent C. Dodds.

describe('Map Page', () => {
   test('should open the marker popup', async () => {
       const { result } = renderHook(() => useMapPage(
           'mapId', {defaultIsMarkerOpened: false}
       ));
       
       act(() => result.current.openMarkerPopup());
       
       expect(result.current.isMarkerOpened).toEqual(true);
   });


   test('should close the marker popup', async () => {
       const { result } = renderHook(() => useMapPage(
           'mapId', {defaultIsMarkerOpened: true}
       ));
       
       act(() => result.current.closeMarkerPopup());

       expect(result.current.isMarkerOpened).toEqual(false);
   });
});

As I said at the beginning of this blog post I wrote a blog post to explain how to reduce coupling in a React application. Please, have a look at this blog post to understand how to make a dependency injection system.

Now, we need to remove the getMarkers function call from the hooks if we want to test the map data loading. We don’t want to trigger side effects like HTTP calls in the unit test suite because we want to have the shortest feedback loop. We will get the getMarkers function to useServiceContainer which is a hook that provides any services.

export function useMapPage(mapId, {defaultIsMarkerOpened} = {defaultIsMarkerOpened: false}) {
   const {getMarkers} = useServiceContainer();
   // ...
  
   useEffect(() => {
       setMarkers(getMarkers(mapId));
   }, [mapId]);
   // ...
}

By default, the useServiceContainer hooks return the production services, we will need to replace the getMarkers service with a fake service for testing purposes. The useServiceContainer hooks can’t work without a React Provider. I like to create a factory that wraps components I test with all needed providers. It avoids a lot of noise in the test suites and makes tests more readable.

export const createWrapper = (serviceContainer) => function Wrapper(
   { children }: { children: ReactElement },
) {
   return (
       <ContainerContext.Provider value={serviceContainer}>
           {children}
       </ContainerContext.Provider>
   );
};

Note: the factory has a parameter which is the service container. It will let us define the services we want to override for testing.

The renderHook has a wrapper option that lets you define the component that will wrap the hook you are testing. We will use the createWrapper factory to wrap the hook into the ContainerContext provider and we create a fake getMarkers service.

describe('Map Page', () => {
   test('should load the markers of the map', async () => {
       const markers = [{id: 'makerId'}];
       const { result } = renderHook(
           () => useMapPage('mapId'),
           {wrapper: createWrapper({getMarkers: () => markers})}
       );
       
       expect(result.current.markers).toEqual(markers);
   });
});

Now, the getMarkers is predictable. That means we can test the map loading because the getMarker function will return [{id: 'makerId'}] every time.

Thanks to my proofreader @LaureBrosseau.