React Testing: Challenges and Tips
Table of contents
In recent years, software testing has become a hot subject for the front-end world. But it wasn’t always like that. This is probably due to the fact that FE design is something we all expect to change a lot during development. This is why writing tests for FE apps seems harder and a waste of everybody's time. It just seems pointless to do it for many projects.
When you first start learning about software testing you stumble across all those oversimplified examples which make you believe that everything is very easy and straightforward. Things just fit in nicely. In these contrived examples you can hardly see the everyday struggle developers face while writing tests on the real project.
And, the struggle is real :) .
This might be a reason I have always felt there is some gap between learning the basics and dealing with the challenges we face on the real projects, where things just don’t always go so smoothly.
This could be the reason why so many people quit writing tests soon after they try it. But what they should do instead is stick with it, because they will eventually become better at it’s definitely worth the effort. And to convince ourselves that is true, let’s start with the why.
The first reason to write tests is lowering the chance of having bugs in the production code. Bugs usually cost money. Money invested in time to debug them, effort to fix them, money lost on application downtime, you name it. And as soon as we catch them in the development process, the less harm they cause.
Another reason is the feeling of pride and self-fulfillment when you ship something you know was done right and you have confidence in.
It also improves the collaboration between developers, as software covered with tests is more easily changed and refactored, which ultimately makes it better. Have you ever worked on a legacy project where you’ve been asked to make some changes to the business logic on the existing feature which is not covered by tests? It might feel like walking on ice. You are trying to make the changes and at the same time alter legacy code as little as possible, in order not to break something. Not a great place to be at.
Hopefully convinced that this is all worth a struggle, let's embrace it, and see which are some of the most common problems we can face.
The Challenge: Not Enough Time for Testing
Some projects just don't have enough budget for the excellent test coverage and that’s fine. But, there are also teams which don’t think testing is a worthy investment, and you might be working in such a team.There is always at least something we can do to improve the software, so here are a few tips.
Tip #1: Cover only critical parts
It’s great to have high test coverage, but that’s not always possible to achieve or even necessary. When we have limited resources, our focus should be on the business perspective and we should try to cover mission-critical features. If we have that covered, we have lowered the probability of a disaster and have more peace of mind. If some widget is not showing the spinner while loading or we are not redirected to a certain page after a certain action is completed might not have a considerable impact on the user experience. On the other hand, not being able to login or complete the payment definitely will.
Tip #2: Replace manual testing with unit tests
How much time do we spend manually testing the features we’ve just implemented, by trying to cover all the scenarios and edge cases? Depending on the feature, this process can be very time-consuming and error prone, and it is almost impossible to cover everything. And when we change a part of the code logic in future, should we repeat the whole process? Often writing just a few unit tests could save us the trouble, and prove to be a good investment. This particularly refers to smaller, more simple functions, which are easily testable. So next time you are writing some helper function, consider adding tests as well.
The Challenge: Maintaining the tests
Unmaintainable tests are a nightmare because they can ruin project schedules, or they may cause testing to be sidelined when the project starts facing a more aggressive pace of development. If each change we make to the production code means that we have to modify a bunch of existing tests just in order to get them green again, we might not be doing such a good job.
Sometimes this is expected, especially when requirements change. But, more often than not, it just means we were relying on the implementation details in our tests. Business people won’t appreciate the negative velocity impact this maintenance has, so nobody is happy here.
Here are few pieces of advice on how to fight this problem:
Tip #1: Avoid testing implementation details
It could be very tempting to write tests which rely on the implementation details. We are the ones who authored the code in the first place, so we are used to thinking in terms of the implementation.
Testing stuff like internal components state, asserting that the underlying component is called with the correct props, whether some “setState” function was called or not, etc. Having assertions like those in the tests will certainly lead us to situations where we've refactored some of the code, without changing its functionality, and still end up with broken tests. Bunch of “fixed tests” commit messages start to appear in the version control log.
We get so focused on the implementation details that we might forget to test what is actually important. So what to do about it?
Tip #2: Testing from user’s perspective
The idea behind this is to be less focused on the inner workings of our component and think more like the user, two users actually 🙂.
One is our fellow developer who is using our component. This means that we want to test the interface of the component - to check if we can pass some props to it, and get the correct render result. If we are using react-testing-library (in short: RTL), we have DOM queries to help us with that.
The other user is the end user of our application, of course. So rather than triggering state changes manually, we can use actions that the user would normally do if he/she was using our application. For example, when it comes to things like clicking the buttons, hovering elements, typing into input, selecting value, etc., RTL also has utilities for them.
After state changing action is executed, we can once more assert that what is rendered (what users see) is what we expect. We are mostly interested in “what goes in and what comes out” and not so much about the details of the in-between process.
At the end of the day, what the user sees and interacts with is the most important thing. For example, we shouldn’t test if some reducer’s action was called with the right error message, but rather assert if this message can be found in the DOM. Today we might show error messages under the form, and, tomorrow, we might show the toast message. We don’t want to have to change our tests because of these trivial changes in the design.
If you want to learn more about this topic, check out this awesome blog post Testing Implementation Details.
Tip #3: Treat you tests as production code
The number of tests in our code base will grow as the project develops. We will have to read them, make some changes to them along the way and use them to trace bugs when they fail. Our fellow developers will have to do the same. So this is not just some stuff we’ve written and forgotten about. It is important that we need to keep them in good shape as the project grows.
Most of the clean code tools and principles we strive to use while developing software apply to the test base, too. We want to make tests readable, avoid duplication, and make stuff easier to change. By doing this we are preparing our tests for the “hard times” when the project approaches the deadline and hard-to-maintain tests are commented out or deleted easily, with a note “we will get back to this after the demo”.
| Narrator: “And they never did...”
Tip #4: Automate tests
Running tests should be easy and fast. Every developer should be able to do this with a press of a button or by running a simple command. No extensive environment specific setup should be needed. This will ensure that tests are run often by everyone during development.
That is how we can benefit from tests alarming us of the issues early on and while we are on the subject. Luckily this has already become a common practise, and there are tools (like jest - https://jestjs.io/) which can help us with that.
Tip #5: Use tools which enforce good practises
Using the right tools can help a lot with writing tests. Besides enabling us to type less, these tools can help us enforce best testing practices. This is also very helpful during the onboarding process as less knowledge transfer is needed to bring developers up to speed.
There are a lot of tools out there, and choosing the right one for the job is certainly a challenge. Some of the most frequently used nowadays are eslint and prettier for the static testing, (react) testing library for unit and integration tests and cypress for the e2e.
It helps to be a part of the community which uses the same technology stack as you do, but try not to get overwhelmed by the hype around new libs and frameworks that pop up. Keep an open mind and try to lay out pros and cons before hopping on the hype train 🙂.
Tip #6: Keep practicing and learning, don’t give up
Practise makes perfect. Cliche, but it can’t be more accurate when it comes to improving your testing skills. The general idea of testing sounds simple but it takes experimentation and experience in order to get a feel of how to write good tests. So don’t beat yourself up when tests you’ve written are not perfect, just try to learn from each mistake you make, and then carry on!
| Motivational music playing in the background
The Challenge: We Lose Confidence in Our Test
One of the things we expect from a test suite is to give us confidence that the code we wrote does exactly what it is supposed to do. If we don’t feel confident that our application will work as expected even with existing tests, then the time invested in writing them didn’t provide much value.
Tip #1: Getting more confidence
When we are talking about web applications, in most cases, our code is supposed to respond to the needs/actions of the user. So the better our tests describe the behaviour of the user, the more confidence we have in our application. It is as simple as that. But people too often forget about this when writing tests as they get caught up in the implementation details.
Thinking about how users interact with the application and having those interactions covered with tests will certainly increase our confidence. With this mindset, we can make sure we are testing the right thing.
Tip #2: Use mocking with caution
Mocking is a great tool for unit testing as it can help us isolate units under test or detach them from external dependencies. It can also speed up the test execution as irrelevant parts are mocked out.
Important thing to note here is that whenever we mock a part of the system, our confidence drops a little as we have replaced some parts of the real system with the fake ones. So we should try to use these techniques with caution and only when it makes sense.
Also we want to make sure that interaction with things we mocked in unit tests is covered by the integration or e2e tests. That way we can regain confidence that our software works well as a whole.
“20 unit tests 0 integration tests”
Tip #3: Write more functional tests
For me, this is the hardest thing to get right. Say we have a component which renders a few other smaller components. Not a rare thing in the React world, right :).
While having unit tests for all those child components is a good thing, it’s not worth the effort if we don’t test parent components to verify how all these work together. Also these child components tend to be refactored a lot, they get split into several components, their props change, etc.
All of this will result in us having to change the tests. So the advice here is to lean a bit towards writing tests for the parent component, and then test some edge cases in the child component when needed. Larger scope tests also tend to give us more confidence.
This is not a hard rule, of course. Some smaller components are highly reusable and deserve strong unit tests themselves.
Testing software is not an easy task and certainly has its challenges. But given all the benefits we can get from having a good test suite our effort is worthwhile.
Like with all things, we get better and better with practise. We should not give up on the testing, but try and improve gradually over time instead. By joining the communities around your favourite technology and following bright people on social media you will, no doubt, learn that you are not alone in the struggle and find ways to improve your skills.
Latest blog posts
Intent classification: understanding text with the powe...
In today’s world, with the expansion of data generated from various sources, analyzing it has become a critical challenge for businesses. Read more about how intent classification of textual data works and how it can lead t...
What Is Stable Diffusion and How Does It Work?
For the past few years, revolutionary models in the field of AI image generators have appeared. Stable diffusion is a text-to-image model of Deep Learning published in 2022. Find out the reasons why Stable diffusion gained ...