The line that separates excellent from average React code can be blurred. Sometimes we refer to good code as “elegant”. Elegance is a very human and subjective quality. There is, however, one objective thing that can test if some code is good (AKA elegant) code, and that’s other code.
On this page:
- Testing approaches
- Focusing the discussion, unit test vs integration test
- Compare and contrast
In this article you are going to learn what is the best approach to test your React components. The bad news is that I'm not going to directly tell you what the best approach is. The good news is that by the end of the article you should be able to make your own informed decisions about what’s the most “elegant” approach for your code.
The most common types of tests we write are end-to-end (AKA e2e), integration tests, and unit tests. e2e executes a user interaction from one end to the other end. For instance, a user logging in interaction requires from a real browser (one end) to the database (the other end). The e2e will test any code in between both ends.
A unit test tests a unit of code. For the purpose of this article, which is focused on testing in React, I’ll refer to a unit as a React component. So in this context, unit means a glob of UI that's intended to be used. Discussing what a “unit” of code is can be an article in itself. No worries, we’ve got that article. An integration test is a test that tests anything in between the other two.
There are different approaches to test a React app based on some variation of those 3 types of tests. Let’s see some of these variations:
This approach tells us that we should write mostly unit tests, followed by fewer integration tests, and even fewer e2e tests.
There are two main reasons we should not write too many e2e tests according to this approach. First, e2e is slow because it tests a lot of things, and it requires other software to run, such as a browser, database, etc to complete the test. Second, e2e tests are fragile in the sense that if any of the many pieces it tests in one go fails, then the entire e2e test will fail.
On the opposite of the e2e side of the pyramid there is unit testing. Unit tests run fast because they don’t require many resources to run. Unit tests are less fragile because they test small parts of code independently. If a unit test fails, it should fail in isolation and not affect the other unit tests.
Mike Cohn coined the term Test Pyramid in his book “Succeeding with Agile”, first published in 2009. Many companies follow this approach nowadays. However, many things can happen in 10 years in the software industry, for instance, e2e tools have improved significantly since then. Therefore, being used by many for many years doesn't mean we should not question it.
Another approach is the Aaron Square, introduced in early 2018 by Aaron Abramov. If we follow this approach we should then write the same amount of e2e tests, integration tests, and unit tests.
Another approach is the Kent C. Dodds Trophy, introduced in early 2018. In this approach, we should write more integration tests than unit tests and e2e tests, followed by a static type checking (TypeScript, Flow, eslint, etc).
In this article, we are focusing on the discussion of integration tests versus unit tests in React. E2E tests and static types are agnostic of the library or framework that we chose to build the UI. We can use Cypress and TypeScript with Angular or Vue for instance.
If you wonder why then I explained e2e in this long intro, it’s because I want to stimulate your critical thinking and question some of the beliefs you might have around testing. Presenting you 3 different established approaches from 3 different experts sets a nice ground for questioning.
Therefore our final questions could be, should we write more unit tests than integration tests? or the other way around? Maybe fifty-fifty?
Once I had the good fortune to get trained by Kyle Simpson, and did pair programming with him. I asked him: "Kyle, do you write more unit tests or integration tests?". He replied something along the lines: "obviously, I write more integration tests". 🤔... When I asked him why, he replied "...users never use units. Those units are always composed with other units to achieve some greater functionality that users will use. Therefore it is more valuable to test how those pieces work together rather than testing them in isolation."
There are different opinions when it comes to testing, even among respected developers. So, what should we do?
It seems we can’t all agree on the right testing approach. Different experts have different opinions. I also have my own opinion, which I won’t tell you. Instead, I’ll tell you what’s the criteria and principles I use to compare them.
First things first, let's define a criteria. To me, a good test is such that:
- I can refactor my code without changing my tests. Which makes me happier.
- It gives me a high degree of confidence that the code I write works as intended.
This is my criteria. I encourage you to have your own. Most likely, you don’t spend 100% of your time as a developer writing tests (I hope, neither, the opposite), but writing good tests is highly influenced by the quality of the code being tested. Therefore, having criteria about what makes a good test will guide us in everyday code-related decision making.
Testing what the code does means that the code we write to test some other code knows no implementation details of the code being tested. If we test the “what”, then we can refactor the test subject without changing the tests associated with it.
Testing how the code being tested works means I’ll likely need to change the test when I refactor the code being tested. In other words, the test knows implementation details of the test subject.
Based on my criteria, testing the “what” is better. The “what” and the “how” is also known as black-box testing and white-box testing, being the “what” the black box, and the “how” the white box.
We know writing software is complex, and so it’s likely that tomorrow we’ll need to change the code we write today. Let’s embrace the change.
One principle many of us follow when building software is to build small independent units that can be reused, like Lego pieces (oops I used a cliché 🤭). The problem is, depending on how we wire those units together, it’ll be difficult to unwire them in our tests at our convenience.
“Unwire them in our tests at our convenience” - yes I’m suggesting we should consider adapting the code to the tests, 😱. You might think that’s fundamentally wrong. Theoretically, I could agree. In practice, if that adjustment significantly improves my refactoring, and increases my confidence at almost no time-cost, then I tend to disagree we should never do it. But! I understand you might have a different criteria, which is perfectly fine if it works for you.
Dependency Injection (also referred as the broader technique of inversion of control) is a technique whereby a glob of code (functionality) is supplied to a unit that depends on it in a way that the supplied functionality can be replaced by any other before or at runtime.
An example of this can be a React component that fetches some data from an API when the component mounts. When the app runs on the user’s browser, we want the component to connect to the API. If, for instance, in our test environment the test didn’t have access to the API or it did but it was very slow, then when running the test we would want to replace the code that connects to the API with some other code that returns a response directly, without involving any network request.
To be clear, I’m not advocating Dependency Injection (DI). Based on the first principle I’m following, testing the “what” and not the “how”, DI persé is not a good thing. The reason is, every time I inject a dependency it means I know something about how the functionality is implemented. From a pure black-box perspective, I shouldn’t even know that the code I’m testing has a dependency.
DI can minimize a problem, but the problem will still be there. As long as our apps have side-effects (the problem) - and I haven’t seen one app without any side effect - we’ll have to somehow deal with that.
Designing our entire application around DI, like some frameworks as Angular do, could encourage implementations that make refactoring and testing tedious in my experience, which defeats its purpose. However, I think DI is a good tool used wisely.
Enough talking, let’s compare some code in this video:
Related articlesThe challenge of learning while working: Rafael’s storyRemote GraphQL Training: Raghu’s Story Learning React in COVID-19 times: Daniela's storyTop 10 reasons why you should learn React right nowHeading to React Amsterdam? We’ll see you there!
Join the newsletter
Do you like the article? Sign up for our newsletter and don't miss a thing!Sign up
Share this on:
This website is built using Gatsbyjs. Curious about how this blog is implemented? It's open source so you can check the source code
Comments? Shoot me a tweet @alex_lobera !