2

In the React component, A button submission takes the values of text fields on the page and passes them into mockFetch. Then, If the promise of mockFetch is successful, It causes a history.push('/newaccount') to fire.

I set up my test to click on the button and then attempt to detect the history.push('/newaccount') call with what i passed into it, but my mock history.push doesn't get called. Does anyone know what i can do to get this test to pass?

EDIT: It turns out replacing the current jest.mock with:

    const history = createMemoryHistory();
    const pushSpy = await jest.spyOn(history, "push");

allows me to call the mocked history when the history.push is OUTSIDE of the mockFetch function... but not when it is inside of it. That's what i'm trying to figure out now.

Main app routing:

function App() {
  const classes = useStyles();

  return (
    <div className="App">
      <Banner />
      <div id="mainSection" className={classes.root}>
        <ErrorBoundary>
          <Paper>
            <Router history={history}>
              <Switch>
                <Route path="/newaccount">
                  <NewAccountPage />
                </Route>
                <Route path="/disqualify">
                  <DisqualificationPage />
                </Route>
                <Route path="/">
                  <LandingPage />
                </Route>
              </Switch>
            </Router>
          </Paper>
        </ErrorBoundary>
      </div>
    </div>
  );
}

export default App;

Component: (omitted some redundant fields)

import React, { useCallback, useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useHistory } from "react-router-dom";
import Grid from "@material-ui/core/Grid";
import Button from "@material-ui/core/Button";
import TextFieldStyled from "../TextFieldStyled/TextFieldStyled.js";
import * as actionTypes from "../../redux/actions/rootActions.js";
import {
  checkAllErrors,
} from "../../validators/validators.js";
import mockFetch from "../../fetchCall/fetchCall";
import "./LandingPage.css";

const LandingPage = () => {
  const dispatch = useDispatch();
  const history = useHistory();

  const {
    changeCarPrice,
    changeErrorMessage,
    resetState,
  } = actionTypes;

  useEffect(() => {
    return () => {
      dispatch({ type: resetState });
    };
  }, [dispatch, resetState]);

  const { carPrice } = useSelector(
    (state) => state
  );

  const handleSubmit = (e) => {
    e.preventDefault();
    if (!checkAllErrors(allErrors)) {
      // Call the API
      mockFetch(carPrice)
        .then((response) => {
          history.push("/newaccount");
        })
        .catch((error) => {
          dispatch({ type: changeErrorMessage, payload: error });
          history.push("/disqualify");
        });
    }
  };

  const [allErrors, setAllErrors] = useState({
    carValueError: false,
  });

  return (
    <div id="landingPage">
      <Grid container>
        <Grid item xs={2} />
        <Grid item xs={8}>
          <form onSubmit={handleSubmit}>
            <Grid container id="internalLandingPageForm" spacing={4}>
              {/* Text Fields */}
              <Grid item xs={6}>
                <TextFieldStyled
                  info={"Enter Car Price ($):"}
                  value={carPrice}
                  adornment={"$"}
                  type="number"
                  label="required"
                  required
                  error={allErrors.carValueError}
                  id="carPriceField"
                  helperText={
                    allErrors.carValueError &&
                    "Please enter a value below 1,000,000 dollars"
                  }
                  passbackFunction={(e) => handleChange(e, changeCarPrice)}
                />
              </Grid>
            </Grid>
            <Grid container id="internalLandingPageFormButton" spacing={4}>
              <Grid item xs={4} />
              <Grid item xs={3}>
                <Button
                  variant="contained"
                  color="primary"
                  type="submit"
                  id="applyNowButton"
                  title="applyNowButton"
                  onSubmit={handleSubmit}
                >
                  Apply Now
                </Button>
              </Grid>
              <Grid item xs={5} />
            </Grid>
          </form>
        </Grid>
        <Grid item xs={2} />
      </Grid>
    </div>
  );
};

export default LandingPage;

Test:

const wrapWithRedux = (component) => {
  return <Provider store={store}>{component}</Provider>;
};

  it("simulates a successful submission form process", () => {
    const mockHistoryPush = jest.fn();
    jest.mock("react-router-dom", () => ({
      ...jest.requireActual("react-router-dom"),
      useHistory: () => ({
        push: mockHistoryPush,
      }),
    }));

    render(
      wrapWithRedux(
        <Router history={history}>
          <Switch>
            <Route path="/newaccount">
              <NewAccountPage />
            </Route>
            <Route path="/">
              <LandingPage />
            </Route>
          </Switch>
        </Router>
      )
    );

    const carPriceField= screen.getByTestId("carPriceField");
    fireEvent.change(carPriceField, { target: { value: "5000" } });

    const buttonSubmission= screen.getByTitle("buttonSubmission");
    fireEvent.click(buttonSubmission);


    expect(mockHistoryPush).toHaveBeenCalledWith('/newaccount');
  });

Milos
  • 1,073
  • 4
  • 14
  • 33
  • Can you add a `console.log` statement inside the `if (!checkAllErrors(allErrors)) {...}` block to make sure it does call `mockFetch`? – Arun Kumar Mohan Feb 10 '21 at 02:48
  • @ArunKumarMohan Just added it inside of the .then of the mockFetch and it does get called. So the code does get to the history.push("/newaccount") – Milos Feb 10 '21 at 02:54
  • Cool. I'm guessing the expectation runs before the promise returned by the `mockFetch` function resolves. You could test this by commenting out the `mockFetch` call and adding a `history.push` inside the `if` condition. And if you could add a CodeSandbox URL with your code, it would be easier to debug. – Arun Kumar Mohan Feb 10 '21 at 02:57
  • @ArunKumarMohan Hm if i move the history.push out of the mockFetch call i still get the same error of the ```jest.fn()``` never being called. I'm beginning to think it's how the ```history.push``` is mocked. – Milos Feb 10 '21 at 03:30
  • I have made some progress on this. If i do: ``` const history = createMemoryHistory(); const pushSpy = jest.spyOn(history, "push"); ``` then i can access the mocked history.push outside of mockFetch. But if it's inside the promise function mockFetch... i cannot. I presume this has something to do with it being in a promise. – Milos Feb 10 '21 at 03:46
  • You mean it didn't work when the only thing the `handleSubmit` function did was `history.push`? – Arun Kumar Mohan Feb 10 '21 at 04:15
  • > then i can access the mocked history.push outside of mockFetch. But if it's inside the promise function mockFetch... i cannot. Not sure if I understand. You only need to add `expect(pushSpy).toHaveBeenCalledWith('/newaccount')` right? – Arun Kumar Mohan Feb 10 '21 at 04:17
  • It worked when i moved the ```history.push``` outside of the ```mockFetch``` function. It did not work when i left the history.push INSIDE of the mockFetch function. To get it to work inside the mockFetch function, i had to add await waitFor wrapped around my expect line. – Milos Feb 10 '21 at 04:22
  • Yeah, that makes sense. As I mentioned earlier, your expectation does not wait for the async action to complete. – Arun Kumar Mohan Feb 10 '21 at 04:24

2 Answers2

0

so after looking further into mocking history, i found this from How to mock useHistory hook in jest?

The answer seems to be to do the code below and then the pushSpy will be called

    const history = createMemoryHistory();
    const pushSpy = jest.spyOn(history, "push");

    render(
      wrapWithRedux(
        <Router history={history}>
          <Switch>
            <Route path="/newaccount">
              <NewAccountPage />
            </Route>
            <Route path="/">
              <LandingPage />
            </Route>
          </Switch>
        </Router>
      )
    );

I also had to wrap the expect line using waitFor from the react testing library to get the history.push to call the mock inside of the mockFetch function like so:

await waitFor(() => expect(pushSpy).toHaveBeenCalledWith("/newaccount"));

With these two modifications, the test now passes.

Milos
  • 1,073
  • 4
  • 14
  • 33
0

Rather than mock a bunch of things and test implementation, I would test the behavior.

Here are some ideas to maybe help writing for behavior over implementation details.

Refer to React Router testing docs

React Router has a guide on testing navigation based on actions.

Hijack render() to wrap with Redux

Rather than writing a Redux wrapper for every test suite, you could hijack the react-testing-library's render() function to get a clean state or seed the state. Redux has docs here.

Don't mock fetch()

A button submission takes the values of text fields on the page and passes them into mockFetch

I would use an HTTP interceptor to stub a response. That way you get the async behavior and you bind your tests to the backend vs binding it to the tool. Say you don't like fetch(), you'll be stuck with it until you migrate everything. I made a blog post on the subject Testing components that make API calls.

Here's your code example with some edits:

  it("creates a new account", () => { // <-- more descriptive behavior
    // Stub the server response
    nock(`${yoursite}`)
      .post('/account/new') // <-- or whatever your backend is
      .reply(200);

    render(
        <MemoryRouter initialEntries={["/"]}> // <-- Start where your forms are at
          <Switch>
            <Route path="/newaccount">
              <NewAccountPage />
            </Route>
            <Route path="/">
              <LandingPage />
            </Route>
          </Switch>
        </MemoryRouter>
    );

    const carPriceField= screen.getByTestId("carPriceField");
    fireEvent.change(carPriceField, { target: { value: "5000" } });

    const buttonSubmission= screen.getByTitle("buttonSubmission");
    fireEvent.click(buttonSubmission);


    expect(document.body.textContent).toBe('New Account Page'); // <-- Tests route change
  });
tony g
  • 334
  • 2
  • 8