0

I have this custom hook:

import {useEffect, useState} from 'react';

...

const isScrollerAtBottom = (elt: HTMLElement) => {
    return (
        Math.floor(Number(elt?.scrollHeight) - Number(elt?.scrollTop)) >
        Number(elt?.clientHeight)
    );
};

export function useScroll(container: HTMLElement): useScrollProps {
    console.dir(container, {depth: 12});
    
    const [displayScroller, setDisplayScroller] = useState(true);
    const [scrollerTop, setScrollerTop] = useState(0);
    const [scrollerLeft, setScrollerLeft] = useState(0);

    useEffect(() => {
        const containerDimensions = container
            ? container.getBoundingClientRect()
            : null;
        console.dir(containerDimensions, {depth: 12});

        const left =
            (containerDimensions?.x || 0) +
            (containerDimensions?.width || 0) -
            40;
        const top = containerDimensions?.height || 0;
        setScrollerTop(top);
        setScrollerLeft(left);

        setDisplayScroller(isScrollerAtBottom(container));
    });

    ...

    const handleScroll = (event: React.UIEvent<HTMLElement>) => {
        const elt = event.target as HTMLElement;
        setDisplayScroller(isScrollerAtBottom(elt));
    };

    return {
        displayScroller,
        scrollerTop,
        scrollerLeft,
        handleScroll,
    };
}

And I'd like to test it. My problem is how to mock the HTMLElement as container.

I tried this:

import { JSXElement } from "@babel/types";
import React from 'react';
import { render, renderHook, screen } from "@testing-library/react";
import { useScroll, useScrollProps } from "../../hooks/useScroll";

describe("useScroll", () => {
  const container: HTMLElement = document.createElement("div");
  container.style.width = "300px";
  container.style.height = "800px";
  container.style.display = "block";
  const content: HTMLElement = document.createElement("p");
  content.innerHTML = `... ... ...`;
  container.appendChild(content);

  test("should return scroller position and display flag", () => {
    const { result } = renderHook(() => useScroll(container));
    console.log(result.current);
  });
});

But the container dimension is not get in the customHook:

 console.dir                                                                                          
    {                                                                                                  
      x: 0,
      y: 0,
      bottom: 0,
      height: 0,
      left: 0,
      right: 0,
      top: 0,
      width: 0
    }

If I can get the right dimension of the container mock then I think I can continue to proceed to do some assertions, but I don't know how to get there.

Any idea will be appreciated. Thanks!

POST-EDIT: What I have seen so far about similar needs is people use to mock such function (getBoundingClientRect), but if I can't use a mocked HTML element and have the expected calculations for the mocked HTMLElement using the hook I don't think the test will make sense. What I want to test is it returns true/false if the scroll is at the bottom of the container. Any ideas, comments and/or opinions about this are welcome.

TRY: Render component

    import { fireEvent, render, renderHook } from "@testing-library/react";
    import { useScroll } from "../../hooks/useScroll";
    
    describe("useScroll", () => {
      test("should return scroller position and display flag", () => {
        const { container } = render(
          <div style={{ width: "200px", height: "700px" }}></div>
        );
        const { result } = renderHook(() => useScroll(container));
    
        container.addEventListener("scroll", result.current.handleScroll);
    
        fireEvent.scroll(container, { target: { scrollTop: 0 } });
        expect(result.current.displayScroller).toBeFalsy();
    
        fireEvent.scroll(container, { target: { scrollTop: 700 } });
        expect(result.current.displayScroller).toBeTruthy();
      });
    });

But the last assertion expect(result.current.displayScroller).toBeTruthy(); is still false although the event returned in the hook I am testing is trigered.

manou
  • 123
  • 2
  • 20

4 Answers4

1

The testing library is more about the expected result rather than the procedure.

Can you create a component inside the test, mount your hook, do whatever you need, and check if the scroll happened?

Or maybe this can help you. https://github.com/testing-library/react-testing-library/issues/671

Fernando Herrera
  • 552
  • 1
  • 6
  • 16
  • I edit the question with what I am trying now, but it still doesn't work as I expect. The post seems to advice to do E2E tests for this sort of things, but I, if I understood correctly, I believe it might be a way. What I tried looks like promising but not :/ – manou Dec 21 '22 at 00:03
  • I can't see a reason why this hook could not be tested on its own. – Dávid Molnár Dec 21 '22 at 17:57
  • Of course, there could be a test, which uses it in a component as well, but just purely as a unit test, the hook can be tested as well. – Dávid Molnár Dec 21 '22 at 17:58
1

I don't think @testing-library/react library will give you usable values for position, size or scroll state. You could use a framework which runs the test in a real browser, there you could get it (e.g. Cypress). However, this is more complicated. I would advise you just to use a simpler solution:

import { JSXElement } from "@babel/types";
import React from 'react';
import { render, renderHook, screen } from "@testing-library/react";
import { useScroll, useScrollProps } from "../../hooks/useScroll";

describe("useScroll", () => {
  test("should return scroller position and display flag", () => {
    const container = {
      getBoundingClientRect: jest.fn().mockReturnValue({ x: 0, y: 10, width: 100, height: 300 }),
      scrollHeight: 50,
      scrollTop: 20,
      clientHeight: 300,
    } as unknown as HTMLElement;
    const { result } = renderHook(() => useScroll(container));
    expect(result.current).toEqual({ ... });
  });
});

I used as unknown as HTMLElement to silence Typescript errors (it might work without it too, just try as HTMLElement). Creating such shallow object works for us, since we're using only a few properties from the HTMLElement. It's easier to get it work like this, rather than trying to "hack" the original HTMLElement. For testing this is fine, but it's not suitable for "real" code running in the browser.

Also note, I used jest.fn().mockReturnValue({ x: 0, y: 10, width: 100, height: 300}) for getBoundingClientRect, which makes it easy to verify that the function was actually called, e.g.:

expect(container.getBoundingClientRect).toHaveBeenCalledWith();

Additionally, your container: HTMLElement argument in the useScroll function is not allowing falsy values, so it'll never be null:

  const containerDimensions = container
    ? container.getBoundingClientRect()
    : null;

You could use HTMLElement | null as the argument's type or just simply drop the ternary operator.

Dávid Molnár
  • 10,673
  • 7
  • 30
  • 55
  • I am not looking for to test if getBoundingClientRect is called or not, please check my answer, I think I have what I was looking for. Please add a comment over my own answer if you'd like to provide a feedback. Thanks a lot. – manou Dec 22 '22 at 00:01
  • Sorry for the misunderstanding. It’s optional to verify the call of getBoundingClientRect. I added it as an additional tip. The gist of this answer is to create a simple object with the required properties as the container. – Dávid Molnár Dec 22 '22 at 06:03
0

Yes, I think I see the problem as to why Jest may not be picking up your test React Hook properly

When you use document.createElement("div") it isn't HTMLElement being returned as the type but rather an HTMLDivElement. Likewise, when you use document.createElement("p") it is HTMLParagraphElement not HTMLElement being returned as the type. HTMLElement is the base Entity that all other JSX.IntrinsicElement types extend in their interface definitions. You can see this for yourself by plugging the code I've included below into a .ts file

export type DocumentCreateElementTagNameTandem = {[P in keyof globalThis.HTMLElementTagNameMap]: globalThis.HTMLElementTagNameMap[P]}

I went ahead and set up a dummy function to get intellisense to infer its return type for me, which is included below

    export type DocumentCreateElementTagNameTandem = {
      [P in keyof globalThis.HTMLElementTagNameMap]: globalThis.HTMLElementTagNameMap[P];
    };

    export const testingDocumentCreateElementTagNameTandem = (
      props: DocumentCreateElementTagNameTandem 
    ): {
      a: HTMLAnchorElement;
      abbr: HTMLElement;
      address: HTMLElement;
      area: HTMLAreaElement;
      article: HTMLElement;
      aside: HTMLElement;
      audio: HTMLAudioElement;
      b: HTMLElement;
      base: HTMLBaseElement;
      bdi: HTMLElement;
      bdo: HTMLElement;
      blockquote: HTMLQuoteElement;
      body: HTMLBodyElement;
      br: HTMLBRElement;
      button: HTMLButtonElement;
      canvas: HTMLCanvasElement;
      caption: HTMLTableCaptionElement;
      cite: HTMLElement;
      code: HTMLElement;
      col: HTMLTableColElement;
      colgroup: HTMLTableColElement;
      data: HTMLDataElement;
      datalist: HTMLDataListElement;
      dd: HTMLElement;
      del: HTMLModElement;
      details: HTMLDetailsElement;
      dfn: HTMLElement;
      dialog: HTMLDialogElement;
      div: HTMLDivElement;
      dl: HTMLDListElement;
      dt: HTMLElement;
      em: HTMLElement;
      embed: HTMLEmbedElement;
      fieldset: HTMLFieldSetElement;
      figcaption: HTMLElement;
      figure: HTMLElement;
      footer: HTMLElement;
      form: HTMLFormElement;
      h1: HTMLHeadingElement;
      h2: HTMLHeadingElement;
      h3: HTMLHeadingElement;
      h4: HTMLHeadingElement;
      h5: HTMLHeadingElement;
      h6: HTMLHeadingElement;
      head: HTMLHeadElement;
      header: HTMLElement;
      hgroup: HTMLElement;
      hr: HTMLHRElement;
      html: HTMLHtmlElement;
      i: HTMLElement;
      iframe: HTMLIFrameElement;
      img: HTMLImageElement;
      input: HTMLInputElement;
      ins: HTMLModElement;
      kbd: HTMLElement;
      label: HTMLLabelElement;
      legend: HTMLLegendElement;
      li: HTMLLIElement;
      link: HTMLLinkElement;
      main: HTMLElement;
      map: HTMLMapElement;
      mark: HTMLElement;
      menu: HTMLMenuElement;
      meta: HTMLMetaElement;
      meter: HTMLMeterElement;
      nav: HTMLElement;
      noscript: HTMLElement;
      object: HTMLObjectElement;
      ol: HTMLOListElement;
      optgroup: HTMLOptGroupElement;
      option: HTMLOptionElement;
      output: HTMLOutputElement;
      p: HTMLParagraphElement;
      picture: HTMLPictureElement;
      pre: HTMLPreElement;
      progress: HTMLProgressElement;
      q: HTMLQuoteElement;
      rp: HTMLElement;
      rt: HTMLElement;
      ruby: HTMLElement;
      s: HTMLElement;
      samp: HTMLElement;
      script: HTMLScriptElement;
      section: HTMLElement;
      select: HTMLSelectElement;
      slot: HTMLSlotElement;
      small: HTMLElement;
      source: HTMLSourceElement;
      span: HTMLSpanElement;
      strong: HTMLElement;
      style: HTMLStyleElement;
      sub: HTMLElement;
      summary: HTMLElement;
      sup: HTMLElement;
      table: HTMLTableElement;
      tbody: HTMLTableSectionElement;
      td: HTMLTableCellElement;
      template: HTMLTemplateElement;
      textarea: HTMLTextAreaElement;
      tfoot: HTMLTableSectionElement;
      th: HTMLTableCellElement;
      thead: HTMLTableSectionElement;
      time: HTMLTimeElement;
      title: HTMLTitleElement;
      tr: HTMLTableRowElement;
      track: HTMLTrackElement;
      u: HTMLElement;
      ul: HTMLUListElement;
      var: HTMLElement;
      video: HTMLVideoElement;
      wbr: HTMLElement;
    } => ({ ...props });

Lastly, you can make a createAllTheElements reusable function with this type by doing

    export const createAllTheElements = (
      props: keyof DocumentCreateElementTagNameTandem
    ):
      | HTMLElement
      | HTMLObjectElement
      | HTMLMapElement
      | HTMLLinkElement
      | HTMLAnchorElement
      | HTMLAreaElement
      | HTMLAudioElement
      | HTMLBaseElement
      | HTMLQuoteElement
      | HTMLBodyElement
      | HTMLBRElement
      | HTMLButtonElement
      | HTMLCanvasElement
      | HTMLTableCaptionElement
      | HTMLTableColElement
      | HTMLDataElement
      | HTMLDataListElement
      | HTMLModElement
      | HTMLDetailsElement
      | HTMLDialogElement
      | HTMLDivElement
      | HTMLDListElement
      | HTMLEmbedElement
      | HTMLFieldSetElement
      | HTMLFormElement
      | HTMLHeadingElement
      | HTMLHeadElement
      | HTMLHRElement
      | HTMLHtmlElement
      | HTMLIFrameElement
      | HTMLImageElement
      | HTMLInputElement
      | HTMLLabelElement
      | HTMLLegendElement
      | HTMLLIElement
      | HTMLMenuElement
      | HTMLMetaElement
      | HTMLMeterElement
      | HTMLOListElement
      | HTMLOptGroupElement
      | HTMLOptionElement
      | HTMLOutputElement
      | HTMLParagraphElement
      | HTMLPictureElement
      | HTMLPreElement
      | HTMLProgressElement
      | HTMLSlotElement
      | HTMLScriptElement
      | HTMLSelectElement
      | HTMLSourceElement
      | HTMLSpanElement
      | HTMLStyleElement
      | HTMLTableElement
      | HTMLTemplateElement
      | HTMLTableSectionElement
      | HTMLTableCellElement
      | HTMLTextAreaElement
      | HTMLTimeElement
      | HTMLTitleElement
      | HTMLTableRowElement
      | HTMLTrackElement
      | HTMLUListElement
      | HTMLVideoElement => document.createElement(props);
Andrew Ross
  • 1,094
  • 7
  • 16
  • To avoid this extra login I am rendering a component, it should do the trick I have missed creating the element. Your suggestion seem too complex for me. Thanks for your time! – manou Dec 20 '22 at 23:55
0

What I finally have done is the following test:

describe("useScroll", () => {

  const scrollHeight = 2392;
  const clientHeight = 767;
  const scrollTop = scrollHeight - clientHeight;
  
  test("should return displayScroller false if the scroll is at the bottom", () => {
    const { container } = render(<div></div>);

    jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => scrollHeight);
    jest.spyOn(container, "clientHeight", "get").mockImplementation(() => clientHeight);

    const { result } = renderHook(() => useScroll(container));

    container.addEventListener("scroll", result.current.handleScroll);

    fireEvent.scroll(container, { target: { scrollTop } });
    expect(result.current.displayScroller).toBeFalsy();
  });

  test("should return displayScroller true if the scroll is not at the bottom", () => {
    const { container } = render(<div></div>);

    jest.spyOn(container, "scrollHeight", "get").mockImplementation(() => scrollHeight);
    jest.spyOn(container, "clientHeight", "get").mockImplementation(() => clientHeight);

    const { result } = renderHook(() => useScroll(container));

    container.addEventListener("scroll", result.current.handleScroll);

    for (let i = 0; i < scrollTop; i++) {
      fireEvent.scroll(container, { target: { scrollTop: i } });
      expect(result.current.displayScroller).toBeTruthy();
    }
  });
});

what it is testing the hook behavieur of scrolling and returning the correspondant value to display or not display the scroll.

Thanks everyone here to contribute on this.

References:

Mocking clientHeight and scrollHeight in React + Enzyme for test


One of the components where I use the custom hook:

import ... 
...
import { useScroll } from "../../../hooks/useScroll";
...
import { Contents } from "./Contents";

export interface IndiceProps {
  publications: Publication[];
  isHamburguer?: boolean;
}

export function MenuIndex({ publications, isHamburguer=false }: IndiceProps) {
  ...
  const containerRef = useRef<HTMLUListElement>(null);
  const { displayScroller, scrollerTop, scrollerLeft, handleScroll } =
    useScroll(containerRef.current as HTMLUListElement);

    ...

  return (
    <>
      <Box
        className={`
          ${publicationStyles.hideScrollBar}
          ${isHamburguer 
              ? publicationStyles.hamburguerContainer 
              : publicationStyles.container}
        `}
        onScroll={handleScroll}
        ref={containerRef}
      >
        <Contents
          publications={publications}
          displayScroller={displayScroller}
          scrollerLeft={scrollerLeft}
        />
      </Box>
    </>
  );
}
E_net4
  • 27,810
  • 13
  • 101
  • 139
manou
  • 123
  • 2
  • 20
  • What is result.current.handleScroll? Is this function exposed only for the test? – Dávid Molnár Dec 22 '22 at 06:04
  • No, it is the handler I inject in the component I use this scrolling feature. I edit the answer with the code where I am using it. I use it in other components too. – manou Dec 22 '22 at 08:50