0

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>
  );
}

1 Answers1

0

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 -

Mulan
  • 129,518
  • 31
  • 228
  • 259
  • Thanks for taking the time to go through all of that! I really like the abstractions but in this case I'm not sure useAsync is helpful. I'm calling the callback function when a user performs an action and that's performing the post request and my data/error/loading is updated based on the response from that post. I definitely don't want to test axios, I was hoping there was a way to mock a hook such that the callback would update state but I'm struggling to do that. – OnlinePseudonym Oct 04 '22 at 19:20
  • you're very welcome. `useAsync` can easily be modified so that it's triggered on a user action and not on component mount like it is now. do you want to try yourself first? i can make an edit if you get stuck – Mulan Oct 04 '22 at 20:13
  • Okay I think I've got the jist of it, so I abstract away the api call and pass it to useAsync so that I dont need to mock it away, I can mock the response of the api call and the hook should function how I expect it to? I've got a [semi working POC up here](https://codesandbox.io/s/stack-overflow-answer-forked-okjnx9?file=/src/MyComponent.tsx). Thanks a ton for the perspective. – OnlinePseudonym Oct 04 '22 at 21:57
  • Very happy to assist :D If it's generated by a user action, I recommend you see the `react-query` and `useSWR` links provided. It should help understand how to design the hooks and capture some of the nuances various components may require. – Mulan Oct 04 '22 at 23:09