We can all agree that testing code is a great idea. It reduces bugs, increases code quality and prevents you from making (the same) mistakes in the future. There are a whole lot of best practices out there, but when looking at how to test controlled components I was a bit stumped. There seem to be four major “routes” which I would like to cover in this blog post.
What is a controlled (react) component?
Roughly there are two types of React components, uncontrolled and controlled. Uncontrolled components take care of their own state and push that state to the server themselves (or through a context). Controlled components expect to receive a value
and setValue
prop, which they will use to show the current state and use it to update to a new state. A controlled component doesn’t know (or care) about what happens with the value and is usually wrapped by an uncontrolled component. Check out this simplified example of an input component below. There is also some great information on the React website
const InputControlled = ({ value, setValue }: { value: string; setValue: (value: string) => unknown }): React.ReactElement => {
return <input
data-testid={dataTestIdInput}
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>;
};
const InputUncontrolled = ({ updater }: { updater: (event: "input", value: string) => unknown }): React.ReactElement => {
const [ value, setValue ] = React.useState("");
useEffect(() => {
updater("input", value);
}, [ updater, value ]);
return <input
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>;
};
Why does Moxio use controlled components?
At Moxio we have multiple applications which should have the same look and feel. To achieve this, we have created a design system. This design system contains a whole bunch of controlled components, for example, inputs that can be used wherever the applications need it. These components can’t have knowledge about their value, as they have no knowledge of the application or which other components they are connected to. There might be a button next to the input to empty the value, or some other external effect that changes it. This would be very difficult to achieve with an uncontrolled component on a design system level.
Route 1 – Don't test controlled components
The most common opinion I found during my searches on the internet was that controlled components shouldn’t be tested, because you’re not testing the components as they will be used. This is a great philosophy for applications, but doesn’t work for libraries, which is what we are trying to test.
Route 2 – Don't test state updates
One of the things you might want to test is seeing if the input component calls the setValue
correctly when a use types in your input. Technically this test doesn't need to update the value
after a setValue
is called, so the re-render step could just be skipped. I personally feel this could work if you’re making a fine-grained test but will not be possible if you want to test something more complex (like several keypresses after each other).
it("should call setValue with correct value single keypress", async () => {
const spy = sinon.spy((value: string) => {});
const { container } = render(
<InputControlled value="" setvalue={spy}></InputControlled>
);
const input = await findByTestId(container, dataTestIdInput) as HTMLInputElement;
await userEvent.type(input, "a", { delay: 0 });
expect(spy.callCount).to.eq(1);
expect(spy.getCall(0).args[0]).to.eq("a");
});
it("should call setValue with correct value multile keypresses", async () => {
const spy = sinon.spy((value: string) => {});
const { container } = render(
<InputControlled value="" setvalue={spy}></InputControlled>
);
const input = await findByTestId(container, dataTestIdInput) as HTMLInputElement;
await userEvent.type(input, "ab", { delay: 0 });
expect(spy.callCount).to.eq(2);
expect(spy.getCall(0).args[0]).to.eq("a");
//This doesn't work because the value never updated, so the second keypress just calls it with b
expect(spy.getCall(1).args[0]).to.eq("ab");
});
Route 3 – Manually re-render
One of the ways you can test complex behavior that includes state changes, is by manually telling the component to re-render, and what props to re-render it with. Check out the code below for an example. This method feels very fragile for more complex tests, easy to make mistakes in your own “when to re-render" code.
it("should call setValue with correct value multile keypresses - rerender", async () => {
const spy = sinon.spy((value: string) => {
//Manually re-render input with correct value
rerender(<InputControlled value={value} setvalue={spy}></InputControlled>);
});
const { container, rerender } = render(
<InputControlled value="" setvalue={spy}></InputControlled>
);
const input = await findByTestId(container, dataTestIdInput) as HTMLInputElement;
await userEvent.type(input, "ab", { delay: 0 });
expect(spy.callCount).to.eq(2);
expect(spy.getCall(0).args[0]).to.eq("a");
expect(spy.getCall(1).args[0]).to.eq("ab");
});
Route 4 – Wrap in uncontrolled component
You can also test a controlled component by wrapping it in an uncontrolled component. It causes your test to not be entirely “pure”, because you’re also testing part of the React stack here. You don’t have to think about when to re-render but spying on the callbacks does become a little bit more complicated. This is the method we seem to have organically gravitated towards.
it("should call setValue with correct value multile keypresses - wrapped", async () => {
const spy = sinon.spy((value: string) => {});
const InputWrapper = () => {
const [ value, setValue ] = React.useState("");
return <InputControlled value={value} setvalue={(value) > {
setValue(value);
spy(value);
}}
/>;
};
const { container } = render(<InputWrapper></InputWrapper>);
const input = await findByTestId(container, dataTestIdInput) as HTMLInputElement;
await userEvent.type(input, "ab", { delay: 0 });
expect(spy.callCount).to.eq(2);
expect(spy.getCall(0).args[0]).to.eq("a");
expect(spy.getCall(1).args[0]).to.eq("ab");
});</InputControlled>
In conclusion
We seem to use option 4, because it causes the least overhead, but it still isn’t great. Did I miss any options, or do you have a great idea? Please drop me a line on twitter @dennisclaassens, I would love to hear from you.
If anything we do makes you dream of greener pastures, we are also looking for senior frontend developers. We would love to have more people join our team!