Search code examples
reactjstypescriptreact-hooksjestjsreact-testing-library

Mocking custom callback hook react-testing-library


I have created the following custom hook, and I'm having trouble mocking the hook in a way that the returned data would be updated when the callback is called.

export const useLazyFetch = ({ method, url, data, config, withAuth = true }: UseFetchArgs): LazyFetchResponse => {
  const [res, setRes] = useState({ data: null, error: null, loading: false});

  const callFetch = useCallback(() => {
    setRes({ data: null, error: null, loading: true});

    const jwtToken = loadItemFromLocalStorage('accessToken');
    const authConfig = {
      headers: {
        Authorization: `Bearer ${jwtToken}`
      }
    };

    const combinedConfig = Object.assign(withAuth ? authConfig : {}, config);

    axios[method](url, data, combinedConfig)
      .then(res => setRes({ data: res.data, loading: false, error: null}))
      .catch(error => setRes({ data: null, loading: false, error}))
  }, [method, url, data, config, withAuth])

  return { res, callFetch };
};

The test is pretty simple, when a user clicks a button to perform the callback I want to ensure that the appropriate elements appear, right now I'm mocking axios which works but I was wondering if there is a way to mock the useLazyFetch method in a way that res is updated when the callback is called. This is the current test

  it('does some stuff', async () => {
    (axios.post as jest.Mock).mockReturnValue({ status: 200, data: { foo: 'bar' } });

    const { getByRole, getByText, user } = renderComponent();
    user.click(getByRole('button', { name: 'button text' }));
    await waitFor(() => expect(getByText('success message')).toBeInTheDocument());
  });

Here's an example of how I'm using useLazyFetch

const Component = ({ props }: Props) => {
  const { res, callFetch } = useLazyFetch({
    method: 'post',
    url: `${BASE_URL}/some/endpoint`,
    data: requestBody
  });

  const { data: postResponse, loading: postLoading, error: postError } = res;
  return (
    <Element
      header={header}
      subHeader={subHeader}
    >
      <Button
          disabled={postLoading}
          onClick={callFetch}
       >
              Submit Post Request
       </Button>
    </Element>
  );
}

Solution

  • axios is already tested so there's no point in writing tests for that. We should be testing useLazyFetch itself. However, I might suggest abstracting away the axios choice and writing a more generic useAsync hook.

    // hooks.js
    import { useState, useEffect } from "react"
    
    function useAsync(func, deps = []) {
      const [loading, setLoading] = useState(true)
      const [error, setError] = useState(null)
      const [data, setData] = useState(null)
      useEffect(_ => {
        let mounted = true
        async function run() {
          try { if (mounted) setData(await func(...deps)) }
          catch (e) { if (mounted) setError(e) }
          finally { if (mounted) setLoading(false) }
        }
        run()
        return _ => { mounted = false }
      }, deps)
      return { loading, error, data }
    }
    
    export { useAsync }
    

    But we can't stop there. Other improvements will help too, like a better API abstraction -

    // api.js
    import axios from "axios"
    import { createContext, useContext, useMemo } from "react"
    import { useLocalStorage } from "./hooks.js"
    
    function client(jwt) {
      // https://axios-http.com/docs/instance
      return axios.create(Object.assign(
        {},
        jwt && { headers: { Authorization: `Bearer ${jwt}` } }
      ))
    }
    
    function APIRoot({ children }) {
      const jwt = useLocalStorage("accessToken")
      const context = useMemo(_ => client(jwt), [jwt])
      return <ClientContext.Provider value={context}>
        {children}
      </ClientContext.Provider>
    }
    
    function useClient() {
      return useContext(ClientContext)
    }
    
    const ClientContext = createContext(null)
    
    export { APIRoot, useClient }
    

    When a component is a child of APIRoot, it has access to the axios client instance -

    <APIRoot>
      <User id={4} /> {/* access to api client inside APIRoot */}
    </APIRoot>
    
    // User.js
    import { useClient } from "./api.js"
    import { useAsync } from "./hooks.js"
    
    function User({ userId }) {
      const client = useClient()  // <- access the client
      const {data, error, loading} = useAsync(id => {       // <- generic hook
        return client.get(`/users/${id}`).then(r => r.data) // <- async
      }, [userId])                                          // <- dependencies
      if (error) return <p>{error.message}</p>
      if (loading) return <p>Loading...</p>
      return <div data-user-id={userId}>
        {data.username}
        {data.avatar}
      </div>
    }
    
    export default User
    

    That's helpful, but the component is still concerned with API logic of constructing User URLs and things like accessing the .data property of the axios response. Let's push all of that into the API module -

    // api.js
    import axios from "axios"
    import { createContext, useContext, useMemo } from "react"
    import { useLocalStorage } from "./hooks.js"
    
    function client(jwt) {
      return axios.create(Object.assign(
        { transformResponse: res => res.data }, // <- auto return res.data 
        jwt && { headers: { Authorization: `Bearer ${jwt}` } }
      ))
    }
    
    function api(client) {
      return {
        getUser: (id) =>                 // <- user-friendly functions
          client.get(`/users/${id}`),    // <- url logic encapsulated
        createUser: (data) =>
          client.post(`/users`, data),
        loginUser: (email, password) =>
          client.post(`/login`, {email,password}),
        // ...
      }
    }
    
    function APIRoot({ children }) {
      const jwt = useLocalStorage("accessToken")
      const context = useMemo(_ => api(client(jwt)), [jwt]) // <- api()
      return <APIContext.Provider value={context}>
        {children}
      </APIContext.Provider>
    }
    
    const APIContext = createContext({})
    const useAPI = _ => useContext(APIContext)
    
    export { APIRoot, useAPI }
    

    The pattern above is not sophisticated. It could be easily modularized for more complex API designs. Some segments of the API may require authorization, others are public. The API module gives you a well-defined area for all of this. The components are now freed from this complexity -

    // User.js
    import { useAPI } from "./api.js"
    import { useAsync } from "./hooks.js"
    
    function User({ userId }) {
      const { getUser } = useAPI()
      const {data, error, loading} = useAsync(getUser, [userId]) // <- ez
      if (error) return <p>{error.message}</p>
      if (loading) return <p>Loading...</p>
      return <div data-user-id={userId}>
        {data.username}
        {data.avatar}
      </div>
    }
    
    export default User
    

    As for testing, now mocking any component or function is easy because everything has been isolated. You could also create a <TestingAPIRoot> in the API module that creates a specialized context for use in testing.

    See also -