DEV Community

Mei
Mei

Posted on • Edited on

Test-driven development with React and React Testing library

Test Driven Development or TDD is essentially the development practice of writing your unit tests before your code. You write failing unit tests, then write the code to make it pass; refactor the code if necessary and repeat.

Test Driven Development is a core part of software development practice. Testing gives us confidence, and confidence allows us to move fast, deploy daily and that quickens the feedback loop with our members.

This article is not a complete introduction to testing in React or React Testing Library. There are much better introductions available for these technologies elsewhere on the web; although you should be able to follow along without knowing these in much depth.

Instead I am going to explore my approach to TDD in our React application. I will talk about some of the techniques and rules when building React components.

Before we can start writing tests, and the code to pass them, we have decide what we should be testing when writing React components. As the creator of React Testing Library himself, Kent C. Dodds says, you should endeavour to make your tests as similar to how users (human or other systems) use your software.

What does this mean in practise? Users don't care about the implementation of your components, only that they work as expected. Your tests should do the same. We try to mock as little as possible, avoid testing implementation details and focus on covering all the use cases we want our component to solve.

How does this help us when we are using TDD? First consider the use case you are trying to solve, document it as a test, then write some code in your component to pass that test (without breaking the others). This forces us to completely understand and verbalise the reason for our component; this is useful for you, and anybody who wants to understand your code after you.

This is also consistent with the Behaviour Driven Development (BDD) approach as we are driving our design and implementation by the behaviour of our application. However following Kent's rule above, good tests are behaviour driven tests.

Show me the Code!

Code Sandbox with components and tests

An example is worth a thousand words, so we'll consider the use case of writing a component that allows a user to send us their email address (because everybody loves forms, right?)

Let's start with our first use case, and our first test. It's always good to start with the happy path so let's make sure if a user enters their email in a form, and clicks a submit button, our form gets submitted successfully.

test("form submits when valid email entered", () => {
  const handleSubmit = jest.fn();
  const { getByLabelText, getByText } = render(
    <EmailForm onSubmit={handleSubmit} />
  );
  const emailInput = getByLabelText(/enter email/i);
  const submitBtn = getByText(/submit/i);

  fireEvent.change(emailInput, { target: { value: "test@test.com" } });
  fireEvent.click(submitBtn);

  expect(handleSubmit).toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

Notice also that the handleSubmit function is mocked despite one of our suggestions being to mock as little as possible. One of the best candidates for mocking is backend interaction. Because this component is not responsible for how the email input is send to our backend systems, this is mocked out and we just want to ensure it is called correctly.

A good way of evaluating your tests is to consider what happens if you refactor your components without changing their functionality (by changing variable names, algorithms etc). Good tests should not be tightly coupled to their implementation, as long as the functionality is consistent. By writing these semantic queries into your front end tests, we can also ensure that our components are accessible by default.

Now that we've written a test that we're happy with, we can write the code in our component to pass this test. The goal of this component should only be to pass this test. It will need to pass a submit function when an email is entered and submit button clicked, but at this point we don't need to worry about any validation.

function EmailForm({ onSubmit }) {
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        onSubmit();
      }}
    >
      <label htmlFor="email">Enter email:</label>
      <input id="email" type="text" />
      <button type="submit"> Submit </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Great, now we have a working email entry field. Any consumer of this component; or new developer can open the test file, see what this component does, prove it works and use/extend it confidently. 👍

Let's extend this component now and build in validation on the email field to ensure we have a valid email address before we allow a submit.

test("form shows an error if invalid email entered and does not call submit", () => {
  const handleSubmit = jest.fn();
  const { getByLabelText, getByText, getByTestId } = render(
    <EmailForm onSubmit={handleSubmit} />
  );
  const emailInput = getByLabelText(/enter email/i);
  const submitBtn = getByText(/submit/i);

  fireEvent.click(submitBtn);
  expect(handleSubmit).not.toHaveBeenCalled();

  fireEvent.change(emailInput, { target: { value: "notvalid" } });
  expect(getByTestId("email-error").textContent).toBe(
    "Please enter a valid email"
  );

  fireEvent.click(submitBtn);
  expect(handleSubmit).not.toHaveBeenCalled();
});
Enter fullscreen mode Exit fullscreen mode

This test uses similar queries and events as the previous test, but is ensuring that if the email field is blank or contains an invalid email address, the onSubmit function will not be called, and an error message is shown.

In this test we also set up the query for our error message with a test-id instead of using a semantic query such as getByText. Test-id's are the best selector for this case as the text is dynamic and there is no standard semantic query to use for errors, although we prefer semantic queries we should still use the most sensible query for each object we are trying to get.

Now that we've written a test that captures our second use case, let's update our component to pass this test.

export default function EmailForm({ onSubmit }) {
  const [formValid, setFormValid] = useState(false);
  const [emailError, setEmailError] = useState();

  const validateEmail = (e) => {
    const email = e.target.value;
    if (!email || email.indexOf("@") === -1) {
      setFormValid(false);
      setEmailError("Please enter a valid email");
    } else {
      setFormValid(true);
      setEmailError();
    }
  };

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        if (formValid) {
          onSubmit();
        }
      }}
    >
      <label htmlFor="email">Enter email:</label>
      <input id="email" type="text" onChange={validateEmail} />
      {emailError && <p data-testid="email-error">{emailError}</p>}
      <button type="submit"> Submit </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

We can update this component by adding a validation function to run onChange on the input. This will ensure that the validation runs before the form is submitted, therefore passing our test. Where there is a validation error, this gets passed to a variable stored in state (using React Hooks) and there is a conditional render to render out any error messages, that uses the

data-testid="email-error"

tag we discussed in our test design.

Notice that our test does not read the state of our component, or care if we implement the validation using the onChange handler, this is all implementation details, and there are many other ways we could refactor this component, or add a form library without breaking the tests.

Conclusion

I hope this example shows how the TDD cycle can work in the context of React components, as well as showing some of the test design considerations that you an use in React Testing Library tests to make your tests both useful and robust.

This article also only covered writing specific unit tests for individual components, for integration tests, we will use very similar rules, For a complete testing strategy I use end-to-end Cypress tests, and a good end to end testing setup should be used with well tested components to give complete confidence in your code.

Top comments (0)