react-testing-library: handleChange works unexpectedly when input is inside of a component being tested

I am trying to figure out why following line of code would always work no matter what number I put. waitForElement(() => expect(onChange).toHaveBeenCalledTimes(3));

I am trying to test that input has been called x number of times however input is controlled within the component itself. No matter what number provide it will always pass the test.
complete-react-testing.zip

With fireEvent, I expect it to be waitForElement(() => expect(onChange).toHaveBeenCalledTimes(1));

With userEvent, I expect it to be waitForElement(() => expect(onChange).toHaveBeenCalledTimes(5));

Here is my Component:

import React, { useState } from "react";
import axios from "axios";
import "./Pokemon.css";

const getPokemonByColor = (color) =>
  `https://pokeapi.co/api/v2/pokemon-color/${color}/`;

const Pokemon = () => {
  const [pokemons, setPokemons] = useState([]);
  const [error, setError] = useState(null);
  const [search, setSearch] = useState("");
  const handleFetch = async (event) => {
    event.preventDefault();
    let result;

    try {
      result = await axios.get(getPokemonByColor(search));
      setPokemons(result.data.pokemon_species.slice(0, 5));
    } catch (error) {
      setError(error);
    }
  };

  const handleChange = (event) => {
    setSearch(event.target.value);
  };

  return (
    <div>
      {error && <span>Something went wrong ...</span>}
      <form onSubmit={handleFetch}>
        <div>
          <input
            id="search"
            type="text"
            value={search}
            onChange={handleChange}
            placeholder="Pokemon Color"
            className="search"
          />
        </div>
        <button className="search-button" type="submit" data-testid="button">
          Fetch Pokemons
        </button>
      </form>
      <ul className="pokemons">
        {pokemons.length > 0 &&
          pokemons.map((pokemon) => (
            <li className="pokemon-item" key={pokemon.name}>
              <a className="pokemon-link" href={pokemon.url}>
                {pokemon.name}
              </a>
            </li>
          ))}
      </ul>
    </div>
  );
};

export default Pokemon;

Here are my tests:

import React from "react";
import axios from "axios";
import {
  render,
  screen,
  waitForElement,
  fireEvent,
  cleanup,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import Pokemon from "./Pokemon";

jest.mock("axios");

afterEach(cleanup);
describe("search input tests", () => {
  test("calls the onChange callback handler", () => {
    const onChange = jest.fn();

    const { getByRole } = render(<Pokemon />);
    const input = getByRole("textbox");
    expect(input.value).toBe("");
    fireEvent.change(input, {
      target: { value: "black" },
    });
    expect(input.value).toBe("black");
    waitForElement(() => expect(onChange).toHaveBeenCalledTimes(3));
  });

  test("calls the onChange callback handler", async () => {
    const onChange = jest.fn();

    const { getByRole } = render(<Pokemon />);

    const input = getByRole("textbox");
    expect(input.value).toBe("");
    await userEvent.type(input, {
      target: { value: "black" },
    });
    waitForElement(() => expect(input.value).toBe("black"));
    waitForElement(() => expect(onChange).toHaveBeenCalledTimes(7));
  });
});

describe("api tests", () => {
  test("fetches pokemons from an API and display them", async () => {
    const pokemons = [
      {
        name: "snorlax",
        url: "https://pokeapi.co/api/v2/pokemon-species/143/",
      },
      {
        name: "murkrow",
        url: "https://pokeapi.co/api/v2/pokemon-species/198/",
      },
      {
        name: "unown",
        url: "https://pokeapi.co/api/v2/pokemon-species/201/",
      },
      {
        name: "sneasel",
        url: "https://pokeapi.co/api/v2/pokemon-species/215/",
      },
    ];

    axios.get.mockImplementationOnce(() =>
      Promise.resolve({ data: { pokemon_species: pokemons } })
    );

    render(<Pokemon />);
    // screen.debug();
    userEvent.click(screen.getByRole("button"));
    expect(await screen.findAllByRole("listitem")).toHaveLength(4);
    // screen.debug();
  });

  test("fetches stories from an API and fails", async () => {
    axios.get.mockImplementationOnce(() => Promise.reject(new Error()));

    render(<Pokemon />);

    userEvent.click(screen.getByRole("button"));

    const message = await screen.findByText(/Something went wrong/);

    expect(message).toBeInTheDocument();
  });
});

About this issue

  • Original URL
  • State: closed
  • Created 4 years ago
  • Comments: 19 (5 by maintainers)

Most upvoted comments

Just following what @WretchedDade wrote and emphasizing one important thing, when using RTL we should test a component the way the user uses it. Kent usually says: “The more your tests resemble the way your software is used, the more confidence they can give you.” Counting the number of times an onChange handler was called is an implementation detail since if this behavior will change (by browsers for example), all of your tests will break, but the user of your component won’t notice it since the component will still work.

@MatanBobi thanks for the input, I agree with what you’re saying. 😄