- Part 1: Unit Tests Basic Concepts (this article)
- Part 2: Unit Testing with NestJS
- Part 3: E2E Testing Basic Concepts
- Part 4: E2E Testing with NestJS
- Part 5: Other Useful Testing Strategies
Unit testing is one of the core foundations of any robust modern application, and something we're huge fans of at Trilon. Having your code properly tested has many advantages, and you've probably already seen someone proudly sharing how much test coverage their code has. Knowing how to do automated testing is a standard practice nowadays, but in this article series, let's dive in deeper to get a better understanding of the idea behind it. It is common to struggle when designing tests properly and understanding which parts of the code must be tested and, even more importantly, which parts shouldn't be tested.
In this blog series, we will cover all the different aspects of testing, and so much more.
We're going to start with a brief introduction explaining what automated testing is, and what exactly is the scope of an unit test. In future posts, we're going to dive deeper into best practices about unit testing and, finally, how to properly test your NestJS application.
Picture this: You're the product manager responsible for the mobile app of a big restaurant franchise. Your company has many branches throughout the country and the engineers from your team just finished a new feature that will allow your users to order food directly from your mobile app.
You update the app on your phone, and use the brand-new feature. Everything looks amazing. Your team rocks ❤️ Just to be sure everything is perfect you test other parts of your app, and everything is good to go, so your team launched the new version.
After the release of the new update you look at your data charts and your new feature is killing it. The amount of users using it is way above your expectations. Everythings looks amazing and the team is really excited 🚀
But, a few days after the release, someone from a different team noticed that your feature broke a critical part of the app, and now no one can create a new account in the app anymore.
Although pretty specific, a scenario like what I described above is extremely common in software development, and they always fall into the following pattern:
You introduce a new feature or fix an existing bug Your team (or yourself) test what changed, and anything "closely related" to it After the release, you find a new bug introduced by your change, completely unrelated to what you've done
The problem with that pattern is what we believe is the "closely related" part. We're humans, not machines, and we tend to underestimate the side-effects of our changes, even if the architecture of our code is good enough.
So, it is impossible to predict all the side-effects of changing code, and it is impossible to manually ensure that everything works. That's why the software development industry created (in its very early days) automated testing.
In summary, automated testing is "the use of software separate from the software being tested to control the execution of tests and the comparison of actual outcomes with predicted outcomes" (ref).
In other words, it is when you code something that will run your application, execute a task and make sure that the result you expected is still functioning as intended.
With automated testing, instead of having to open the application, create a new user, and check if the user was created. You can simply execute one line of code and be sure that it is working. It is a way of saving time and effort in expense of slightly more effort during the development phase.
Since the creation of the concept, automated testing has taken many forms. Taking into account the example I gave before, if you have wanted to create an automated test to ensure that the user creation is still working, the most obvious and direct way of doing so would be:
Launch your application in a "sandbox" Execute the request to create a new user Check in the database if the user is created
By doing so, you're being sure that when your code tries to create a user, that a user is actually created. Like I've mentioned, the most obvious way of testing it is manually. For a single business requirement this is easy, but what happens when we have more business requirements? Something like, 600? Can we test each one of them manually?
This is why the concept of automated testing was created. The idea is pretty simple, an automated test is a piece of code that can execute a business requirement against your code. And this can take many forms. For example:
- You can run your entire application and execute a request to it, checking if it worked (this is called end-to-end testing);
- You can integrate a bunch of modules, but not your entire application, executing a given use-case and check if it worked (this is called integration testing);
- You can launch a single part of your application (a module), execute a use-case and check if it worked (this is called acceptance testing).
But what is a Unit Test, after all?
Well, unit testing is not a simple subject to understand. Many developers have different ideas about what a unit test is, because of the definition of it (according to Wikipedia):
"In computer programming, unit testing is a software testing method by which individual units of source code (...) are tested to determine whether they are fit for use"
And this is where things start to get messy. In order to understand what that Wikipedia definition means, you need to understand two important concepts: unit and mocking.
A unit is a specific part of our code. And this is one of the easiest concepts to misunderstand. Consider the following example of a simple application structure:
CatsApplication (class) > AdoptionModule (class) > AdoptionService (class) > adoptCat (method)
What is "the unit" of that application?
Some might say: "It is the adoptCat method, of course!" but, that is not entirely correct. Although the adoptCat method is, indeed, a unit, any of those can be units as well. The only difference between them is the level of abstraction.
adoptCat method is (in that small tree) the lowest level of abstraction, since it contains the implementation of a specific use-case, while the
CatsApplication has the highest level of abstraction.
For now, keep all of this in mind, while we move forward to the next important concept for a moment, that is also highly misunderstood.
Before diving into mocking, we need to understand what a test double is. A double (link stunt doubles from movies) are anything that can replace a production component. For example, instead of using the real implementation of an AdoptionService you could use a double. This is a pretty useful concept, since we can easily control doubles, making them behave as we like.
There are many different types of doubles, and explaining all of them isn't within the scope of this article. Mocking is the act of creating a mock, which is a type of double, where we control the state and output of a dependency in order to have some control over our test execution. This is extremely useful when we're coding automated tests.
A common problem with mocking is when we mock what was not supposed to be mocked.
In general, the best practice is to mock only the dependencies that are not directly related to the business logic we're testing.
For example: in our
adoptCat method (example above). We might have a
catsRepository that will retrieve data from our database. In order for our tests to execute the code, we have two options:
- We can inject a valid repository, that will retrieve data from a real database (which would be useful in integration and end-to-end tests)
- Or we can use a double of a real repository to retrieve fake data in order for us to have a more fine-grained test (used in unit tests)
So, in cases like this. We can easily mock our database and execute our tests. But, what happens when getting data from the database is our use-case?
It is really common to see unit tests that mock the database, configure the mock to return the expected data, then execute an use-case that would basically retrieve data from the database, which was exactly what we've mocked. In cases like this, we shouldn't mock the database.
Actually, use-cases like that one shouldn't even have a unit test in the first place.
It is important to notice that if that same use-case has any business logic related to it (like throwing an exception if the entity is missing from the database), then we should have unit tests dedicated to ensure that the business logic works. In general, we should only add unit tests if we have something being handled by our unit.
Now that we understand the two basic concepts related to unit testing, some problems that we might run into are pretty clear. It is super common for developers to test the implementation, not the behavior. Unit tests must be focused on the expected behavior, testing the result of executing a given use-case. We typically do that by asserting:
- The return of the execution
- The side-effects of that execution
Whenever we see an unit test that is asserting things like:
- If a given mocked function was called with the expected parameters
- If a given logic was executed in the expected order
- If a given input was used inside the function
Chances are that this unit test is testing the implementation instead of the behavior (but, of course, there are some exceptions).
And this is a bad practice because our test would be too fragile.
It is not acceptable for a unit test to break upon refactoring, or if we changed the implementation of a specific use-case without affecting any of its side-effects and behaviors. When we see a codebase where any refactoring leads to dozens of tests failing, that is typically a result of bad unit testing.
This usually happens because of a misunderstanding either of what a mock is used for, or a misunderstanding of what a unit is. It isn't always true that the unit that we're testing must be all the functions within an application. Depending on the application structure, we could test our use-cases by:
- Executing some critical public functions (which contain business logic), mocking their dependencies (unit tests)
- Launching a module, and executing a couple of read-queries that are integrated with the database (acceptance testing)
- Launching the entire application and dispatching some HTTP request to test our controllers and application structure (e2e testing)
In general, unit testing is only useful when we have relevant business logic within a given unit.
In this article we explored some basic concepts about automated testing. We understood the base difference between automated tests and unit tests, as well as the role that unit tests play within our testing strategy. Finally, we understood that unit tests are designed to ensure that our unit's business logic is working properly, and we should only code a unit test when we have at least one relevant business logic happening within that unit.
In the next article of this series we're going to understand how NestJS handles unit testing, and how we can easily integrate our application within a testing tool to start coding unit tests.
Learn NestJS - Official NestJS Courses 📚Level-up your NestJS and Node.js ecosystem skills in these incremental workshop-style courses, from the NestJS Creator himself, and help support the NestJS framework! 🐈
🚀 The NestJS Fundamentals Course is now LIVE and 25% off for a limited time!
🎉 NEW - NestJS Course Extensions now live!