Making React Testable
Writing React to be testable can be a major headache. How do I test internal functions? How do I test that setState was run? Why do I hate jest.Mock so much?
Simple project organization can help these issues. Let's take a project that's using tRPC for example. tRPC uses a lot of complex type inference and attaches itself to React Query.
How do you mock these? Their creators Alex Johansson and Tanner Linsley aren't likely to give you a clear answer. In fact, they're more likely to tell you not to. And this makes sense. Because A) these are trusted, well tested libraries already, there's no reason to test that they work. B) Mocking them is a ton of setup that may not work with your stack. And C) This is not good testing.
I've used a few different methods to separate logic from view in React components. The two big ones being custom hooks, and data components. I think by mixing the two, this can help keep unit tests clean, small, and stress free.
Let's start with a tRPC call. Below is a tRPC call that uses Prisma to creates a “Project Preference” for a user given a username and preference name.
How do we test this call? The answer is we don't. Instead, we test the Zod schemas. In other languages, we would probably call these DTOs. We test that they can accept and successfully parse/fail the schemas. We can trust that tRPC and Zod are not broken. If they are, we shouldn't be using them.
If we have more logic around this Prisma call, we probably want to move it to it's own function. The Prisma Client is incredibly easy to mock as the Client itself is only a transportation layer between Node and the real Rust-based client. Plus types are generated, not inferred, so explicit database types exist within node_modules. Relatively speaking, compared to tRPC and React Query, Prisma Client is shallow enough for Jest to handle.
Let's take a look at the schema tests.
When we use this call in a React component, it's best to create a “data component.” A component whose only job is to either fetch data, or act as a vehicle to mutate it. The below component has a “useQuery” for fetching data, and a “useMutation” which is being used to call our create method above.
So how do we test this component? The answer is again, we don't. The API call is already tested via the Zod schemas. And of course once we get into the view, we can depend on React Testing Library and mocked props. Without getting into what's in the view, let's just skip straight to a test.
And this is my favorite part of using Zod. We've tested the schema before. But by using @anatine/zod-mock we can generate mock data for it and pass it straight to the component! Now, when working with the view and RTL, we don't have to worry about mocking our API first. We just give it props. A much simpler and controlled process than the sometimes confusing jest.Mock().
But here's the kicker, we saw before that this view is taking in a create function. And here were passing a jest.fn() to mock it. But this view is a form. Which means it has state, and hooks. How do we handle those?
Move them to a custom hook.
The implementation details of what's happening here aren't too important. We have two custom hooks, useForm and useUser. Both handle what they sound like they handle. The point is, with this hook separated from the component, we can test it's internal state with Testing Library's renderHook()
Again, there are some unimportant details here. We have a “setTestUserCookie()” that gives us a logged in state for a user with the username ‘developer’. But we can test the formState's initial state and make sure “create” is called when the form is submitted.
This is a contrived example. Because this is just a form submit, we can get “coverage” with user actions on the component. So you might say that this hook in particular is unnecessary. And that may be true, it's not always necessary to separate hooks into their own functions. But for developers, coverage is not an important metric.
Writing thorough tests to ensure the stability of the system is. Keeping that organized so you can keep writing thorough tests in the future is as well. With that in mind, if it makes sense to move the hook and test it, I say move the hook and test it.