How I usually test my ReactJS components

potouridisio

Ioannis Potouridis

Posted on December 27, 2019

How I usually test my ReactJS components

Introduction

What I like about @testing-library/react is that it encourages testing on what users see instead of how a component works.

Today, I had a fun with it and I wanted to share an example component along with its tests.

The component is a login form. For simplicity reasons I skipped the password input.

Show me the component first

To start with, I added the interface for its props.

interface LoginFormProps {
  initialValues: { email: string };
  onSubmit?: (values: { email: string }) => void;
}
Enter fullscreen mode Exit fullscreen mode

The component expects some initialValues, we keep it simple with just the email here, and the onSubmit callback that can be called with our new values.

It renders a form with an input and a button element. Other than that, a form component usually includes at least two event handlers and a state.

The state's value derives from initialValues prop.

const [values, setValues] = useState(initialValues);
Enter fullscreen mode Exit fullscreen mode

As you might have guessed, one event handler will use the set state action that have been destructured from the useState hook in order to update the form's state.

function handleChange({ target }: React.ChangeEvent<HTMLInputElement>) {
  setValues(prev => ({ ...prev, [target.name]: target.value }));
}
Enter fullscreen mode Exit fullscreen mode

The other event handler should be called when the form is submitted and should call or not the onSubmit callback with the form's state.

const handleSubmit = useCallback(
  (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();

    onSubmit?.(values);
  },
  [onSubmit, values]
);
Enter fullscreen mode Exit fullscreen mode

When a callback has dependencies I create a memoized version of it with the help of useCallback hook.

Let's get dirty...

Seriously, let's get a dirty variable in order to disable or not the button.

const dirty = useMemo((): boolean => {
  return values.email !== initialValues.email;
}, [initialValues.email, values.email]);
Enter fullscreen mode Exit fullscreen mode

Again, when I have variables with computed values I tend to memoize them.

That's all...

// LoginForm.tsx

import React, { useCallback, useMemo, useState } from 'react';

export interface LoginFormProps {
  initialValues: { email: string };
  onSubmit?: (values: { email: string }) => void;
}

function LoginForm({
  initialValues,
  onSubmit
}: LoginFormProps): React.ReactElement {
  const [values, setValues] = useState(initialValues);

  const dirty = useMemo((): boolean => {
    return values.email !== initialValues.email;
  }, [initialValues.email, values.email]);

  function handleChange({ target }: React.ChangeEvent<HTMLInputElement>) {
    setValues(prev => ({ ...prev, [target.name]: target.value }));
  }

  const handleSubmit = useCallback(
    (event: React.FormEvent<HTMLFormElement>) => {
      event.preventDefault();

      onSubmit?.(values);
    },
    [onSubmit, values]
  );

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        onChange={handleChange}
        placeholder="Email"
        type="email"
        value={values.email}
      />
      <button disabled={!dirty} type="submit">
        Login
      </button>
    </form>
  );
}

export default LoginForm;
Enter fullscreen mode Exit fullscreen mode

Show me the tests

@testing-library helps us write user-centric tests, thus meaning the what user sees I mentioned in the beginning.

Here are some things that we need to test for this component.

  1. The user sees a form with an input and a button.
  2. The input displays the correct values.
  3. The button should be disabled when the form is not dirty.
  4. The form is working.

There are a lot of ways to write tests. jest provides us a variety of matchers and @testing-library a lot of query helpers.

Here's what I've come up with for the first case.

describe('LoginForm component', () => {
  it('renders correctly', () => {
    const initialValues = { email: '' };

    const { container } = render(<LoginForm initialValues={initialValues} />);

    expect(container.firstChild).toMatchInlineSnapshot(`
      <form>
        <input
          name="email"
          placeholder="Email"
          type="email"
          value=""
        />
        <button
          disabled=""
          type="submit"
        >
          Login
        </button>
      </form>
    `);
  });
});
Enter fullscreen mode Exit fullscreen mode

A couple of things to note here, render is coming from @testing-library/react and it renders the component into a container div and appends it to document.body.

container is that div and we expect from the firstChild which is our form to match the inline snapshot.

Another way I would write this test would be:

// ...
const {
  getByPlaceholderText,
  getByText
} = render(<LoginForm initialValues={initialValues} />);

expect(getByPlaceholderText('Email').toBeInTheDocument();
expect(getByText('Login').toBeInTheDocument();
// ...
Enter fullscreen mode Exit fullscreen mode

For the second item in our list I wrote the following tests.

describe('input element', () => {
  it('renders the default value', () => {
    const initialValues = { email: '' };

    const { getByPlaceholderText } = render(
      <LoginForm initialValues={initialValues} />
    );

    expect(getByPlaceholderText('Email')).toHaveValue('');
  });

  it('renders the correct value', () => {
    const initialValues = { email: '' };

    const { getByPlaceholderText } = render(
      <LoginForm initialValues={initialValues} />
    );

    fireEvent.change(getByPlaceholderText('Email'), {
      target: { value: 'laura.marshall@cowtown.io' }
    });

    expect(getByPlaceholderText('Email')).toHaveValue(
      'laura.marshall@cowtown.io'
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

@testing-library's render returns a variety of queries such as getByPlaceholderText which gives as access to the elements they find.

fireEvent on the other hand simply fires DOM events.

For example the following code fires a change event on our email input getByPlaceholderText('Email') and sets its value to laura.marshall@cowtown.io.

fireEvent.change(getByPlaceholderText('Email'), {
  target: { value: 'laura.marshall@cowtown.io' }
});
Enter fullscreen mode Exit fullscreen mode

With that said, I tested that our input renders the initial value and also updates properly.

I then test the accessibility of the user to the Login button.

I used another amazing query getByText to find my button and changed my input's state by firing an event like my previous test.

describe('submit button', () => {
  it('is disabled when the form is not dirty', () => {
    const initialValues = { email: 'laura.marshall@cowtown.io' };

    const { getByText } = render(<LoginForm initialValues={initialValues} />);

    expect(getByText('Login')).toBeDisabled();
  });

  it('is enabled when the form is dirty', () => {
    const initialValues = { email: '' };

    const { getByPlaceholderText, getByText } = render(
      <LoginForm initialValues={initialValues} />
    );

    fireEvent.change(getByPlaceholderText('Email'), {
      target: { value: 'laura.marshall@cowtown.io' }
    });

    expect(getByText('Login')).toBeEnabled();
  });
});
Enter fullscreen mode Exit fullscreen mode

Finally I tested the button's functionality.

I created a mock function for my submit handler and tested that it is called with our new values when the Login button is pressed.

describe('submit button', () => {
  // previous tests

  it('calls handleSubmit with the correct values', () => {
    const initialValues = { email: '' };
    const handleSubmit = jest.fn();

    const { getByPlaceholderText, getByText } = render(
      <LoginForm initialValues={initialValues} onSubmit={handleSubmit} />
    );

    fireEvent.change(getByPlaceholderText('Email'), {
      target: { value: 'laura.marshall@cowtown.io' }
    });

    fireEvent.click(getByText('Login'));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: 'laura.marshall@cowtown.io'
    });
  });
});
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
potouridisio
Ioannis Potouridis

Posted on December 27, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related