How to test for document being undefined with RTL?

Viewed 185

I have the following react hook which brings focus to a given ref and on unmount returns the focus to the previously focused element.


export default function useFocusOnElement(elementRef: React.RefObject<HTMLHeadingElement>) {
  const documentExists = typeof document !== 'undefined';
  const [previouslyFocusedEl] = useState(documentExists && (document.activeElement as HTMLElement));

  useEffect(() => {
    if (documentExists) {
      elementRef.current?.focus();
    }

    return () => {
      if (previouslyFocusedEl) {
        previouslyFocusedEl?.focus();
      }
    };
  }, []);
}

Here is the test I wrote for it.

/**
 * @jest-environment jsdom
 */

describe('useFocusOnElement', () => {
  let ref: React.RefObject<HTMLDivElement>;
  let focusMock: jest.SpyInstance;

  beforeEach(() => {
    ref = { current: document.createElement('div') } as React.RefObject<HTMLDivElement>;
    focusMock = jest.spyOn(ref.current as HTMLDivElement, 'focus');
  });

  it('will call focus on passed ref after mount ', () => {
    expect(focusMock).not.toHaveBeenCalled();
    renderHook(() => useFocusOnElement(ref));
    expect(focusMock).toHaveBeenCalled();
  });
});

I would like to also test for the case where document is undefined as we also do SSR. In the hook I am checking for the existence of document and I would like to test for both cases.

JSDOM included document so I feel I'd need to remove it and some how catch an error in my test?

3 Answers

First of all, to simulate document as undefined, you should mock it like:

jest
   .spyOn(global as any, 'document', 'get')
   .mockImplementationOnce(() => undefined);

But to this work in your test, you will need to set spyOn inside renderHook because looks like it also makes use of document internally, and if you set spyOn before it, you will get an error.

Working test example:

it('will NOT call focus on passed ref after mount', () => {
    expect(focusMock).not.toHaveBeenCalled();

    renderHook(() => {
      jest
        .spyOn(global as any, 'document', 'get')
        .mockImplementationOnce(() => undefined);

      useFocusOnElement(ref);
    });

    expect(focusMock).not.toHaveBeenCalled();
});

You should be able to do this by creating a second test file with a node environment:

/**
 * @jest-environment node
 */

describe('useFocusOnElement server-side', () => {
  ...
});

I ended up using wrapWithGlobal and wrapWithOverride from https://github.com/airbnb/jest-wrap.


describe('useFocusOnElement', () => {
  let ref: React.RefObject<HTMLDivElement>;
  let focusMock: jest.SpyInstance;
  let activeElMock: unknown;
  let activeEl: HTMLDivElement;

  beforeEach(() => {
    const { window } = new JSDOM();
    global.document = window.document;
    activeEl = document.createElement('div');
    ref = { current: document.createElement('div') };
    focusMock = jest.spyOn(ref.current as HTMLDivElement, 'focus');
    activeElMock = jest.spyOn(activeEl, 'focus');
  });
  wrapWithOverride(
    () => document,
    'activeElement',
    () => activeEl,
  );
  describe('when document present', () => {
    it('will focus on passed ref after mount and will focus on previously active element on unmount', () => {
      const hook = renderHook(() => useFocusOnElement(ref));
      expect(focusMock).toHaveBeenCalled();
      hook.unmount();
      expect(activeElMock).toHaveBeenCalled();
    });
  });

  describe('when no document present', () => {
    wrapWithGlobal('document', () => undefined);

    it('will not call focus on passed ref after mount nor on previously active element on unmount', () => {
      const hook = renderHook(() => useFocusOnElement(ref));
      expect(focusMock).not.toHaveBeenCalled();
      hook.unmount();
      expect(activeElMock).not.toHaveBeenCalled();
    });
  });
});
Related