Originally published on Medium
Knowing what to expect()
in tests is not an easy problem.
Having written hundreds of jest
, react-testing-library tests
, I believe I’ve gained enough experience to share useful advice.
In my previous blog post, What NOT To Assert in React Component Tests, I covered what one should not assert. I suggest reading it because this blog post will greatly overlap and you’ll notice that I refer to it multiple times.
Consequently, the goal of this blog post is to explain what you can assert and what I think you should assert when writing component tests.
Note: code snippets are based on Jest and React Testing Library (RTL)
Two categories of assertions
Assertions can be categorized into two categories:
- UI assertions
- Non-UI assertions
Which assertions you should use usually depend on the test case.
If a test is named updates button text
, then it‘s quite clear that you’ll want to have a UI assertion that asserts button text, e.g. expect(screen.getByText('Updated button')).toBeInTheDocument()
Conversely, a test named calls API should have a non-UI assertion, such as expect(fetchForumThreads).toHaveBeenCalledTimes(1)
You might want to have both kinds of assertions in the same test case. There is nothing inherently wrong with that. Although, personally, I try to avoid mixing them because such tests might fail for many reasons. This makes it harder to pinpoint and fix the issue behind the test failure.
UI assertions
UI assertions should be a priority. Although there are a fraction of components that are not meant to render anything visual (think of Context API providers, SEO wrappers), most React components have a sole reason to exist: produce UI.
Therefore, UI assertions give you the most confidence that the application is working as intended. If there’s one thing that you should take away from this blog post, take away this: prioritize UI assertions.
Thoroughly and effectively tested React application begins with plenty of tests that assert how UI changes in response to user interaction, time passing, or whatever else that might change the UI. For that reason, when starting to think about component test cases, try to answer this very question: how does the UI change?
To illustrate my point, let’s talk about a classic example of a counter component. I’ll assume the counter cannot fall below zero.
I can think of a couple of ways the component evolves its UI:
- Initially, it renders zero
- Counter is incremented by one when +1 is clicked
- Counter is decremented by one when -1 is clicked
- Counter stays zero when -1 is clicked
A useful technique that really helped me early on is to write down all test cases before implementing them. Or, if you are really up for the challenge, you can try TDD (Test Driven Development), which presumes writing tests hand-in-hand with the application code. In any case, we can make use of Jest’s it.todo()
API:
1it.todo('renders zero')2it.todo('increments counter by one on click')3it.todo('decrements counter from one to zero')4it.todo('does not decrement when counter is zero')
When it comes to implementing the tests, keep in mind that RTL queries have an order of priority of UI assertions. Testing Playground is a tool that might help out in choosing the most suitable query.
One important thing to note here — role queries can be incredibly slow for large DOM trees (in my experience, a single role query might take more than 1 second), so you might want to take that into account.
Variations of UI assertions
Just to give you a gist, these UI assertions are probably the ones that I’ve encountered the most:
- Asserting presence:
expect(screen.getByText('+1')).toBeInTheDocument()
- Asserting absence:
expect(screen.queryByText('+1')).not.toBeInTeDocument()
- Asserting an attribute:
expect(screen.getByRole('link', { name: 'Threads' })).toHaveAttribute('href', '/forum/threads'))
- Snapshot:
expect(container).toMatchSnapshot()
Note that get RTL query is being used when asserting for presence and query is being used when asserting absence. Useful read: Common mistakes with React Testing Library
Snapshot tests
Jest provides a way to write snapshot tests, which is a fancy name for a test that has expect(...).toMatchSnapshot()
assertion.
In my book, it falls into UI assertion category.
You might want to incorporate snapshot assertions into your test suite, but beware that they come with their share of pitfalls! I elaborate on that in What NOT To Assert in React Component Tests.
Non-UI assertions
It is not enough to solely rely on UI assertions if you want to have an effective test suite, because there are many things that your component might be doing besides rendering UI. A few common examples:
- Fetch data
- Log BI (Business Intelligence) events
- Call 3rd party SDK
Most of the time, having non-UI assertions is slightly more cumbersome than UI assertions, because you have to spy on methods using jest.spyOn()
. In some cases, you may also want to mock certain modules, which can also be done using jest.spyOn()
.
As an example, you certainly don’t want to call production endpoints when fetching data.
Which non-UI actions should be tested?
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?
I’ve also touched upon this in What NOT To Assert in React Component Tests, but just to reiterate, my view is that the assertion is beneficial, as long as it’s a side effect that is important to the user or to other components (think of fetching data, dispatching a Redux action that other components rely on).
Fetching data
A few practical tips regarding fetching data, as it is perhaps the most common non-UI assertion use case.
Instead of mocking an API client (e.g. axios
, fetch
), you may want to take a look at MSW (see Stop mocking fetch for a deep dive into the reasoning) or nock — those will make your tests less dependent on the API client.
However, MSW discourages asserting whether an API request has been dispatched. I disagree. I see the benefit in asserting whether an API endpoint has been hit. Those assertions give confidence that we’re dispatching the request at the right time and an intended number of times.
I find it particularly useful when fetching data in useEffect
.
It is very easy to misuse useEffect
API, causing an API request to be sent more times than intended.
Illustration:
1import { useEffect, useState } from "react";2import { render } from "@testing-library/react";34const App = () => {5 const [isFetching, setIsFetching] = useState(false);67 useEffect(() => {8 const fetchData = async () => {9 await fetch();1011 setIsFetching(false);12 };1314 setIsFetching(true);15 fetchData();16 });1718 return <div />;19};2021it("renders learn react link", () => {22 const fetch = jest23 .spyOn(window, "fetch")24 .mockReturnValue(new Promise(() => undefined));2526 render(<App />);2728 // this will fail because we missed useEffect dependency array29 expect(fetch).toHaveBeenCalledTimes(1);30});
By the way, mockReturnValue(new Promise(() => undefined))
is a trick that can be used to make a promise to never resolve.
It helps in avoiding act()
warnings.
I’ve written about why these warnings occur in You Probably Don’t Need act() in Your React Tests.
Integration tests
At some point, the question of “should I assert logic of child components” becomes relevant. This is a very valid question. It is rather similar to “should I write integration tests?”
Yes, you should. Integration tests give a lot of confidence. Especially when you think about it from the user’s perspective — users don’t care whether the component is a child component or a parent component. Hell, it could even be a grandmother component!
However, there are tradeoffs. I’ve covered cases that I think you should not cover with integration tests in What NOT To Assert in React Component Tests.
Summary
If you’ve made it thus far — be proud, I know this wasn’t easy! I hope that by now you have a much better view of what you can assert and the tradeoffs of having different types of assertions. A quick recap:
- Prioritize UI assertions, such as
expect(screen.getByText('Hello world')).toBeInTheDocument()
— these assertions give you the most confidence - Non-UI assertions, such as
expect(logBusinessEvent).toHaveBeenCalledTimes(1)
- Assertions that cover child component logic