reactjstypescriptjestjsmockingreact-testing-library

Mock custom hook in react 18 with jest 29


I'm currently trying to test my component that uses a custom hook, however I can't seem to manually mock the custom hook used within the component.

My code is current as follow;

src/components/movies/MovieDetail.tsx

import { useParams } from 'react-router-dom';

import { Movie } from '../../models/Movie';
import { useFetch } from '../../hooks/useFetch';
import { SCYoutubeVideoPlayer } from '../YoutubeVideoPlayer';
import { SCTagList } from '../tags/TagList';
import { SCActorList } from '../actors/ActorList';
import { SCMovieSpecs } from './MovieSpecs';
import { SCServerError } from '../errors/ServerError';
import { SCLoading } from '../loading/Loading';

import styles from './MovieDetail.module.scss';

export const SCMovieDetail = () => {
  const { slug } = useParams();
  const { error, loading, data: movie } = useFetch<Movie>({ url: `http://localhost:3000/movies/${slug}` });

  if (error) {
    return <SCServerError error={error} />;
  }

  if (loading || movie === undefined) {
    return <SCLoading />;
  }

  return (
    <>
      <section className={`${styles.spacing} ${styles.container}`}>
        <h2>{movie.title}</h2>
        <SCMovieSpecs movie={movie} />
      </section>

      <section className={styles['trailer-container']}>
        <div>
          <SCYoutubeVideoPlayer src={movie.trailer} />
        </div>
      </section>

      <section className={`${styles.spacing} ${styles.container}`}>
        <SCTagList tags={movie.tags} />
        <div className={styles.description}>
          <p>{movie.description}</p>
        </div>
        <SCActorList actors={movie.cast} />
      </section>
    </>
  );
};

src/components/movies/MovieDetail.test.tsx

import renderer from 'react-test-renderer';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

import { SCMovieDetail } from './MovieDetail';

describe('SCMovieDetail', () => {
  const componentJSX = () => {
    return (
      <MemoryRouter>
        <SCMovieDetail />
      </MemoryRouter>
    );
  };

  it('Should match snapshot', () => {
    const component = renderer.create(<SCMovieDetail />);
    const tree = component.toJSON();
    expect(tree).toMatchSnapshot();
  });

  it('Should render error', () => {
    jest.mock('../../hooks/useFetch');

    render(componentJSX());

    expect(screen.getByText('Hello!')).toBeInTheDocument();
  });
});

src/hooks/mocks/useFetch.ts

import { useFetchPayload, useFetchProps } from '../useFetch';

export const useFetch = <T>({ url, method = 'GET' }: useFetchProps): useFetchPayload<T> => {
  let data: T | undefined = undefined;
  let error: string | undefined = undefined;
  let loading = false;

  console.log('MOCKED!!!');
  console.log('MOCKED!!!');
  console.log('MOCKED!!!');

  switch (url) {
    case 'success':
      data = {} as T;
      break;
    case 'error':
      error = `${method} request failed`;
      break;
    case 'loading':
      loading = true;
      break;
  }

  return { data, error, loading };
};

src/hooks/useFetch.ts

import { useEffect, useState } from 'react';

export interface useFetchProps {
  url: string;
  method?: 'GET' | 'POST' | 'UPDATE' | 'PATCH' | 'DELETE';
}

export interface useFetchPayload<T> {
  data?: T;
  error?: string;
  loading: boolean;
}

export const useFetch = <T>({ url, method = 'GET' }: useFetchProps): useFetchPayload<T> => {
  const [data, setData] = useState<T | undefined>(undefined);
  const [error, setError] = useState<string | undefined>(undefined);
  const [loading, setLoading] = useState(false);

  console.log('REAL!!!');
  console.log('REAL!!!');
  console.log('REAL!!!');

  useEffect(() => {
    const abortController = new AbortController();

    const fetchData = async () => {
      try {
        const response = await fetch(url, { method, signal: abortController.signal });
        if (!response || !response.ok) {
          throw new Error('Request failed!');
        }
        const json = await response.json();
        setData(json);
      } catch (error: unknown) {
        if (error instanceof DOMException && error.name == 'AbortError') {
          return;
        }
        const customError = error as Error;
        setError(customError.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    return () => abortController.abort();
  }, [url, method]);

  return { data, error, loading };
};

A quick overflow of the directory structure is;

src/
|-- ...
|-- components/
|   | ...
|   |-- movies/
|   |   |-- MovieDetail.tsx
|   |   |-- MovieDetail.test.tsx 
|   |   |-- ...
|-- hooks/
|   |-- __mocks__/
|   |   |-- useFetch.tsx
|   |-- useFetch.tsx
|   |-- ...
|-- ...

I've already searched multiple stackoverflow posts and other sites, but still no answer has been found. Hopefully one of you can help me finding the missing piece! I'm using React 18 with Jest 29. The goal is to use the least amount of node_modules as I'm still learning React and the react-testing-library in combination with Jest. Its also nice if the mock could be reused, so using the mocks directory is preferred over mocking the implementation directly in my test every single time.


Solution

  • It seemed that the fix was actually really simple. Instead of mocking inside the it scope, it should be entirely outside of the describe block.

    import...
    
    jest.mock('../../hooks/useFetch');
    
    describe('...', () => {
        ...
    });
    

    Another solution I also found that grants additional flexibility is to be able to specify the return value inside the test callback like below. This solution does not use the mock file which is present in __mocks__ but grants flexibility you'll otherwise not have.

    import { useFetch } from '../../hooks/useFetch';
    
    jest.mock('../../hooks/useFetch', () => ({ useFetch: jest.fn() }));
    
    describe('...', () => {
        it('...', () => {
            (useFetch as jest.Mock<typeof useFetch>).mockReturnValue({ data: undefined, error: undefined, loading: false });
        });
    });