When I was starting to write React tests, it was really tough. It felt like it requires a completely different mindset to that of writing application code.
One of the most terrifying questions I had was how do I know what I should expect()
in React component tests? One cannot answer that in a short blog post.
Therefore, to make it more digestible, in this blog post I will only focus on what you should NOT assert in React component tests.
Note: code snippets are based on Jest and React Testing Library (RTL)
❌ Implementation details
“Implementation detail” is a loose term and it might be referred to different things by different people, but what’s very important to understand is the tradeoffs of testing implementation details.
The more your assertions depend on implementation details, the more prone to false positives (test fails although your application logic works as intended) your tests become. It is also commonly referred to as white box testing.
Therefore, you want to depend on implementation details as little as possible. It’s not always feasible, though. For instance, testing DOM class names. I suppose that most people would agree that it is an implementation detail.
However, what if your class name dictates whether the component is visible or hidden? In that case, I’d choose to bite the bullet and assert the class name, because it gives me confidence that the component is visible/hidden correctly.
So the real question is how you should balance testing implementation details. You want to find the golden mean where you get a significant amount of confidence out of the tests and, at the same time, they do not cause too many false positives.
Some specific don’ts to watch out for:
❌ Relying on DOM structure. Avoid using
within()
,.children
,.parentElement
and other attributes/helpers that rely on DOM structure.❌ Class names, such as
expect(button.className).toContain('with-loader')
. You cannot meaningfully test the actual UI with component tests. However, personally, I have one exception — I’d test class names that are vital to the functionality of the component, such as hidden/visible.❌ Component’s internal state. In fact, RTL doesn’t provide you with an API to achieve this, but I just want to emphasize that you shouldn’t strive to do that.
eslint-plugin-testing-library is your friend here (although, as a contributor, I am somewhat biased 😶 ). It might help you find soo many anti-patterns.
❌ Don’t assert 3rd party code
Always think about what code your tests assert. Is it your code? Is it 3rd party library code?
You certainly want to assert your own code. It’s also a good idea to test 3rd party code without mocking it for more integration-like tests because they provide more confidence. Although, usually, there’s a certain threshold after which the cost of having those tests is higher than the provided value (they may require more maintenance).
Furthermore, in some cases, you must mock modules, such as an API client (fetch
, axios
) because you don’t want to call real endpoints in your tests.
✨ Fun fact: in every React component test you’re testing 3rd party code because you are running React lifecycle, hence you are testing React itself. Aaand babel JSX transformer as well 🙂
But you don’t want to end up asserting things that are outside of your control. As an example, you could assert window.addEventListener
in your tests: expect(addEventListener).toHaveBeenCalledTimes(5)
.
This, however, would essentially mean that you are asserting whether React works correctly. There is little point in doing so. It is already well tested.
💡 Practical example: instead of clicking on
<a />
and asserting that the URL has changed, you can do this:expect(screen.getByRole('link')).toHaveAttribute('href', 'google.com')
I’ve also seen people writing some interesting assertions: expect(render(<div />)).not.toBeNull()
Again, think of what code it asserts against. It has nothing to do with the component that you’re testing! There’s nothing in your component that would make such assertion fail.
❌ Too many snapshot tests
Jest provides a way to write snapshot tests, which is a fancy name for a test that has expect(...).toMatchSnapshot()
assertion.
I think Effective Snapshot Testing by Kent C Dodds is a worthwhile read if you want to delve even more into dos and don’ts.
I want to express a couple of things, though. First of all, for most components, there’s no vital need to have snapshot tests. So don’t write them just because it‘s cool. Snapshot tests come with many drawbacks, such as a significant likelihood of false positives, a pain to review (let’s be real, who reviews snapshots in PRs? 🙃 ), the incentive to update snapshots without understanding the reason behind their change, and others.
I am not saying they don’t have a reason to live, but, my suggestion is to limit their amount. I usually tend to have them in those circumstances:
A general* snapshot test that acts as a safeguard against unexpected changes (for instance, 3rd party UI library has changed).
When having a non-snapshot test is too brittle. For instance, if you have many UI assertions or when some of them are implementation specific (e.g. relying on DOM structure by using within() from RTL or asserting class names), then it seems more worthwhile having a snapshot test because the test is simpler and can be updated in a more interactive way.
*General meaning that it is the most common state of your component. For instance, for a landing page, it’d be a successful state after the data is fetched.
💡 One trick that I wasn’t initially aware of: you can take “partial snapshots”. Instead of having
expect(container).toMatchSnapshot()
you can doexpect(screen.getByTestId('dropdown-button')).toMatchSnapshot()
❌ Side effects that are NOT important to the user or to other components
You may wonder — which non-UI actions should be tested? If my component calls function transformDateToText()
, should there be a test with expect(transformDateToText).toHaveBeenCalledTimes(1)
assertion?
In this particular case, assuming that transformDateToText
function is used to render a pleasant date, probably no.
It can be tested through UI assertions (e.g. expect(screen.getByText('5 days ago')).toBeInTheDocument()
) which gives more confidence.
By side effect that is important to the user, think of fetching data. Usually, you can test data fetching through UI assertions, but it’s important to the user that you don’t spam requests. If you unnecessarily send 10 GET requests, users’ mobile data will vanish.
By a side effect that is important to other components — think of something like a Redux dispatch()
. It might be vital to other components that correct data is dispatched, hence it’s a good candidate for an assertion.
Asserting logic of child components
One question that you might have is “should I assert logic of child components”? That’s a very good question to ask. It roughly translates to “should I write integration tests?” And my rough answer is — most of the time, yes.
But there are cases where, in my opinion, the confidence boost does not outweigh the maintenance burden (test code is also code that has to be maintained). Hence, what I wouldn’t assert when it comes to child components:
❌ If a child component is a highly reusable one, such as
<ForumBreadcrumbs />
, then I wouldn’t extensively* cover it with integration tests, because testing the same child component functionality in tens of test suites comes with a significant maintenance and performance burden. The confidence that you gain by testing the same component 10 times, as opposed to, say, 2 times, is not significant.❌ Non-UI assertions that cover logic, which is implemented strictly in the child component. By strictly I mean that the component does not have any control of whether the assertion fails or not. For instance, I wouldn’t assert that the child component calls API. Our component does not pass a prop with an API call and does not have any other way of controlling whether the child component executes the API call. If it does have some sort of control, then I would test it.
*Covering highly reusable child components with integration tests is still useful in making sure that the child component is used correctly, but I wouldn’t strive to cover all of the edge cases that the child component handles.
However, how far you are willing to go with integration tests is a question that, ideally, should be aligned on a project or a team level. The most important part here is to be mindful and have a reason behind your decision.
Summary
Here’s a quick recap on what assertions I recommend to stay away from in React component tests:
❌ Assertions that rely on DOM structure
❌ Class names, UNLESS it is vital to the functionality of the component (e.g. the class name hides a DOM node)
❌ Component’s internal state
❌ 3rd party code
❌ Too many snapshot tests
❌ Side effects that are NOT important to the user or to other components, such as
transformDateToText()
❌ Highly reusable child components
❌ Non-UI logic that is implemented strictly in child components