Hey guys I've got a component that overrides the back button behavior by creating a popstate event, but I haven't found a way to test it's functionality. It should be as easy as creating a spy and checking if the window.confirm is being called, but it's not calling the function when I do window.history.back(), and I don't understand why.
Also if I pull the function outside of the component, it's being rendered as an anonymous function and the remove event listener isn't being called, which makes the popup event display on every page, and there's no way to remove it because it's an anonymous function. I'm able to test the function though and the logic is working just fine (;
How do we fix this? What should I do?
This function stops the initial back button navigation behavior, and creates a popup event to ask if the person wants to navigate to the home page, and if they click okay then they navigate, otherwise they stay on the page. We have this wrapped around a page after they submit a form to prevent them from submitting another form when they click the back button
Here's my component:
import React, {useEffect, ReactElement} from 'react';
import { navigate } from '#reach/router';
export interface BackButtonBehaviorProps {
children: ReactElement;
}
export const BackButtonBehavior = ({children}: BackButtonBehaviorProps) => {
const onBackButtonEvent = (e: { preventDefault: () => void; }) => {
e.preventDefault();
const backButtonIsConfirmed = window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?");
if (backButtonIsConfirmed) {
navigate('/');
} else {
window.history.pushState(window.history.state, "success page", window.location.pathname); // When you click back (this refreshes the current instance)
}
};
useEffect(() => {
window.history.pushState(window.history.state, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent, true);
// As you're unmounting the component
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
return (children);
};
If I pull the function outside of the component and export it, it's rendered in the popstate event listeners as anonymous, and will not be deleted when I'm unmounting the component, and there's no way to fix that. Here are the tests that work when I exported it though:
import {cleanup, render} from '#testing-library/react';
import * as router from '#reach/router';
import { mockPersonalReturnObj } from '../Summary/testData';
describe('<App />', () => {
let navigateSpy: any;
let backButtonBehavior: any;
beforeEach(() => {
const mod = require('./BackButtonBehavior');
backButtonBehavior = mod.onBackButtonEvent;
navigateSpy = jest.spyOn(router, 'navigate');
});
afterEach(() => {
jest.resetAllMocks();
cleanup();
});
it('should display a popup when the user clicks the back button', async () => {
jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true);
backButtonBehavior({preventDefault: () => {}});
expect(global.confirm).toHaveBeenCalled();
});
it('should navigate to home page when you click ok on the window.confirm popup', async () => {
jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true);
backButtonBehavior({preventDefault: () => {}});
expect(global.confirm).toHaveBeenCalled();
expect(await navigateSpy).toHaveBeenCalledWith('/');
});
});
I haven't found a way to call the global confirm when I do a test and I literally just want to test if the window.confirm event is being called (there's a bunch of ways to check window.confirm through spies, none of them worked). I need a way to simulate the back button click event for this to be called, but I haven't found a way to do this. Here's a test example:
it('should navigate to home page when you click ok on the window.confirm popup', async () => {
jest.spyOn(global, 'confirm' as any).mockReturnValueOnce(true);
render(
<BackButtonBehavior>
<CurrentPage />
</BackButtonBehavior>
);
window.history.back();
expect(global.confirm).toHaveBeenCalled();
expect(await navigateSpy).toHaveBeenCalledWith('/', {});
});
How do we simulate clicking the back button in the browser for react tests?
Related
I found lots of ways of using mock functions in jest to spy on callback functions that are passed down to a component but nothing on testing a simple onClick that is defined in the same component.
My Example Page:
const ExamplePage: NextPage = () => {
const router = useRouter();
const onClick = (): Promise<void> => {
axios.post(`/api/track`, {
eventName: Event.TRACK_CLICK,
});
router.push("/new-route");
return Promise.resolve();
};
return (
<Container data-testid="container">
<Title>Example Title</Title>
<CreateButton data-testid="create-button" onClick={onClick}>
Create Partner
</CreateButton>
</Container>
);
};
export default ExamplePage;
My current test where I am attempting to get the onClick from getAttribute:
import { fireEvent, render } from "../../../../test/customRenderer";
import ExamplePage from "../../../pages/example-page";
describe("Example page", () => {
it("has a button to create", () => {
const { getByTestId } = render(<ExamplePage />);
const createButton = getByTestId("create-button");
expect(createButton).toBeInTheDocument();
});
it(" the button's OnClick function should be executed when clicked", () => {
const { getByTestId } = render(<ExamplePage />);
// find the button
const createButton = getByTestId("create-button");
// check the button has onClick
expect(createButton).toHaveAttribute("onClick");
// get the onClick function
const onClick = createButton.getAttribute("onClick");
fireEvent.click(createButton);
// check if the button's onClick function has been executed
expect(onClick).toHaveBeenCalled();
});
});
The above fails since there is no onClick attribute only null. My comments in the test highlight my thought process of trying to reach down into this component for the function on the button and checking if it has been called.
Is there any way to test a onClick that is self contained in a react component?
You need to provide mocked router provider and expect that a certain route is pushed to the routers. You also need extract the RestAPI into a separate module and mock it! You can use Dependency Injection, IOC container or import the Api in the component and mock it using jest. I will leave the RestAPi mocking to you.
Mocking router details here: How to mock useRouter
const useRouter = jest.spyOn(require('next/router'), 'useRouter')
describe("", () => {
it("",() => {
const pushMock = jest.fn();
// Mocking Rest api call depends on how you are going to "inject it" in the component
const restApiMock = jest.jn().mockResolvedValue();
useRouter.mockImplementationOnce(() => ({
push: pushMock,
}))
const rendrResult = render(<ExamplePage />);
//get and click the create button
//expect the "side" effects of clicking the button
expect(restApiMock).toHaveBeenCalled();
expect(pushMock).toHaveBeenCalledWith("/new-route");
});
});
I have "react-dom-router v6.3.0" (strictly!) now and I couldn't understand how to handle browser's "back" button. For example I need to catch the event, so I could open warning modal that user leaves the page after clicking back. At least give me a direction, please.
I'm using Typescript 4.4.2.
The useBackListener:
import { useEffect, useContext } from "react";
import { NavigationType, UNSAFE_NavigationContext } from "react-router-dom";
import { History, Update } from "history";
const useBackListener = (callback: (...args: any) => void) => {
const navigator = useContext(UNSAFE_NavigationContext).navigator as History;
useEffect(() => {
const listener = ({ location, action }: Update) => {
console.log("listener", { location, action });
if (action === NavigationType.Pop) {
callback({ location, action });
}
};
const unlisten = navigator.listen(listener);
return unlisten;
}, [callback, navigator]);
};
Then usage:
useBackListener(({ location }) => {
if (isDirty) {
setOpenWarning(true)
} else navigate("go back")
})
How to open modal if form is dirty without redirecting after clicking browser's "back" button ? Also, is it possible to avoid #ts-ignore?
You can create your own Custom Router with history object which will help you to listen actions such as "POP" for react-router-dom v6.
To create custom route you may want to follow these steps: https://stackoverflow.com/a/70646548/13943685
This how React Router specific history object comes into play. It provides a way to "listen for URL" changes whether the history action is push, pop, or replace
let history = createBrowserHistory();
history.listen(({ location, action }) => {
// this is called whenever new locations come in
// the action is POP, PUSH, or REPLACE
});
OR you can also use
window.addEventListener("popstate", () => {
// URL changed!
});
But that only fires when the user clicks the back or forward buttons. There is no event for when the programmer called window.history.pushState or window.history.replaceState.
I've been looking for this question and found it but they're using class components and react router dom v5
What i want is
When user click browser back button I'll redirect them to home page
If you are simply wanting to run a function when a back navigation (POP action) occurs then a possible solution is to create a custom hook for it using the exported NavigationContext.
Example:
import { UNSAFE_NavigationContext } from "react-router-dom";
const useBackListener = (callback) => {
const navigator = useContext(UNSAFE_NavigationContext).navigator;
useEffect(() => {
const listener = ({ location, action }) => {
console.log("listener", { location, action });
if (action === "POP") {
callback({ location, action });
}
};
const unlisten = navigator.listen(listener);
return unlisten;
}, [callback, navigator]);
};
Usage:
import { useNavigate } from 'react-router-dom';
import { useBackListener } from '../path/to/useBackListener';
...
const navigate = useNavigate();
useBackListener(({ location }) =>
console.log("Navigated Back", { location });
navigate("/", { replace: true });
);
If using the UNSAFE_NavigationContext context is something you'd prefer to avoid then the alternative is to create a custom route that can use a custom history object (i.e. from createBrowserHistory) and use the normal history.listen. See my answer here for details.
Update w/ Typescript
import { useEffect, useContext } from "react";
import { NavigationType, UNSAFE_NavigationContext } from "react-router-dom";
import { History, Update } from "history";
const useBackListener = (callback: (...args: any) => void) => {
const navigator = useContext(UNSAFE_NavigationContext).navigator as History;
useEffect(() => {
const listener = ({ location, action }: Update) => {
console.log("listener", { location, action });
if (action === NavigationType.Pop) {
callback({ location, action });
}
};
const unlisten = navigator.listen(listener);
return unlisten;
}, [callback, navigator]);
};
Well after a long journey to find out how to do that finally i came up with this solution
window.onpopstate = () => {
navigate("/");
}
I came up with a pretty robust solution for this situation, just using browser methods, since react-router-v6's API is pretty sketchy in this department right now.
I push on some fake history identical to the current route (aka a buffer against the back button). Then, I listen for a popstate event (back button event) and fire whatever JS I need, which in my case unmounts the component. If the component unmounts WITHOUT the use of the back button, like by an onscreen button or other logic, we just clean up our fake history using useEffect's callback. Phew. So it looks like:
function closeQuickView() {
closeMe() // do whatever you need to close this component
}
useEffect(() => {
// Add a fake history event so that the back button does nothing if pressed once
window.history.pushState('fake-route', document.title, window.location.href);
addEventListener('popstate', closeQuickView);
// Here is the cleanup when this component unmounts
return () => {
removeEventListener('popstate', closeQuickView);
// If we left without using the back button, aka by using a button on the page, we need to clear out that fake history event
if (window.history.state === 'fake-route') {
window.history.back();
}
};
}, []);
You can go back by using useNavigate hook, that has become with rrd v6
import {useNabigate} from "react-router-dom";
const App = () => {
const navigate = useNavigate();
const goBack = () => navigate(-1);
return (
<div>
...
<button onClick={goBack}>Go back</button>
...
</div>
)
}
export App;
I used <Link to={-1}>go back</Link> and its working in v6, not sure if it's a bug or a feature but seems there is no error in console and can't find any documentation stating this kind of approach
You can try this approach. This worked for me.
import { useNavigate, UNSAFE_NavigationContext } from "react-router-dom";
const navigation = useContext(UNSAFE_NavigationContext).navigator;
const navigate = useNaviagte();
React.useEffect(() => {
let unlisten = navigation.listen((locationListener) => {
if (locationListener.action === "POP") {
//do your stuff on back button click
navigate("/");
}
});
return(() => {
unlisten();
})
}, []);
I'm on rrd#6.8 and testing John's answer worked for me right away for a simple "GO back 1 page", no useNavigate needed:
<Link to={-1}>
<Button size="sm">← Back </Button>
</Link>
So as a simple back button this seems to work without unexpected errors.
If I've got a function that creates a confirm popup when you click the back button, I want to save the state before navigating back to the search page. The order is a bit odd, there's a search page, then a submit form page, and the summary page. I have replace set to true in the reach router so when I click back on the summary page it goes to the search page. I want to preserve the history and pass the state of the submitted data into history, so when I click forward it goes back to the page without error.
I've looked up a bunch of guides and went through some of the docs, I think I've got a good idea of how to build this, but in this component we're destructuring props, so how do I pass those into the state variable of history?
export const BaseSummary = ({successState, children}: BaseSummaryProps) => {
let ref = createRef();
const [pdf, setPdf] = useState<any>();
const [finishStatus, setfinishStatus] = useState(false);
const onBackButtonEvent = (e) => {
e.preventDefault();
if (!finishStatus) {
if (window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?")) {
setfinishStatus(true);
props.history.push(ASSOCIATE_POLICY_SEARCH_ROUTE); // HERE
} else {
window.history.pushState({state: {successState: successState}}, "", window.location.pathname);
setfinishStatus(false);
}
}
};
useEffect(() => {
window.history.pushState(null, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent);
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
Also I'm not passing in the children var because history does not clone html elements, I just want to pass in the form data that's returned for this component to render the information accordingly
first of all, I think you need to use "useHistory" to handling your hsitry direct without do a lot of complex condition, and you can check more from here
for example:
let history = useHistory();
function handleClick() {
history.push("/home");
}
now, if you need to pass your history via props in this way or via your code, just put it in function and pass function its self, then when you destruct you just need to write your function name...for example:
function handleClick() {
history.push("/home");
}
<MyComponent onClick={handleClick} />
const MyComponent = ({onClick}) => {....}
I fixed it. We're using reach router, so everytime we navigate in our submit forms pages, we use the replace function like so: {replace: true, state: {...stateprops}}. Then I created a common component that overrides the back button functionality, resetting the history stack every time i click back, and using preventdefault to stop it from reloading the page. Then I created a variable to determine whether the window.confirm was pressed, and when it is, I then call history.back().
In some scenarios where we went to external pages outside of the reach router where replace doesn't work, I just used window.history.replaceStack() before the navigate (which is what reach router is essentially doing with their call).
Anyways you wrap this component around wherever you want the back button behavior popup to take effect, and pass in the successState (whatever props you're passing into the current page you're on) in the backButtonBehavior function.
Here is my code:
import React, {useEffect, ReactElement} from 'react';
import { StateProps } from '../Summary/types';
export interface BackButtonBehaviorProps {
children: ReactElement;
successState: StateProps;
}
let isTheBackButtonPressed = false;
export const BackButtonBehavior = ({successState, children}: BackButtonBehaviorProps) => {
const onBackButtonEvent = (e: { preventDefault: () => void; }) => {
e.preventDefault();
if (!isTheBackButtonPressed) {
if (window.confirm("Your claim has been submitted, would you like to exit before getting additional claim information?")) {
isTheBackButtonPressed = true;
window.history.back();
} else {
isTheBackButtonPressed = false;
window.history.pushState({successState: successState}, "success page", window.location.pathname); // When you click back (this refreshes the current instance)
}
} else {
isTheBackButtonPressed = false;
}
};
useEffect(() => {
window.history.pushState(null, "", window.location.pathname);
window.addEventListener('popstate', onBackButtonEvent);
return () => {
window.removeEventListener('popstate', onBackButtonEvent);
};
}, []);
return (children);
};
I have a fairly basic react component in a React app. I want to test that the "submitted" portion of the state changes from false to true when the form is submitted. Not particularly hard. But the enzyme test seems unable to find the button. Not sure if it has to do with the if/else statement.
Here is the component:
import React from 'react';
import { connect } from 'react-redux';
import { questionSubmit } from '../actions/users';
import { getCurrentUser, clearMessage } from '../actions/auth';
export class AnswerForm extends React.Component {
constructor(props) {
super(props);
this.state = {
submitted: false
}
}
handleFormSubmit(event) {
event.preventDefault();
this.setState({ submitted: true });
this.props.dispatch(questionSubmit(this.answerInput.value, this.props.currentUsername));
this.answerInput.value = '';
}
handleNextButton() {
this.setState({ submitted: false });
this.props.dispatch(getCurrentUser(this.props.currentUsername))
}
render() {
let nextButton;
let form;
let message = <p>{this.props.message}</p>
if (this.state.submitted) {
nextButton = <button className="button-next" onClick={() => this.handleNextButton()}>Next</button>;
}
else {
form =
<form onSubmit={e => this.handleFormSubmit(e)}>
<input className="input-answer" ref={input => this.answerInput = input}
placeholder="Your answer" />
<button id="button-answer" type="submit">Submit</button>
</form>;
}
return (
<div>
<p>{this.props.message}</p>
{form}
{nextButton}
</div>
)
}
}
export const mapStateToProps = (state, props) => {
return {
message: state.auth.message ? state.auth.message : null,
currentUsername: state.auth.currentUser ? state.auth.currentUser.username : null,
question: state.auth.currentUser ? state.auth.currentUser.question : null
}
}
export default connect(mapStateToProps)(AnswerForm);
Here is the test:
import React from 'react';
import {AnswerForm} from '../components/answer-form';
import {shallow, mount} from 'enzyme';
describe('<AnswerForm />', () => {
it('changes submitted state', () => {
const spy = jest.fn();
const wrapper = mount(<AnswerForm dispatch={spy}/> );
wrapper.instance();
expect(wrapper.state('submitted')).toEqual(false);
const button = wrapper.find('#button-answer');
button.simulate('click')
expect(wrapper.state('submitted')).toEqual(true);
});
});
I get this error when I try running this test:
expect(received).toEqual(expected)
Expected value to equal:
true
Received:
false
at Object.it (src/tests/answer-form.test.js:24:44)
at <anonymous>
at process._tickCallback (internal/process/next_tick.js:188:7)
Any ideas? It's a pretty straight shot other than the if statement. Not sure what is going on here.
The issue here is that the intrinsic DOM event propagation that is expected to occur between a submit button and a form element is not being done by enzyme or React during simulation.
The event system in React is all synthetic in order to normalise browser quirks, they actually all get added to document (not the node you add the handler to) and fake events are bubbled through the components by React (I highly recommend watching this webinar from the React core team explaining in event system in depth)
This makes testing them a little unintuitive and sometimes problematic, because simulation does not trigger real DOM event propagation
In enzyme, events triggered on shallow renders are not real events at all and will not have associated DOM target. Even when using mount which does have a DOM fragment backing it, it still uses React's synthetic event system, so simulate still only tests synthetic events bubbling though your components, they do not propagate via real DOM, so simulating a click on a submit button does not in turn intrinsically trigger a submit DOM event on the form itself, as its the browser not React that is responsible for that. https://github.com/airbnb/enzyme/issues/308
So two ways to get around that in a test are...
1) Not ideal from a UI test perspective as bypasses button, but clean for a unit test, especially as it should work with shallow rendering to isolate the component.
describe('<AnswerForm />', () => {
const spy = jest.fn();
const wrapper = shallow(<AnswerForm dispatch={spy}/> );
it('should show form initially', () => {
expect(wrapper.find('form').length).toEqual(0);
})
describe('when the form is submitted', () => {
before(() => wrapper.find('form').simulate('submit')))
it('should have dispatched the answer', () => {
expect(spy).toHaveBeenCalled();
});
it('should not show the form', () => {
expect(wrapper.find('form').length).toEqual(0);
});
it('should show the "next" button', () => {
expect(wrapper.find('#button-next').length).toEqual(1);
});
});
});
2) Trigger a real click event on DOM button element itself, rather than simulating it on your component as if it were a Selenium functional test (so feels a little dirty here), which the browser will propagate into a form submit before React catches the submit event and takes over with synthetic events. Therefore this only works with mount
describe('<AnswerForm />', () => {
const spy = jest.fn();
const wrapper = mount(<AnswerForm dispatch={spy}/> );
it('should show form initially', () => {
expect(wrapper.find('form').length).toEqual(0);
})
describe('when form is submitted by clicking submit button', () => {
before(() => wrapper.find('#button-answer').getDOMNode().click())
it('should have dispatched the answer', () => {
expect(spy).toHaveBeenCalled();
});
it('should not show the form', () => {
expect(wrapper.find('form').length).toEqual(0);
});
it('should show the "next" button', () => {
expect(wrapper.find('#button-next').length).toEqual(1);
});
});
});
You'll also note I'm not testing state itself. It's generally bad practice to test state directly as its pure implementation detail (state change should eventually cause something more tangible to happen to the component that can instead be tested).
Here I have instead tested that your event causes the dispatch spy to have been called with correct args, and that the Next button is now shown instead of the form. That way it is more focused on outcomes and less brittle should you ever refactor the internals.
Be mindful that the component you are testing is not the AnswerForm class component, but rather the wrapped component created by passing AnswerForm to react-redux's connect higher order component.
If you use shallow rendering rather than full mounting, you can use the dive() function of the Enzyme API to get to your actual class component. Try this:
import React from 'react';
import {AnswerForm} from '../components/answer-form';
import {shallow, mount} from 'enzyme';
describe('<AnswerForm />', () => {
it('changes submitted state', () => {
const spy = jest.fn();
const wrapper = shallow(<AnswerForm dispatch={spy}/> );
expect(wrapper.dive().state('submitted')).toEqual(false);
const button = wrapper.dive().find('#button-answer');
button.simulate('click')
expect(wrapper.dive().state('submitted')).toEqual(true);
});
});
Another option is to test the non-wrapped component instance directly. To do this, you just need to change your export and import. In answer-form.js:
export class AnswerForm extends React.Component
...your code
export default connect(mapStateToProps)(AnswerForm);
This exports the non-wrapped component in addition to the wrapped component. Then your imports in answer-form.test.js:
import WrappedAnswerForm, { AnswerForm } from 'path/to/answer-form.js`;
This way, you can test AnswerForm functionality independently, assuming you don't need to test any Redux received props. Check out this GitHub issue for more guidance.