It's all about unit testing?
It's a major subject with many controversial opinions from both sides. Although both are not wrong, one is a bit more redundant, in my opinion. Before we jump in, I'll leave this with a question: Are unit tests enough to prove our code is sound and correct, and prevent bugs? Let's discuss it. We need to start with a base application, let's say a nestjs application, with vertical slice architecture, where our application is mainly composed by: controller, service and a repository. Inside a repository, we will have data fetching, in most of the applications that's where you perform your database queries/selects/updates. Inside the service layer, it's supposed to handle business logic, whereas in 98% of the time it's only used to pipe data from the repository back to the controller. Inside the controller, well, it's only job is to receive the request and call the service layer. With all the definitions out of the way, let's define what are the tests that we usually do: Controllers On controllers, our main concern is to know if the services are being called correctly, only that. So 110% of the time we are just piping data to the services, so we usually do: describe("UserController", () => { let controller: UserController; let service: UserService; beforeEach(async () => { controller = await Test.createTestingModule({ imports: [UserModule], providers: [{ provide: UserService, useValue: { findAll: jest.fn().mockResolvedValue([]) } }] }).compile(); service = module.get(UserService); controller = module.get(UserController); }) describe("list users", () => { it("should return an array of users", async () => { service.findAll.mockResolvedValue([{ userId: "1" }]); const result = await controller.listUsers(); expect(result).toEqual([{ userId: "1" }]); }) }) }) The same pattern will be followed for all the methods on the controller, which does the same thing, expecting a result and seeing if we are receiving it. Does it prove something? Does it prove if the business logic was implemented correctly? Are the joins and the table columns being well defined?? Totally not There are so many nuances on a more complex application that these tests on the controller don't prove anything. Services Here is where we start to have the possibility to have more business logic into place, it's where we pull data from database and do something with it. 99% of you reading only pipes back to the controller on a simple CRUD, but in other situations you will do something with all the data Well, here's an example: describe("UserController", () => { let service: UserService; let repository: UserRepository; beforeEach(async () => { controller = await Test.createTestingModule({ imports: [UserModule], providers: [ UserService, { provide: UserRepository, useValue: { findAll: jest.fn().mockResolvedValue([]) } }] }).compile(); service = module.get(UserService); repository = module.get(UserRepository); }) describe("list users", () => { it("should return an array of users", async () => { repository.findAll.mockResolvedValue([{ userId: "1" }, { userId: "2", deleted: true }]); const result = await service.listUsers(); expect(result).toEqual([{ userId: "1" }]); }) }) }) This somewhat proves something, say that for example, for some weird reason, we are doing filtering over the deleted users on the service layer, which aims to be an example of a """"complex"""" logic. This at a certain level, proves that the logic done inside the service is correct. This makes a checkmark on preventing bugs and acting as guardrails for future refactors. But for the simplest use cases where it just pipes back data, that's totally a waste of time. Repositories Here is where the fun begins! What is a repository supposed to do? Well, retrieve data from the database. So how would we test it? describe("UserRepository", () => { let repository: UserRepository; beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ UserRepository, { provide: getRepositoryToken(User), useValue: { find: jest.fn().mockResolvedValue([]) } } ] }).compile(); repository = module.get(UserRepository); }) de
It's a major subject with many controversial opinions from both sides. Although both are not wrong, one is a bit more redundant, in my opinion. Before we jump in, I'll leave this with a question: Are unit tests enough to prove our code is sound and correct, and prevent bugs? Let's discuss it.
We need to start with a base application, let's say a nestjs application, with vertical slice architecture, where our application is mainly composed by: controller
, service
and a repository
.
Inside a repository,
we will have data fetching, in most of the applications that's where you perform your database queries/selects/updates.
Inside the service
layer, it's supposed to handle business logic, whereas in 98% of the time it's only used to pipe data from the repository
back to the controller
.
Inside the controller
, well, it's only job is to receive the request and call the service
layer.
With all the definitions out of the way, let's define what are the tests that we usually do:
Controllers
On controllers, our main concern is to know if the services are being called correctly, only that. So 110% of the time we are just piping data to the services, so we usually do:
describe("UserController", () => {
let controller: UserController;
let service: UserService;
beforeEach(async () => {
controller = await Test.createTestingModule({
imports: [UserModule],
providers: [{
provide: UserService,
useValue: {
findAll: jest.fn().mockResolvedValue([])
}
}]
}).compile();
service = module.get<UserService>(UserService);
controller = module.get<UserController>(UserController);
})
describe("list users", () => {
it("should return an array of users", async () => {
service.findAll.mockResolvedValue([{
userId: "1"
}]);
const result = await controller.listUsers();
expect(result).toEqual([{
userId: "1"
}]);
})
})
})
The same pattern will be followed for all the methods on the controller
, which does the same thing, expecting a result and seeing if we are receiving it.
Does it prove something? Does it prove if the business logic was implemented correctly? Are the joins and the table columns being well defined??
Totally not
There are so many nuances on a more complex application that these tests on the controller don't prove anything.
Services
Here is where we start to have the possibility to have more business logic into place, it's where we pull data from database and do something with it. 99% of you reading only pipes back to the controller on a simple CRUD, but in other situations you will do something with all the data
Well, here's an example:
describe("UserController", () => {
let service: UserService;
let repository: UserRepository;
beforeEach(async () => {
controller = await Test.createTestingModule({
imports: [UserModule],
providers: [
UserService,
{
provide: UserRepository,
useValue: {
findAll: jest.fn().mockResolvedValue([])
}
}]
}).compile();
service = module.get<UserService>(UserService);
repository = module.get<UserRepository>(UserRepository);
})
describe("list users", () => {
it("should return an array of users", async () => {
repository.findAll.mockResolvedValue([{
userId: "1"
}, {
userId: "2",
deleted: true
}]);
const result = await service.listUsers();
expect(result).toEqual([{
userId: "1"
}]);
})
})
})
This somewhat proves something, say that for example, for some weird reason, we are doing filtering over the deleted users on the service layer, which aims to be an example of a """"complex"""" logic. This at a certain level, proves that the logic done inside the service is correct. This makes a checkmark on preventing bugs and acting as guardrails for future refactors. But for the simplest use cases where it just pipes back data, that's totally a waste of time.
Repositories
Here is where the fun begins! What is a repository supposed to do? Well, retrieve data from the database.
So how would we test it?
describe("UserRepository", () => {
let repository: UserRepository;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserRepository,
{
provide: getRepositoryToken(User),
useValue: {
find: jest.fn().mockResolvedValue([])
}
}
]
}).compile();
repository = module.get<UserRepository>(UserRepository);
})
describe("findAll", () => {
it("should return an array of users", async () => {
const mockUsers = [{
userId: "1"
}, {
userId: "2",
deleted: true
}];
const typeormRepo = module.get(getRepositoryToken(User));
typeormRepo.find.mockResolvedValue(mockUsers);
const result = await repository.findAll();
expect(result).toEqual(mockUsers);
expect(typeormRepo.find).toHaveBeenCalled();
})
})
})
With the help of our friend chat gpt, I generated an example using typeorm.
As you can see, we are mocking the database. Well, again, will this prevent bugs or prove anything that the implementation is wrong?
HELL NO
As we proved over here, in 100% of the use cases testing a repository, a controller and sometimes a service is a waste of time, because if it does not contain complex business logic, you would be only proving that the data that you mocked is being returned correctly.
What is the correct approach then?
I'm very in favor of unit tests when there's complex logic on certain parts of the code and those can be easily broken on any change. By doing that you help others in the future understand the behavior of those functions and also prevent from future problems if someone change anything.
For those more simpler projects where we are basically doing a CRUD, E2E tests with testcontainers
is your friend.
With those e2e tests you will be able to act as a real consumer of your API for example:
- Showcase validation errors when interacting with your API
- Check for data being correctly inserted into your database
- Check for return data from apis
- Check for errors and other things.
Don't get me wrong, unit tests have their place to guarantee testing business logic or elements that have a lot of baked logic inside.
I hope to make the world of testing better <3