If you are making your first steps in code testing, I want to start by congratulating you on walking that path! 🥳 This is a crucial decision and a great addition to your skill set. Before writing some tests, it’s vital to grasp the difference between two fundamental testing methods regarding integration and unit tests. In this post, we’ll dive into these testing types and explore their differences.
Testing scenario for our comparison
To understand the difference between integration and unit tests, let’s use an example of a function that calculates a user’s daily budget. We’ll name our function determineUserBudget
and have the following flow:
- Takes a
userId
parameter to perform a search for a user. - Uses the provided
userId
to search for the corresponding user in theUsers
table of our database. - If we call the function without passing a
userId
, it will throw anError
to indicate the missing parameter. - Furthermore, if no user is found, it will also throw an
Error
to signify the absence of a matching user. - However, if a user is found, it will use the user’s info and call our
Budget
service. - Subsequently, it will perform budget calculations based on the retrieved data.
- Lastly, the function will return the budget as its final output.
Let’s see what was just described through the following diagram so that it is easier to understand the function flow:
To differentiate between the internal workings of the function and the external connections that need to be made, I am using a dotted border.
Now that we have our testing scenario, let’s see what’s the difference between these two types of testing, and how we would test our function in each method.
(We won’t be writing any code in this post).
What is a unit test?
When writing a unit test we want to isolate the part of the code we are testing from the rest of the system.
This Is The Main Characteristic Of Unit Tests, Isolation!
Consider it as if you are focusing all your energy only on what’s happening inside a function without any regard for the function’s calls/interactions with the database, services, or any other invoked functions.
In our scenario, to unit test determineUserBudget
, we’ll ignore the call to our database and the call to the money service. Our focus will solely be on testing if all potential function flows (see green arrows) are followed as expected.
So, for the unit testing methodology, here are some testing scenarios we could write regardless of the testing suite, to ensure comprehensive coverage of your function:
- it should throw an error if no userId is passed.
- it should make a call to the database if the correct userId is passed.
- it should throw an error if no user is found.
- it should contact the Money service if a user is found.
- it should return the calculated user’s budget
However, what about the two calls in the database and service? How can we isolate our function from these calls?
When writing unit tests we need to substitute the external calls with mock functions so that we can simulate the call without actually making it.
A mock function is a function that replaces the behavior of an actual function during the testing.
To create an effective mock function, it’s essential to understand the response type and structure of the function being replaced. Understanding the response type and structure allows us to mimic the behavior of the original function and ensure that the mocked function provides the expected data during our testing.
After replacing the external calls with mocked ones we can safely focus on testing the function without any interactions with the external environment. This isolation allows us to concentrate on the internal workings of the function and successfully unit test it.
What is an integration test?
In contrast with unit tests, the keyword on integration tests is not isolation but interaction. Through integration tests, we are testing how different parts of our code interact with each other.
The Key Word On Integration Tests Is Interaction!
In our testing scenario, we will test much more! We will test our function’s results, including the interaction with the database and the correctness of the budget returned. The difference with the unit test is that we will pass “real” data and receive back “real” data to test.
In the integration testing methodology, the following testing scenarios could be some examples you could use based on our use case:
- it should return X budget when a user is approved.
- it should return Y budget when user is new.
- it should follow Z budget calculation methodology when user is pending confirmation.
- it should calculate the budget correctly for a user with a positive income and no expenses.
As you can see, the nature of our tests changed; we shifted our focus from testing the internal flow, to verifying our function produces the desired results, data, and budget.
Writing integration tests can be challenging because they require proper data preparation before executing the actual test. It’s quite common to spend more time during the initial steps to understand the required data and set up the necessary test environment before successfully executing the test.
Trusting external package providers
See that I didn’t mention testing the connection with the service? Well even if these are integration tests, they still don’t test connections with external services/providers.
Through integration tests, we are testing the way our functions interact with each other, not how they interact with external service functions. These functions are expected to have been tested by the service provider.
So, if for example you like using lodash and have used isEmpty
in your function, you should not write unit tests about lodash isEmpty
. It should be taken for granted that isEmpty
is a black box for you that has been thoroughly tested by lodash’s developers.
Caution when using real data
It’s important to note that when I mention using real data, I am not referring to the same data used on the production site. Using production data in testing can lead to serious issues, like GDPR compliance and the leak of sensitive data.
Instead, by “real” data, I mean realistic/similar data, that the testing suite will use based on the testing environment. For example, when testing locally, the test would use data from the local database. When testing on a staging environment, the test would use data from the staging database.
A staging environment is an environment we use for testing features and bug fixes before deploying them on production. A safe simulation of a site or software used in production.
You should anticipate that these databases will be populated with data that is structured similarly to your production database, but not identical.
Which testing method to choose?
While there isn’t a definitive answer to this question if I had to answer that, I’d say, “It depends.” And yes I know you probably don’t like that answer. In that case, if I had to choose one answer, I would say that you always need to choose both types.
Unit tests are awesome for checking function flows, but integration tests are the best so that you may have a good night’s sleep! 😴 What I usually do is follow an approach that combines both. For complex functions, I write at least one integration test and multiple unit tests for each individual function the complex function relies on.
I consider writing tests a form of art, and while it may take time to master, incorporating it into your workflow will bring immense value. Once it becomes an integral part of your everyday workflow, you’ll experience a remarkable mind-shift that will not only enhance your testing skills but will also change the way you implement a feature. You’ll begin wearing two hats, the developer and the tester. By doing so, you’ll be able to proactively think about edge cases, error conditions, and much more while implementing your features!
I can assure you it’s a mind-blowing and life-changing process. So what are you waiting for? Go for it!!