DEV Community

loading...

How I usually test my ReactJS components

potouridisio profile image Ioannis Potouridis Updated on ・4 min read

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;
}

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);

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 }));
}

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]
);

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]);

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;

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>
    `);
  });
});

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();
// ...

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'
    );
  });
});

@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' }
});

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();
  });
});

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'
    });
  });
});

Discussion

pic
Editor guide
Collapse
alexr89 profile image
Alex

Hi Ioannis,

Great post! I am wondering though:

How would you go about testing an on submit if a component does not have or require an onSubmit prop? I can think of multiple cases whereby we do not need to pass in prop for submitting a form - the component handles it itself. It therefore seems very counterintuitive and unrealistic to have to pass in a prop that is ONLY used for testing?