skip to Main Content

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.

2

Answers


  1. Chosen as BEST ANSWER

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

  2. I prefer mock fetch rather than useFetch hook.

    Because just mocking the return value of useFetch will destroy its functionality, internally used hook functions such as useState and useEffect have no chance to run. The component will be rendered multiple times due to setState with the real implementation of the useFetch hook.

    For the sake of simplicity, I will mock fetch directly instead of introducing msw package. Here is a simple example with minimal code to demonstrate:

    MovieDetail.tsx:

    import React from 'react';
    
    import { useFetch } from './hooks/useFetch';
    
    export const SCMovieDetail = () => {
      const movieQuery = useFetch<string>({ url: `http://localhost:3000/movies/1` });
    
      console.log('🚀 ~ SCMovieDetail ~ movieQuery:', movieQuery);
      return (
        <>
          <section>{movieQuery.data}</section>
        </>
      );
    };
    

    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 () => {
          setLoading(true);
          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 };
    };
    

    MovieDetail.test.tsx:

    import React from 'react';
    import { render, screen } from '@testing-library/react';
    import '@testing-library/jest-dom';
    
    import { SCMovieDetail } from './MovieDetail';
    
    describe('SCMovieDetail', () => {
      it('Should render data', async () => {
        jest.spyOn(global, 'fetch').mockResolvedValue({
          ok: true,
          json: jest.fn().mockResolvedValue('Hello!'),
        } as unknown as Response);
    
        render(<SCMovieDetail />);
    
        expect(await screen.findByText('Hello!')).toBeInTheDocument();
      });
    });
    

    Test result:

      console.log
        REAL!!!
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:19:11)
    
      console.log                                                                                                                                                                                                                                            
        REAL!!!                                                                                                                                                                                                                                              
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:20:11)
    
      console.log                                                                                                                                                                                                                                            
        REAL!!!                                                                                                                                                                                                                                              
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:21:11)
    
      console.log                                                                                                                                                                                                                                            
        🚀 ~ SCMovieDetail ~ movieQuery: { data: undefined, error: undefined, loading: false }                                                                                                                                                               
    
          at log (stackoverflow/78474280/MovieDetail.tsx:9:11)
    
      console.log
        REAL!!!
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:19:11)
    
      console.log                                                                                                                                                                                                                                            
        REAL!!!                                                                                                                                                                                                                                              
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:20:11)
    
      console.log                                                                                                                                                                                                                                            
        REAL!!!                                                                                                                                                                                                                                              
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:21:11)
    
      console.log                                                                                                                                                                                                                                            
        🚀 ~ SCMovieDetail ~ movieQuery: { data: undefined, error: undefined, loading: true }                                                                                                                                                                
    
          at log (stackoverflow/78474280/MovieDetail.tsx:9:11)
    
      console.log
        REAL!!!
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:19:11)
    
      console.log                                                                                                                                                                                                                                            
        REAL!!!                                                                                                                                                                                                                                              
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:20:11)
    
      console.log
        REAL!!!
    
          at log (stackoverflow/78474280/hooks/useFetch.ts:21:11)
    
      console.log
        🚀 ~ SCMovieDetail ~ movieQuery: { data: 'Hello!', error: undefined, loading: false }
    
          at log (stackoverflow/78474280/MovieDetail.tsx:9:11)
    
     PASS  stackoverflow/78474280/MovieDetail.test.tsx
      SCMovieDetail
        √ Should render error (98 ms)
    
    Test Suites: 1 passed, 1 total
    Tests:       1 passed, 1 total
    Snapshots:   0 total
    Time:        1.183 s
    Ran all test suites related to changed files.
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search