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.
- The user sees a form with an input and a button.
- The input displays the correct values.
- The button should be disabled when the form is not dirty.
- 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'
});
});
});
Top comments (1)
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?