React Testing Library (RTL) is an end-to-end testing tool that’s designed to test your application’s UI from the user’s perspective (you can find more about why you should use it in my previous blog post). Since Redux is tightly integrated with React components, it’s typically tested as part of the components’ tests. Therefore, it’s important to have a solid understanding of how to write effective component tests using RTL before diving into testing Redux with RTL.
Once you have a good grasp of testing React components with RTL, you can start to think about how to test your Redux store and the interactions between your components and the store. This is where testing with RTL can become particularly useful.
In this blog post, we’ll explore whether or not it’s necessary to test Redux with RTL and provide guidance on when and how to do so. We’ll also cover best practices for testing Redux with RTL, including how to mock your Redux store and write effective integration tests.
There has been a longstanding debate in the testing community about the boundaries between unit tests and integration tests and what constitutes a true “unit” of functionality.
Traditionally, unit tests were defined as tests that isolated a single unit of code (e.g., a function or a class) and mocked all of its dependencies. The goal was to test the unit in isolation to ensure that it behaved as expected under various inputs and conditions.
However, in recent years, a new philosophy has emerged that challenges this strict definition of unit tests. This philosophy, often referred to as “sociable unit testing,” encourages developers to write tests that exercise multiple units of code together in order to test their interactions and integration points.
When it comes to testing Redux with RTL, it’s important to consider where the boundaries between units of functionality lie. On one hand, you could argue that a single Redux reducer or action creator is a “unit” that should be tested in isolation. On the other hand, you could argue that the Redux store itself is a unit that should be tested as a whole, along with its interactions with React components.
Ultimately, the choice of whether to write “true” unit tests or “sociable” unit tests with RTL depends on the goals of your testing strategy and the specific requirements of your application. Let’s dive deeper.
By far the most important way to ensure this is to write tests that invoke the system being tested in the same way its users would; that is, make calls against its public API rather than its implementation details. If tests work the same way as the system’s users, by definition, change that breaks a test might also break a user. As an additional bonus, such tests can serve as useful examples and documentation for users.
The more your tests resemble the way your software is used, the more confidence they can give you.
Firstly, sociable tests can help to identify integration issues between your Redux store and your React components. Since Redux is tightly integrated with React, it’s important to test how they work together in order to ensure that your application is functioning correctly. For example, imagine a situation when the contract of Redux action creator has changed (one attribute was renamed), you successfully changed your unit tests to cover the change but React component is still using the old contract. Boom, you introduced a bug to production (assuming, you don’t have end-to-end canaries covering this use case).
Secondly, sociable tests with RTL can help to reduce the amount of duplicated testing and provide more comprehensive coverage of your codebase by testing the interactions between different units of code. If you have already written component tests with RTL, you can leverage those tests to also test the interactions between the components and the Redux store. This can save time and effort in writing additional tests from scratch. Forget about writing separate tests for reducers, actions, thunks, middlewares, and whatsoever — creating unit tests for the component alone will cover all these Redux actors.
Thirdly, sociable tests can help to improve the maintainability of your test suite by reducing the number of mock objects that you need to maintain. If you’re writing isolated unit tests, you may need to create many mock objects for each unit, which can be time-consuming and error-prone. By contrast, sociable tests with RTL can reduce the need for mock objects and simplify your test code.
And last but not least. By writing sociable tests, you can future-proof your codebase and ensure that your tests will continue to work even if you make changes to your architecture — generally speaking, you make your tests framework-agnostic. One day you may decide to replace action thunks with stream-oriented rxjs middleware, upgrade your Redux architecture to use Redux toolkit, replace Redux with a different state management solution or get rid of Redux layer in general (welcome to heaven!) — your unit tests should pass!
Of course, there are also some potential drawbacks to sociable testing, such as increased complexity, higher chance of introducing brittle tests (due to async nature of UI interactions) and longer test runtimes. However, the benefits can outweigh the drawbacks in many cases, particularly for applications with complex interactions between Redux and React.
Writing unit tests for Redux can still be important, even if you’re also writing sociable tests with RTL. Here are a few situations where you might want to write unit tests specifically for Redux:
- When testing complex logic in reducers: While it’s possible to test Redux reducers through component tests with RTL, it can sometimes be more convenient to write isolated unit tests for complex reducer logic. This can help to ensure that the reducer behaviour is correct, independent of any particular component interactions.
- When testing side effects in Redux middleware: If you have custom middleware in your Redux store, you may want to write isolated unit tests to ensure that the middleware is working correctly. Let’s say you have some action triggered when a user navigates to specific routes. This logic usually exists outside of the component and can’t be triggered via UI interactions.
- Differences in the number or order of calls to a function would cause undesired behaviour. Let’s say you have a massive thunk action that implements heterogeneous transactional save of multiple related items — it might be important to have DELETE requests going before POST requests. You can argue that the same thing can be asserted in the component tests but it will make them less state-focused and add redundant complexity (we will have to parse entire API payload as we don’t have access to Redux actions). But this is more a tradeoff rather than a hard limitation. If you feel that the same thing can be done in component tests with minimal effort and with the same level of confidence, then why not?
NOTE: an approach, when unit tests validate how a function is called without actually calling the implementation of the function, is called interaction testing. The opposite strategy is state testing. With state testing, you render the component under test and validate that either the correct value (or element) was rendered or that some other state in the component under test changed as expected. Always prefer state testing! But in some cases, it’s important to check that the right API endpoint with the correct payload was invoked after some UI interaction triggered by RTL test in the component. So it’s OK to mix both approaches in one test:
expect(await screen.findByRole('input')).toHaveValue(expectedValue); expect(fetchMock.mock.calls).toMatchSnapshot();
Always be cautious that you mock network calls. In my post about best practices for unit tests, I already mentioned jest-offline library which might help to validate this.
In conclusion, whether or not you should write Redux tests with RTL depends on the specific needs and context of your project. While sociable tests with RTL can be a powerful tool for testing the interactions between Redux and React, there are still situations where you may want to write isolated unit tests for specific aspects of your Redux architecture.
Ultimately, the key is to strike a balance between the different testing approaches and to choose the most appropriate approach for each particular scenario. By combining sociable tests with RTL and isolated unit tests for Redux, you can create a robust and comprehensive test suite that helps to ensure the quality and reliability of your application.
Remember, the goal of testing is not to achieve 100% coverage, but to catch as many bugs and issues as possible before they make it to production. By using the right testing approaches and tools, you can improve the quality of your code and reduce the risk of bugs and errors in your application.
Originally published at https://thesametech.com on April 18, 2023.