Testing element dimensions without the browser
Tags: typescript, react, testing, javascript
Published: 2020-12-18 01:49:59

[React Testing Library](https://testing-library.com/docs/react-testing-library/intro) 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.

```tsx
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>
  );
};
```

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](https://www.cypress.io/) 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get) and [setters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/set).

```tsx
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();
  });
});
```

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.

{% codesandbox conditional-tooltip-7nimw %}