Test document listener with React Testing Library - reactjs

I'm attempting to test a React component similar to the following:
import React, { useState, useEffect, useRef } from "react";
export default function Tooltip({ children }) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef(null);
const handleClickOutside = (event) => {
if (
open &&
wrapperRef.current &&
!wrapperRef.current.contains(event.target)
) {
setOpen(false);
}
};
useEffect(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
const className = `tooltip-wrapper${(open && " open") || ""}`;
return (
<span ref={wrapperRef} className={className}>
<button type="button" onClick={() => setOpen(!open)} />
<span>{children}</span>
<br />
<span>DEBUG: className is {className}</span>
</span>
);
}
Clicking on the tooltip button changes the state to open (changing the className), and clicking again outside of the component changes it to closed.
The component works (with appropriate styling), and all of the React Testing Library (with user-event) tests work except for clicking outside.
it("should close the tooltip on click outside", () => {
// Arrange
render(
<div>
<p>outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Temporary assertion - passes
expect(button.parentElement).toHaveClass("open");
// Act
const outside = screen.getByText("outside");
// Gives should be wrapped into act(...) warning otherwise
act(() => {
userEvent.click(outside);
});
// Assert
expect(button.parentElement).not.toHaveClass("open"); // FAILS
});
I don't understand why I had to wrap the click event in act - that's generally not necessary with React Testing Library.
I also don't understand why the final assertion fails. The click handler is called twice, but open is true both times.
There are a bunch of articles about limitations of React synthetic events, but it's not clear to me how to put all of this together.

I finally got it working.
it("should close the tooltip on click outside", async () => {
// Arrange
render(
<div>
<p data-testid="outside">outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Verify initial state
expect(button.parentElement).toHaveClass("open");
const outside = screen.getByTestId("outside");
// Act
userEvent.click(outside);
// Assert
await waitFor(() => expect(button.parentElement).not.toHaveClass("open"));
});
The key seems to be to be sure that all activity completes before the test ends.
Say a test triggers a click event that in turn sets state. Setting state typically causes a rerender, and your test will need to wait for that to occur. Normally you do that by waiting for the new state to be displayed.
In this particular case waitFor was appropriate.

Related

Simulate user navigation with React Testing Library and React Router

I have a component that is meant to trigger when the user navigates away from a page. It wraps a formik form; if a user has unsaved changes, it attempts to save those changes as soon as the user attempts to navigate away. While the save is resolving, users will see a modal that says "saving..." If the save is successful, the user continues on to the next page. If it is unsuccessful, it displays a modal prompting them to either stay or move along. The component works fine, but I'm struggling to test it.
Component in question:
const AutoSaveUnsavedChangesGuard: React.FC<Props> = ({
when,
onLeave,
children,
ignoreRoutes = [],
submitForm,
}) => {
const { push } = useHistory();
const { error, savingStatus } = useSavingStatusContext();
const [nextLocation, setNextLocation] = React.useState<string>();
const [isShowing, setIsShowing] = React.useState<boolean>(false);
const [showUnsavedChangesModal, setShowUnsavedChangesModal] = React.useState<boolean>(false);
const [showSavingModal, setShowSavingModal] = React.useState<boolean>(false);
const handleBlockNavigation = (nextLocation: Location) => {
if (!!matchPath(nextLocation.pathname, ignoreRoutes)) {
return true;
}
setNextLocation(nextLocation.pathname);
setIsShowing(true);
submitForm();
return false;
};
React.useEffect(() => {
// Proceed to next location when there has been a navigation attempt and client no longer blocks it
if (!when && nextLocation) {
push(nextLocation);
}
}, [when, nextLocation, push]);
React.useEffect(() => {
// If we have an error and we have triggered the Prompt, display the unsaved changes guard.
setShowUnsavedChangesModal(!!error)
}, [error]);
React.useEffect(() => {
setShowSavingModal(savingStatus=== SavingStatusType.SAVING)
}, [savingStatus]);
return (
<React.Fragment>
<Prompt when={when} message={handleBlockNavigation}/>
<UnsavedChangesModal
show={showUnsavedChangesModal && isShowing}
onLeave={() => {
onLeave && onLeave();
}}
onStay={() => {
setNextLocation(undefined);
}}
onHide={() => {
setIsShowing(false);
setShowUnsavedChangesModal(false);
}}
/>
<SavingModal show={showSavingModal && isShowing} />
{children}
</React.Fragment>
);
};
export default AutoSaveUnsavedChangesGuard;
I'm trying to test behavior with react-testing-library. I'd like to simulate a user navigating away (IE call the message method in the rendered component with a new location), but I am struggling to do so. We had a function like the one below when we tested using enzyme.
const changeRouteLocation = (nextLocation: Location, wrapper: ShallowWrapper) => {
const prompt = wrapper.find(ReactRouter.Prompt);
const onNavigate = prompt.props().message as (location: Location) => string | boolean;
onNavigate(nextLocation);
};
Unfortunately, this component uses useEffect hooks that don't play nice with enzyme, and I must test it using react-testing-library. How can I simulate a user attempting to navigate to a new location with react-testing-library?
Edit: Adding what I have for testing code per a request. This code does not produce the desired outcome, and I honestly didn't expect it to.
const RenderingComponent = ({initialEntries})=>{
return(
<ThemeProvider>
<MemoryRouter initialEntries={initialEntries}>
<AutoSaveUnsavedChangesGuard {...defaults} />
</MemoryRouter>
</ThemeProvider>
)
}
beforeEach(() => {
jest.spyOn(ReactRouter, 'useHistory').mockReturnValue(makeHistory());
useSavingStatusContextSpy = jest.spyOn(useAutoSaveContextModule, 'useSavingStatusContext')
});
it('should render default. It should not show any modals when there are no errors and the route has not changed.', async () => {
// Default rendering. Works fine, because it's not meant to display anything.
const wrapper = render(
<RenderingComponent initialEntries={['/initial-value']} />
)
expect(screen.queryByText('Saving...')).toBeNull();
expect(screen.queryByText('Unsaved Changes')).toBeNull();
expect(wrapper).toMatchSnapshot()
});
it('should show the saving modal when the route location changes and saving status context is of type SAVING',()=>{
useSavingStatusContextSpy.mockReturnValueOnce(makeAutoSaveContext({savingStatus: SavingStatusType.SAVING}))
const {rerender, debug} = render(
<RenderingComponent initialEntries={["initial-value"]} />
)
rerender(<RenderingComponent initialEntries={['/mock-value','/mock-some-new-value']} />)
// I had hoped that re-rendering with new values for initial entries would trigger a navigation event for the prompt to block. It did not work.
debug()
const savingModal = screen.getByText('Saving...');
expect(savingModal).toBeVisible();
})
})

clickOutside hook triggers on inside select

I have a card component which consists of 2 selects and a button, select1 is always shown and select2 is invisible until you press the button changing the state. I also have an onClickOutside hook that reverts the state and hides select2 when you click outside the card.
The problem Im having is that in the case when select2 is visible, if you use any select and click on an option it registers as a click outside the card and hides select2, how can I fix this?
Heres the relevant code from my card component:
const divRef = useRef() as React.MutableRefObject<HTMLInputElement>;
const [disableSelect2, setDisableSelect2] = useState(true);
const handleActionButtonClick = () => {
setDisableSelect2(!disableSelect2)
}
useOutsideClick(divRef, () => {
if (!disableSelect2) {
setDisableSelect2(!disableSelect2);
}
});
return (
<div ref={divRef}>
<Card>
<Select1>[options]</Select1>
!disableSelect2 ?
<Select2>[options]</Select2>
: null
<div
className="d-c_r_action-button"
onClick={handleActionButtonClick}
>
</Card>
</div>
);
};
And this is my useoutsideClick hook
const useOutsideClick = (ref:React.MutableRefObject<HTMLInputElement>, callback:any) => {
const handleClick = (e:any) => {
if (ref.current && !ref.current.contains(e.target)) {
callback();
}
};
useEffect(() => {
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
});
};
Extra informtaion: Im using customized antd components and cant use MaterialUI
I tried to recreate your case from the code you shared. But the version I 'built' works.
Perhaps you can make it fail by adding in other special features from your case and then raise the issue again, or perhaps you could use the working code from there to fix yours?
See the draft of your problem I made at https://codesandbox.io/s/serverless-dust-njw0f?file=/src/Component.tsx

How can I test a click event which changes a useState state with enzyme?

I have the following component:
import React, { useState } from "react";
import { Button, ThirteenBold } from "#selfdecode/sd-component-library";
import { PlayIcon } from "assets/icons";
import { TourButtonProps } from "./interfaces";
import { WelcomeVideoModal } from "../../modals/welcome-video-modal";
/**
* The tour button.
*/
export const TourButton: React.FC<TourButtonProps> = (props) => {
const [isIntroVideoShowing, setIsIntroVideoShowing] = useState(false);
return (
<>
<WelcomeVideoModal
isOpen={isIntroVideoShowing}
onClickX={() => setIsIntroVideoShowing(false)}
data-test="tour-button-welcome-video"
/>
<Button
{...props}
width={["max-content"]}
variant="tour"
onClick={() => setIsIntroVideoShowing(true)}
data-test="tour-button"
>
<ThirteenBold
mr={["12px"]}
color="cl_blue"
width={["max-content"]}
letterSpacing={["1px"]}
display={["none", "block"]}
textTransform="uppercase"
>
welcome tour
</ThirteenBold>
<PlayIcon style={{ height: "30px", fill: "#4568F9" }} />
</Button>
</>
);
};
And the test coverage report is complaining that I am not testing both of the onClick events, which change the state.
I've tried two approaches, and both fail.
Approach one was to mock the useState and see if it gets called as I'd have expected it.
This was the test I tried:
const setState = jest.fn();
const useStateMock: any = (initState: any) => [initState, setState];
jest.spyOn(React, "useState").mockImplementation(useStateMock);
const button = wrapper.find(`[data-test="tour-button"]`);
expect(button).toHaveLength(1);
button.simulate("click");
expect(setState).toHaveBeenCalled();
This shouldn't even be the final test, as it doesn't check what was the valuee it was called with, but still, it failed because useState wasn't even called.
The second approach I've tried was to check the prop value on this component:
<WelcomeVideoModal
isOpen={isIntroVideoShowing}
onClickX={() => setIsIntroVideoShowing(false)}
data-test="tour-button-welcome-video"
/>
And this is the test I've tried
test("Check the isIntroVideoShowing changes to true on buton click", () => {
jest.spyOn(React, "useState").mockImplementation(useStateMock);
const button = wrapper.find(`[data-test="tour-button"]`);
const welcomeVideo = wrapper.find(
`[data-test="tour-button-welcome-video"]`
);
expect(button).toHaveLength(1);
expect(welcomeVideo.prop("isOpen")).toEqual(false);
button.simulate("click");
expect(welcomeVideo.prop("isOpen")).toEqual(true);
});
This one failed claiming it was called with false even after the click.
Is there a way to make these work? Or a different approach to cover these?
You need to give wrapper.update for updating the template with state changes after simulating the click event.
test("Check the isIntroVideoShowing changes to true on buton click", () => {
jest.spyOn(React, "useState").mockImplementation(useStateMock);
const button = wrapper.find(`[data-test="tour-button"]`);
const welcomeVideo = wrapper.find(
`[data-test="tour-button-welcome-video"]`
);
expect(button).toHaveLength(1);
expect(welcomeVideo.prop("isOpen")).toEqual(false);
button.simulate("click");
wrapper.update();
expect(welcomeVideo.prop("isOpen")).toEqual(true);
});
Reference - https://enzymejs.github.io/enzyme/docs/api/ShallowWrapper/update.html

Tooltip delay on hover with RXJS

I'm trying to add tooltip delay (300msemphasized text) using rxjs (without setTimeout()). My goal is to have this logic inside of TooltipPopover component which will be later be reused and delay will be passed (if needed) as a prop.
I'm not sure how can I add "delay" logic inside of TooltipPopover component using rxjs?
Portal.js
const Portal = ({ children }) => {
const mount = document.getElementById("portal-root");
const el = document.createElement("div");
useEffect(() => {
mount.appendChild(el);
return () => mount.removeChild(el);
}, [el, mount]);
return createPortal(children, el);
};
export default Portal;
TooltipPopover.js
import React from "react";
const TooltipPopover = ({ delay??? }) => {
return (
<div className="ant-popover-title">Title</div>
<div className="ant-popover-inner-content">{children}</div>
);
};
App.js
const App = () => {
return (
<Portal>
<TooltipPopover>
<div>
Content...
</div>
</TooltipPopover>
</Portal>
);
};
Then, I'm rendering TooltipPopover in different places:
ReactDOM.render(<TooltipPopover delay={1000}>
<SomeChildComponent/>
</TooltipPopover>, rootEl)
Here would be my approach:
mouseenter$.pipe(
// by default, the tooltip is not shown
startWith(CLOSE_TOOLTIP),
switchMap(
() => concat(timer(300), NEVER).pipe(
mapTo(SHOW_TOOLTIP),
takeUntil(mouseleave$),
endWith(CLOSE_TOOLTIP),
),
),
distinctUntilChanged(),
)
I'm not very familiar with best practices in React with RxJS, but this would be my reasoning. So, the flow would be this:
on mouseenter$, start the timer. concat(timer(300), NEVER) is used because although after 300ms the tooltip should be shown, we only want to hide it when mouseleave$ emits.
after 300ms, the tooltip is shown and will be closed mouseleave$
if mouseleave$ emits before 300ms pass, the CLOSE_TOOLTIP will emit, but you could avoid(I think) unnecessary re-renders with the help of distinctUntilChanged

Testing if button changes state, or if component appears (React)

I have a component with a button and a form. When button is visible, the form is hidden and the opposite - when we click button it dissapears and form is shown. I would like to test it either with enzyme or testing-library, but all my tests fail.
import React, { useState } from 'react';
import Form from './Form';
const FormComponent = () => {
const [isFormVisible, setFormVisibility] = useState(false);
function toggleFormVisibility() {
setFormVisibility(!isFormVisible);
}
return (
<section>
{!isFormVisible && (
<button
id='toggle-form-button'
data-testid='toggle-form-button'
onClick={toggleFormVisibility}
>
Show form
</button>
)}
{isFormVisible && <Form onCancel={toggleFormVisibility} />}
</section>
);
};
export default FormComponent;
My test:
describe('Form component', () => {
it('should fire toggle form action on button click', () => {
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState');
useStateSpy.mockImplementation(() => [undefined, setState]);
const component = render(
<Form />
);
const showFormButton = component.getByTestId('toggle-form-button');
Simulate.click(showFormButton);
expect(showFormButton).toBeCalled();
});
});
and another one:
it('should fire toggle form action on button click', () => {
const toggleFormVisibility = jest.fn();
const component = render(
<Form />
);
const showFormButton = component.getByTestId('toggle-form-button');
Simulate.click(showFormButton);
expect(toggleFormVisibility).toBeCalled();
});
It looks like in your tests, you are trying to render the <Form> instead of the <FormComponent>, that might be causing the problem in your test.
Also in your 2nd test, you are not setting up the toggleFormVisibility mocked function with your component, so that wouldn't be invoked at all, the answer above is pretty reasonable, you might want to consider giving that a shot, not sure why it gets downvoted.
testing-library may make this test easier:
import { render, fireEvent } from '#testing-library/react'
render(<Form />);
fireEvent.click(screen.getByLabelText('Show form'));

Resources