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.
2
Answers
Sharing a ref code from one of my profile components –
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:
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 callinguseRef
), and it would make the hook play nicer with other hooks taking the same approach.