Search code examples
javascriptreactjsunit-testingreact-testing-library

Reactjs - Mock multiple useRefs react testing library


I have created a custom hook that I am going to use to build a custom scroll component. The hook works however I am failing to cover it with unit test.

I believe it's because I am not mocking the useRef correctly but there might be other things as well that I am missing. I have checked many articles and posts but couldn't find an answer to my problem.

This is the hook I have created:

export const useScrollArea = () => {
  const containerRef = useRef<HTMLDivElement>(null);
  const contentRef = useRef<HTMLDivElement>(null);
  const scrollBarThumbRef = useRef<HTMLDivElement>(null);
  const [isScrollable, setIsScrollable] = useState(false);

  const isOverflowing = useCallback(() => {
    if (!containerRef.current || !contentRef.current) {
      return false;
    }

    const isContentOverflowing =
      contentRef.current.scrollHeight > containerRef.current.clientHeight;

    setIsScrollable(isContentOverflowing);
  }, []);

  const updateThumbPosition = useCallback(() => {
    const scrollRatio =
      containerRef.current.scrollTop /
      (contentRef.current.scrollHeight - containerRef.current.clientHeight);

    const thumbTop =
      scrollRatio *
      (containerRef.current.clientHeight -
        scrollBarThumbRef.current.clientHeight);

    scrollBarThumbRef.current.style.transform = `translateY(${thumbTop}px)`;
  }, []);

  const scroll = (direction: ScrollDirection) => {
    if (
      !containerRef?.current ||
      !contentRef?.current ||
      !scrollBarThumbRef?.current ||
      !isScrollable
    ) {
      return;
    }

    const maxScrollPosition =
      contentRef.current.scrollHeight - containerRef.current.clientHeight;
    let newScrollPosition =
      containerRef.current.scrollTop +
      (direction === 'up' ? -1 : 1) * SCROLL_STEP;

    newScrollPosition = Math.max(
      0,
      Math.min(newScrollPosition, maxScrollPosition)
    );

    containerRef.current.scroll({ top: newScrollPosition });

    updateThumbPosition();
  };

  useEffect(() => {
    isOverflowing();
  }, [isOverflowing]);

  return {
    scroll,
    isScrollable,
    contentRef,
    containerRef,
    scrollBarThumbRef
  };
};

These are some of the unit tests I have created:

const mockContainerRef = {
  current: {
    clientHeight: 100,
    scrollTop: 0,
    scroll: jest.fn()
  }
};

const mockContentRef = {
  current: {
    scrollHeight: 200
  }
};

const mockScrollBarThumbRef = {
  current: {
    clientHeight: 50,
    style: {
      transform: ''
    }
  }
};

jest.mock('react', () => ({
  ...jest.requireActual('react'),
  useRef: jest
    .fn()
    .mockImplementationOnce(() => mockContainerRef)
    .mockImplementationOnce(() => mockContentRef)
    .mockImplementationOnce(() => mockScrollBarThumbRef)
}));

describe('useScrollArea', () => {
  it('should return `isScrollable` as true when content is overflowing', () => {
    const { result } = renderHook(() => useScrollArea());

    expect(result.current.isScrollable).toBe(true);
  });

  it('should return `isScrollable` as false when content is not overflowing', () => {
    mockContentRef.current.scrollHeight = 50;

    const { result } = renderHook(() => useScrollArea());

    expect(result.current.isScrollable).toBe(false);
  });

  it('should scroll down', () => {
    mockContainerRef.current.scrollTop = 100;

    const { result } = renderHook(() => useScrollArea());

    act(() => {
      result.current.scroll('down');
    });

    expect(mockContainerRef.current.scroll).toHaveBeenCalledWith({ top: 40 });
  });
});

Unfortunately, I keep getting errors such as:

TypeError: Cannot read properties of undefined (reading 'current')

or the hook not returning the expected outcome.

Anyone know how I properly test the logic of my custom hook?


Solution

  • If I understand correctly, what's throwing you for a loop is that you need to populate the refs before the first time your hook's effect runs; and to achieve that, you're trying to mock out the refs so that they're populated before the hook itself runs. Do I have that right?

    But you don't need to do that. I haven't used the testing library that you're using, but it's clear from the documentation for the renderHook function that the callback you pass to it isn't restricted to just calling your hook. So you should be able to populate your references inside the callback, just after you've called the hook.

    That is — you should be able to write something like this:

    describe('useScrollArea', () => {
      it('should return `isScrollable` as true when content is overflowing', async () => {
        const mockScroll = jest.fn();
    
        const { result, waitForNextUpdate } = renderHook(() => {
          // call hook:
          const result = useScrollArea();
    
          // populate references as desired:
          result.containerRef.current ??= {
            clientHeight: 100,
            scrollTop: 0,
            scroll: mockScroll,
          };
          result.contentRef.current ??= {
            scrollHeight: 200,
          };
          result.scrollbarThumbRef.current ??= {
            clientHeight: 50,
            style: {
              transform: '',
            }
          };
    
          // pass result up to testing framework:
          return result;
        });
    
        // wait for state update to go through:
        await waitForNextUpdate();
    
        // verify result:
        expect(result.current.isScrollable).toBe(true);
      });
    
      it('should return `isScrollable` as false when content is not overflowing', async () => {
    describe('useScrollArea', () => {
      it('should return `isScrollable` as true when content is overflowing', async () => {
        const mockScroll = jest.fn();
    
        const { result, waitForNextUpdate } = renderHook(() => {
          // call hook:
          const result = useScrollArea();
    
          // populate references as desired:
          result.containerRef.current ??= {
            clientHeight: 100,
            scrollTop: 0,
            scroll: mockScroll,
          };
          result.contentRef.current ??= {
            scrollHeight: 50,
          };
          result.scrollbarThumbRef.current ??= {
            clientHeight: 50,
            style: {
              transform: '',
            }
          };
    
          // pass result up to testing framework:
          return result;
        });
    
        // wait for state update to go through:
        await waitForNextUpdate();
    
        // verify result:
        expect(result.current.isScrollable).toBe(false);
      });
    
      it('should scroll down', () => {
        const mockScroll = jest.fn();
    
        const { result } = renderHook(() => {
          // call hook:
          const result = useScrollArea();
    
          // populate references as desired:
          result.containerRef.current ??= {
            clientHeight: 100,
            scrollTop: 100,
            scroll: mockScroll,
          };
          result.contentRef.current ??= {
            scrollHeight: 200,
          };
          result.scrollbarThumbRef.current ??= {
            clientHeight: 50,
            style: {
              transform: '',
            }
          };
    
          // pass result up to testing framework:
          return result;
        });
    
        // call the scroll function returned by the hook:
        act(() => {
          result.current.scroll('down');
        });
    
        // assert that the desired underlying scroll function was called:
        expect(mockScroll).toHaveBeenCalledWith({ top: 40 });
      });
    });
    

    That said, I should mention that even aside from the complexity of testing your hook, I don't think this hook's approach to refs — creating refs and expecting callers to populate them — is really ideal. A given DOM element will only accept a single ref arg, so if two hooks both want references to the same DOM element, they can't both generate refs and expect callers to use them both. So this hook's approach is artificially restrictive — the hook is demanding to own something that it has no need to own.

    So a better approach, in my opinion, is for the hook to take the refs as arguments passed in from the caller. That would eliminate your reasons for mocking out useRef (because the hook isn't calling useRef), and it would make the hook play nicer with other hooks taking the same approach.