Testing element dimensions without the browser

tmikeschu

Mike Schutte

Posted on December 18, 2020

Testing element dimensions without the browser

React Testing Library takes the joy and possibilities of testing to the next level.

I ran into a case today at work where I wanted to assert a conditional tooltip. The tooltip should only show up if the label text was overflowing and cut off by an ellipsis.

Here is a simplified implementation of what I did.

import * as React from 'react';
import { Tooltip } from 'Tooltip';

// text overflow is triggered when the scroll width
// is greater than the offset width
const isCutOff = <T extends HTMLElement>(node: T | null) => 
  (node ? node.offsetWidth < node.scrollWidth : false);

export const useIsTextCutOff = <T extends HTMLElement>(
  ref: React.RefObject<T>
): [boolean, () => void] => {
  // since the ref is null at first, we need a stateful value
  // to set after we have a ref to the node
  const [isTextCutOff, setIsTextCutOff] = React.useState(
    isCutOff(ref.current)
  );

  // allow the caller to refresh on account of refs being 
  // outside of the render cycle
  const refresh = () => {
    setIsTextCutOff(isCutOff(ref.current));
  };

  return [isTextCutOff, refresh];
};

interface Props {
  href: string;
  label: string;
}

export const NameRenderer: React.FC<Props> = ({
  label,
  href
}) => {
  const labelRef = React.useRef<HTMLDivElement>(null);
  const [isTextCutOff, refresh] = useIsTextCutOff(labelRef);

  return (
    <div>
      <Tooltip showTooltip={isTextCutOff} tooltip={label}>
        <div
          // adds ellipsis on overflow
          className="truncate-text"
          onMouseEnter={refresh} 
          ref={labelRef}
        >
          <a href={href}>
            {label}
          </a>
        </div>
      </Tooltip>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Because the ref.current value starts as null, I can't compute the width on the initial render. To solve this problem, I used the onMouseEnter event to check the element width once someone actually hovers over it. We can be confident ref.current will be defined by then.

Cypress would be a great way to test this as well, but the screen I'm on in this context requires auth and specific test data set up that's easier to do at a component integration test level.

The key here is to intervene with how React handles ref props. With hooks, you just assign a name to a React.useRef(null) result and pass that to a node like <div ref={someRef} />.

When you inspect the width on that virtual node, you'll get a big fat 🍩. There is no actually painted element with a width to measure.

So, we'll spy on React.useRef with jest.spyOn and use get and set functions from good ol' JavaScript getter and setters.

import * as React from 'react';
import * as utils from '@testing-library/react';
import user from '@testing-library/user-event';
import { NameRenderer } from '.';

describe('Components: NameRenderer', () => {
  const props = {
    href: "blah blah",
    label: "halb halb",
  };

  type NodeWidth = Pick<
    HTMLElement,
    'offsetWidth' | 'scrollWidth'
  >;

  const setMockRefElement = (node: NodeWidth): void => {
    const mockRef = {
      get current() {
        // jest dom elements have no width,
        // so mocking a browser situation
        return node;
      },
      // we need a setter here because it gets called when you 
      // pass a ref to <component ref={ref} />
      set current(_value) {},
    };

    jest.spyOn(React, 'useRef').mockReturnValue(mockRef);
  };

  it('shows a tooltip for cutoff text', async () => {
    setMockRefElement({ offsetWidth: 1, scrollWidth: 2 });

    const { getByRole } = utils.render(
      <NameRenderer {...props} />
    );
    const checklist = getByRole(
      'link',
      { name: new RegExp(props.label) }
    );

    expect(utils.screen.queryByRole('tooltip'))
      .not.toBeInTheDocument();

    user.hover(checklist);

    expect(utils.screen.getByRole('tooltip'))
      .toBeInTheDocument();

    user.unhover(checklist);

    await utils.waitForElementToBeRemoved(
      () => utils.screen.queryByRole('tooltip')
    );
  });


  afterEach(() => {
    jest.resetAllMocks();
  });
});
Enter fullscreen mode Exit fullscreen mode

The setMockRefElement utility makes it easy to test different variations of the offsetWidth to scrollWidth ratio. With that visual part of the specification mocked, we can return to the lovely query and user event APIs brought to us by Testing Library.

Here is a full demo.

💖 💪 🙅 🚩
tmikeschu
Mike Schutte

Posted on December 18, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related