Testing software and making it easy to do so has been an interest of mine for a few years now. This interest is now more focused on frontend applications that used to be. More specifically, for reactjs applications. It’s been a few months now that I have been diving into a five to ten-year-old reactjs code that is providing me with insights and challenges.
I personally have been developing reactjs code for a few years now; one of the first open-source projects I have shared is called testable. It was released in 2020 or so, and since then, I have dedicated part of my learning path to reactjs.
Recently, I have been working on other open-source projects that focus on the testability part of things such as json-tool and text-tool; both applications are open-source and are deployed on Snapcraft. In addition to those, I frequently run experiments in a repository called reactjs-playground. It is the place where I experiment with reactjs features and dedicate my learning hours to it. The experience I have gained in those years in close-sourced projects and open-source projects gave me a foundation that allows me to identify some common pitfalls and advantages.
Pitfalls
Developers who join the reactjs pattern and tooling realize that the building blocks that the library offers are easy to understand and compose. The most abstract concept of a component can be used to compose user interfaces quickly. However, it can also be a source of mistakes when it comes to the structure of a component’s hierarchy. The decomposing of systems is a subject that has been studied for years.
Big Components
The size of the unity of abstraction in software has been a subject of debate as well; some argue that methods and classes should have a small number of lines, while others prefer to have a bigger, well-structured piece. As in any software project regardless of the size, I intend to prefer the size that provides more context and produces less friction on the cognitive load while reading the code.
Coincidence or not, in my experience, I find this possible when I have well-set boundaries for the piece of code I am working on along with the business context. I haven’t yet found the magic number for that, however, my measurement became the number of jumps I have to do between files to understand what I need to do.
As many jumps as I have to do, the more I need to hold in my head the context and information; this becomes hard while maintaining the code base. Decomposing those components is a challenge, however, it needs to be taken into account for better maintenance of the code base.
Global State
For professional development, the global state is a basic requirement; for reactjs applications, it is no different. The global state is easily spotted by users of the application. If you are buying something and you add the product to your cart, you can see the number of items you have added; this is the global state.
In reactjs applications, the once widely used global state management package redux has now decreased its usage in favor of smaller contexts around boundaries in the application. However, the community has shared different opinions on the need to use redux for global state management; this led to some blogs on the subject:
Despite pushback from the community, react-redux, which is a library used to bind redux to reactjs components, has had its adoption growing in the last five years according to npm trends. On February 01, 2025, the downloads shown in the npm for redux is 6,752,764.
If you are wondering what the alternative to that is then the answer is reactjs context and hooks with queries.
For applications that are dependent on the redux package, it is a challenge in itself to get away with it. The global state is one of the dependencies that decrease the testability of the application. In my experience, the required global context often comes with increased complexity of the domain knowledge. While you might want to test just a particular component or a slice of your application, you won’t be able to without sharing the global dependencies that this slice requires.
Advantages
Adopting reactjs for enterprise applications, at the time of the writing of this piece, has been a correct decision for maintainability (despite the critical moment that Facebook had when it changed the library licensing model). ReactJs provides backward compatibility for APIs that are no longer being used, which makes long-term adoption one of the key advantages.
Contexts
As if components are not enough as an abstract concept to get around, contexts are also one of the advantages that reactjs provides in regards to testing. I elaborate more on this subject in a blog post dedicated to reactjs context testing. The level of encapsulation that reactjs context provides is one of the key benefits for retrofitting tests and enabling refactoring in reactjs code bases.
As the decomposition of components and business logic are a challenge, contexts are the tools that enable a better restructuring of the code. In the testability part of things, this is also an advantage as it is a mechanism that enables the reduced number of test-double used, thus, leading to a more testable code.
Retrofitting Tests
Given such a list so far, the testing part hopefully has become clear that depends on how the source code is structured and how the boundaries of the application have been organized. However, as much as the testing code depends on the production code, the test doubles can be used to help delimit the scope. The thought process for retrofitting tests is composed of a few simple steps.
- Write a simple test case for the desired functionality.
- Run the test case and see it failing.
2.1. Did the test pass?
2.1.1 - Yes → Check if the feedback is correct
2.1.2 - No → Provide the dependency without questioning
- Go back to 1.
Note that: The approach described here is similar to what is described as the Characterization test described by Michael Feathers in the
Working Effectively with Legacy Code book.
The advent of LLMs can also provide insights while writing the first tests and retrofitting code bases without any tests. Each step in this strategy is meant to be iterative; it is possible to combine different styles of TDD as well. The proposed approach is not to stop doing everything and retrofit all possible test cases and all possible issues that the code base has, it is a puzzle game. Each functionality tested is a piece that fits into the puzzle.
The same approach is suggested for refactoring. Developers should be able to constantly refactor a piece of code. It is not a different project, and it is not aimed at only being dedicated to it. Its main goal is to make the code base better than it was. Iteratively. There are a few aspects that can be used to retrofit tests in code bases that have none:
- Fixing a bug – This is the opportunity to get exposed to the code and to understand how it behaves. Fixing the bug is part of the issue, however, this is also an opportunity to characterize the application at hand.
- Implementing a new functionality – This is the same as fixing a bug, however, the approach taken shifts a bit. It is the same approach as the one suggested by Kent Beck: “Make the change easy, then make the easy change.” A new functionality is a place that exposes the code base for making a change before introducing the new feature.
At the heart of this approach is the learning process; every step of learning the code base is taken into account.
LLMs
Copilot can be used to automate the first exploratory test described in the previous section; following three rules, it can get started with identifying dependencies and writing a set of comprehensive tests to be used as a base.
Let’s take Testable as an example; it is an application that was written more than five years ago in reactjs and uses enzyme for testing. For today’s standard, the library that took over is vitest or jest along with the testing library. To retrofit the test cases for testable and take advantage of LLMs, we can use copilot to help us with the heavy lifting.
Testable is composed of a gamified experience that has state, levels, and user progression through different challenges. The component described in the following image is the component used to show dialogs and to navigate forward in the history of the experience. Using Copilot for vscode, I asked it to write a test with the production code we are interested in:
Once it loads the question and analyzes the code, it provides the answer.
Along with the answer, Copilot noticed that I am not yet using a testing library and it suggests I do that, and after that, the test case generated is shown. Running the test case as it is shown makes the test not pass and marks it as red.
This point is important because it shows that additional setup needs to be taken into account and Copilot couldn’t figure it out. They are as follows:
- The import is not correct; it needs fixing to point to the correct file.
- The Setup for content is not correct; the file was not possible to make the content valid for the test case.
The flow described earlier in this section applies here as well. The feedback loop now is to start fixing those issues and run the tests until the TDD cycle is completed.