Home > Enterprise >  Mocking custom callback hook react-testing-library
Mocking custom callback hook react-testing-library

Time:10-05

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

CodePudding user response:

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: client(jwt)}), [jwt])
  return <APIContext.Provider value={context}>
    {children}
  </APIContext.Provider>
}

function useClient() {
  const {client} = useContext(APIContext)
  return client
}

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({})

export { APIRoot, useAPI: _ => useContext(APIContext) }

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 -

  • Related