Testing the Untested
How to bring useful tests into your untested React app
Background Lore
So I’ve been working my first big dev job for several months now at a fast-paced, growing start-up. Its exciting, rewarding, dynamic, and a lot of fun! Perhaps too much fun… Like many growing developers coming into their half year mark at a new role, I’m reaching a point where I’m comfortable tackling larger projects and quality-of-life improvements outside the scope of our usual sprint tasks. And as I’ve had so much fun building new features and working together with different engineers, I’ve decided to balance out the fun with some torture: writing tests.
I’m not doing this entirely without cause though. I’m not that crazy. I’ve been working on a team developing a React application for a few months now, and while the backend repo has an established workflow that involves writing plenty of unit and integration tests, we couldn’t say the same for the frontend. In fact, I’ve said that our tests folder exists simply so that the higher ups will think we have tests. And while I find this joke hilarious, I don’t think my tech lead, team lead, and CTO find it as comical. So in order to appease both them and the part of my inner self that desperately wants to be a good developer, I am writing some tests for this codebase.
I found it surprisingly difficult to find good tutorials or information on React testing beyond the super basic introductory stuff (testing pure JS functions with Jest, checking that a basic state-less React component will render with React Testing Library). So let’s jump in to what I learned, what I ended up doing, and what you can do for your own React app.
Getting Started: Test Organization
There seem to be two primary paradigms for organizing your tests. Though if you’re like me and have a huge codebase with many deeply nested folders of components, you may want to select the latter. You can either:
- Place test files in the same directories as the files they’re testing:
- Place all tests in the
tests/
directory, mirroring the source code folder structure:
As I mentioned above, I chose the latter as it made it easier to write multiple tests and see which components I had written tests for. For the second case, many developers will put their tests in a folder called __tests__
as Jest looks for this folder by default and will run all .js
files here, even if the filename does not contain the .test
suffix.
Writing the Tests
I’m assuming you have some basic knowledge of how to write tests, or at least how they are structured (i.e. a test function (test
or it
) that is passed the actual test, which will expect (expect()
) several conditions to be true or events to have occurred (toBe()
, toHaveBeenCalled()
). But if you’re new to testing, checkout the help pages for Jest and React Testing Library first.
Low Complexity Tests: Unit Tests for JS Functions and Utilities
Utility functions and other pure JS functions make for quick, easy targets in the testing world. One can parameterize inputs and run functions under dozens of scenarios with only a few lines.
For example, take the utility function below, callIfNew
, that will call a callback function if the first two arguments are not equivalent. We use this to update state or send requests only if a “new” input value is different from the current value (use case: a user clears and then types the same number into a text field). We use Jest to mock the function being handed to the utility (testFn1
and testFn2
) so that we can see if these functions are called and if so, how many times.
A few tips for this type of testing:
- Use the
toStrictEqual()
method to compare objects ({} != {}
in JS) - Don’t forget to test for unexpected argument types like
Undefined
andNull
, just like I haven’t done above - As you write tests, you will discover edge cases not handled by your functions. Update the function you’re testing as you go (usually these are caught during normal development because we are writing tests as we write code 🐶). Just make sure it is backwards compatible with all its use cases in the codebase.
- Test as many functions as possible. As I said above, these tests are quick and easy to write.
High Complexity Tests: React Component Tests
Next up, and perhaps what will make up the majority of your tests in a large application, are component level tests. We want to test that the component renders in its default state as well as other expected states. Below is a simple example where I am testing that certain props cause specific messages to be shown in a modal. Note the use of rerender
to update the component with new props without remounting. You can also use fireEvent
or userEvent
to click on buttons/elements to test certain functionality.
You can also test custom hooks at this level, and may even need to create a dummy component or time travel. For example, this test uses Jest’s advanceTimersByTime
method to do just that. It will push forward time, which may be useful for dealing with animations, timed events, or timeout callbacks. In this case, it allows me to verify that my interval hook calls the mocked function a specific number of times.
Misc. Tests: Snapshots and Context
I hate to put snapshots in the misc. category because they can cover so much of an application with just a few tests. That being said, mileage will vary. A snapshot is exactly what it sounds like, practically a screenshot of the component being rendered. In the case below, we are rendering nearly the whole page. Any changes in the UI will cause the test to fail. If your UI is relatively constant, these tests will help you identify unintended changes. However, if you are developing pages and features on pages often, these tests may be more trouble than they’re worth. Also, I had issues using some external UI libraries, as some keys are generated randomly, changing the snapshot on each test run.
Next, I tested my context. We wrap each page in its own context, so it was easy to write tests for the page-context combo together. Just create some context data in your application, copy it (or log it), and save it to use for tests (you may have to mock out API calls if your context implements them). Then, you can verify that the component renders correctly with the given context. You can also use userEvents
to trigger context functions attached to page events. Remember not to call internal component/context functions directly. The whole point of component tests is to test the implementation of a component, not its internal logic in isolation.
Conclusions
- Start with the low hanging fruit. Some tests are better than no tests. Once you get started, you’ll see its not so bad.
- Writing component tests is not as difficult as finding the documentation needed to write them. Once you have the knowledge, writing a suite of tests is easy.
- Pick a point early on (once the backbone of the UI has been established) to begin writing tests. Continue to write tests alongside development. Make testing part of your workflow.
- Tests catch bugs. Even just the handful of tests I wrote were able to catch unintended side effects before they made it out the door. Time invested in testing is time saved in QA and debugging.
Happy coding! -Will
Thanks to:
- Ethan Dam for sharing his expertise
- Alfred Yang for this Medium article about React testing
- Carbon for clean code images
- Tree for the file structure diagrams