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>
);
};
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();
});
});
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.
Top comments (2)
I had expected something more dramatic, e.g. a way to add a CSSOM to JSDOM that would allow actual calculations. Still, this method is good enough for testing and so, thanks for sharing!
Oh yeah that's an interesting idea! I'll take the good enough for testing route for now as well 😊