- Part 1: TDD in NestJS - Unit Tests
- Part 2: TDD in NestJS - Integration Tests (this article)
- ... more to come ...
Introduction
In the previous article, we have presented TDD and how to apply it using NestJS in a narrowly scoped context (for simplicity's sake). This allowed us to build a lean service step by step by validating each business rule as a separate test case. Let's recall the conceptual schema and what we've accomplished so far:
Conceptual schema representing an appointment and its rules.
✅ An unconfirmed schedule should be created on success✅ The end time should not be before the start time✅ The end time should be after the start time✅ An appointment start and end time should be within the same day🔲 The patientId should be validated?
As you can see, we implemented all the business rules with simple unit tests. However, the patientId
remained an open issue. We couldn't quickly test it without integration tests, so, this article aims to fill that gap. Here, we'll learn how to build integration tests, drive our code architecture by these tests, and a few nice NestJS tricks as a bonus. I hope this article elucidates a few common questions revolving TDD and integration tests.
What are integration tests?
There are many descriptions of what integration tests are. Let's take a look at a few examples:
"Integration tests determine if independently developed units of software work correctly when they are connected to each other." (Martin Fowler)
"A test written by architects and/or technical leads for the purpose of ensuring that a sub-assembly of system components operates correctly. These are plumbing tests. They are not business rule tests. Their purpose is to confirm that the sub-assembly has been integrated and configured correctly." (Uncle Bob)
"Integration tests are tests that, unlike unit tests, work with some of the volatile dependencies directly (usually with the database and the file system). They can potentially interfere with each other through those dependencies and thus usually cannot run in parallel." (Vladimir Khorikov)
In our scenario, we're considering the last requirement, "The patientId should be validated?"
, to be part of our integration tests.
This is in line with the first definition above, because it's highly likely that a different module will be responsible for validating a patient's existence. Moreover, it's also aligned with the third description since a patientId
indicates that we should have some kind of database persistence that stores which patients we have in our application.
Nonetheless, before even considering which database we should use here, and how to create a test for the last requirement, we can turn our thoughts to a forthcoming requirement:
✅ An unconfirmed schedule should be created on success✅ The end time should not be before the start time✅ The end time should be after the start time✅ An appointment start and end time should be within the same day🔲 The patientId should be validated?🔲 We should be able to register a patient
It makes sense to prioritize this new requirement now since we cannot validate if a patient exists if no patient can ever exist, right? So, let's take a slight detour and focus on an emerging module: PatientModule
Implementing the PatientModule
💡 Note:
We'll continue our work from the last commit in this repository used in the previous article.
Let's create a new module that should be responsible for registering a new patient in our app. We can easily do so with nest-cli by changing your working directory to the root folder of your project and executing the command nest g module patient
. This will create a new patient
folder with a patient.module
inside:
📦 src┣ 📂 appointment┃ ┣ 📜 appointment.model.ts┃ ┣ 📜 appointment.service.spec.ts┃ ┗ 📜 appointment.service.ts┣ 📂 patient┃ ┣ 📜 patient.module.ts┣ 📜 app.controller.spec.ts┣ 📜 app.controller.ts┣ 📜 app.module.ts┣ 📜 app.service.ts┗ 📜 main.ts
Now, we can also create a service that will implement the register
feature. Yet again within our root folder, let's execute nest g service patient
. This command will automatically create a patient.service.ts
and a patient.service.spec.ts
file while also including the new PatientService
in the PatientModule's providers array:
import { Module } from '@nestjs/common';import { PatientService } from './patient.service';@Module({ providers: [PatientService], exports: [PatientService],})export class PatientModule {}
Note: Make sure to add the
PatientService
to theexports
array as we're going to need it later.
After that, we can finally start with our first real test case for the PatientService
class. Let's assume that our service will have a register
method, and it simply returns a new patient instance that only has an Id and a name:
import { Test, TestingModule } from '@nestjs/testing';import { PatientService } from './patient.service';describe('PatientService', () => { let service: PatientService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [PatientService], }).compile(); service = module.get<PatientService>(PatientService); }); // This test was automatically generated with the `nest g service` command. it('should be defined', () => { expect(service).toBeDefined(); }); // ✨ Our new, actual test describe('register', () => { it('should return a new patient with given name', async () => { const newPatient = await service.register({ name: 'John Doe' }); expect(newPatient).toEqual({ id: expect.any(Number), name: 'John Doe', }); }); });});
Let's reiterate an important point from the first article:
The first step of TDD is to implement the simplest possible solution to fix the failing test.
So, although we have mentioned that this service indicated that it has a database presence, we won't bother about it right now. Let's simply return what we expect for this test:
import { Injectable } from '@nestjs/common';@Injectable()export class PatientService { /* using `any` type here as a shortcut. We'll take care of this immediately after checking the test passes. */ async register(patientInput: any): Promise<any> { return { id: 1, name: patientInput.name, }; }}
If we try executing the tests, we'll see that they pass, so we can now move on to refactoring this code a bit. Let's create a PatientInput
type to be used as the parameter's type and a Patient
Interface in a separate file within the patient
folder to be used as the output:
// src/patient/patient.model.tsexport interface Patient { id: number; name: string;}
And the new PatientService
class:
import { Injectable } from '@nestjs/common';import { Patient } from './patient.model';export interface PatientInput { name: string;}@Injectable()export class PatientService { async register(patientInput: PatientInput): Promise<Patient> { return { id: 1, name: patientInput.name, }; }}
Excellent. We have more appropriate interfaces for our class, so let's commit these changes. Now that we have this method to register a patient (I know, I know, this implementation isn't complete, but we'll get there), we can turn our attention to the original requirement: "The patientId should be validated"
. This indicates that our service should have a method to verify a patient's existence.
Let's track this new requirement:
🔲 The patientId should be validated?🔲 We should be able to register a patient.🔲 We should be able to say when a patient exists.
Now, we can create the doesPatientExist(id: number)
method that should return true if a patient with the given id
exists in our system. Here is the test below:
import { Test, TestingModule } from '@nestjs/testing';import { PatientService } from './patient.service';describe('PatientService', () => { // .. setup code and `register` test above describe('doesPatientExist', () => { it('should return false when no patient was registered', async () => { const patientId = 1; const exists = await service.doesPatientExist(patientId); expect(exists).toBe(false); }); });});
We can then execute this test, which will fail because doesPatientExist
isn't defined. So, let's implement it now, and remember:
💡 We will use the simplest solution possible and return a static false.
// patient.service.tsimport { Injectable } from '@nestjs/common';import { Patient } from './patient.model';export interface PatientInput { name: string;}@Injectable()export class PatientService { private readonly patients: Patient[] = []; public async register(patientInput: PatientInput): Promise<Patient> { return { id: 1, name: patientInput.name, }; } public async doesPatientExist(patientId: number): Promise<boolean> { return false; }}
We execute the tests, and they pass again. Great! Let's commit once more, and move to the next test. We must ensure that this function returns true when a patient exists. But how do we make sure a patient exists beforehand? Simple enough, we use the PatientService
public API to do it using the register
method:
// patient.service.spec.ts// other tests aboveit('should return true when patient was registered', async () => { const { id: patientId } = await service.register({ name: 'John Doe' }); const exists = await service.doesPatientExist(patientId); expect(exists).toBe(true);});
This is a good example of how we should stick to behavioral tests instead of fiddling around with implementation details. A (bad) alternative here would be to expose the patients
array from the PatientService
and push a new patient with the given id
to it.
In any case, this test will fail because we had a fake implementation that returned a static false value. How do we make it pass? Well, we'll have to provide a more appropriate behavior for the register
method as well:
import { Injectable } from '@nestjs/common';import { Patient } from './patient.model';export interface PatientInput { name: string;}@Injectable()export class PatientService { private readonly patients: Patient[] = []; public async register(patientInput: PatientInput): Promise<Patient> { const newPatient = { id: 1, name: patientInput.name, }; this.patients.push(newPatient); return newPatient; } public async doesPatientExist(patientId: number): Promise<boolean> { return false; }}
Alright, even with this modification - our old tests continue to pass, so we should consider this a successful refactoring. Now, we'll likely notice that the implementation to make a new "passing" test for doesPatientExist
, is pretty straightforward:
public async doesPatientExist(patientId: number): Promise<boolean> { return this.patients.some((patient) => patient.id === patientId);}
🟢 The tests are all green once more, so we commit one more time, and let's cross out that requirement:
✅ We should be able to say when a patient exists.🔲 We should be able to register a patient.🔲 The patientId should be validated.
Furthermore, you might have noticed that our register method still uses a fake id
value (1).
Ideally we should fix this, but to do so, we must start the same way as always with TDD: creating a test that fails. In this case, we can make sure that when calling the register
method twice, it will produce different patients:
// patient.service.spec.ts// other tests aboveit('should return different ids when called twice with the same name', async () => { const firstPatient = await service.register({ name: 'John Doe' }); const secondPatient = await service.register({ name: 'John Doe' }); expect(firstPatient).not.toEqual(secondPatient);});
This will fail at first because the register
method returns the same id for every call. To fix that, we have yet another simple solution:
import { Injectable } from '@nestjs/common';import { Patient } from './patient.model';export interface PatientInput { name: string;}@Injectable()export class PatientService { private readonly patients: Patient[] = []; // added a private counter private nextId = 1; public async register(patientInput: PatientInput): Promise<Patient> { const newPatient = { // now we use this new counter and increase it in every call id: this.nextId++, name: patientInput.name, }; this.patients.push(newPatient); return newPatient; } public async doesPatientExist(patientId: number): Promise<boolean> { return this.patients.some((patient) => patient.id === patientId); }}
With this final addition, all our tests are passing again, and we are done with the PatientModule
implementations. The final step is to integrate this new service with the AppointmentService
, and use it to check whether a patient - exists - or not.
Integrating PatientModule with AppointmentModule
Let's recall how our successful test to schedule an appointment in the appointment.service
looked:
it('should schedule an unconfirmed appointment for a user on success', async () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-01T15:00:00Z'); const newAppointment = service.scheduleAppointment({ patientId: 1, startTime, endTime, }); expect(newAppointment).toEqual({ patientId: 1, startTime, endTime, confirmed: false, });});
The first obvious thing missing here for this test to make sense is creating a patient. We postponed our concerns about this before, but since we already took care of patient persistence, we can also add this logic here.
To do so, we need access to the PatientService
, which is part of the PatientModule
we've created before. So, let's import it in our setup code for this test file:
describe('AppointmentService', () => { let service: AppointmentService; let patientService: PatientService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [PatientModule], providers: [AppointmentService], }).compile(); service = module.get<AppointmentService>(AppointmentService); patientService = module.get(PatientService); }); // tests below...});
Now that we have a reference to the patientService
, we simply call its method to register a patient:
it('should schedule an unconfirmed appointment for a user on success', async () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-01T15:00:00Z'); // Using the `register` method to retrieve the new patient id const { id: patientId } = await patientService.register({ name: 'John Doe', }); const newAppointment = service.scheduleAppointment({ patientId, startTime, endTime, }); expect(newAppointment).toEqual({ patientId, startTime, endTime, confirmed: false, });});
With this addition, we have ensured our test code follows the test description.
Now, we can commit this change and finally tackle our last requirement from the list:
✅ We should be able to say when a patient exists.✅ We should be able to register a patient.🔲 The patientId should be validated.
Let's create a failing test for it:
it('should throw an error when the patient does not exist', async () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-01T15:00:00Z'); await expect( service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).rejects.toThrowError('Patient does not exist');});
In this case, since we didn't register the patient before, it should throw an error, but our test will fail when we first execute it because we haven't implemented this logic yet.
To add this behavior, we'll need the PatientService
again to check if a user exists, so let's add it to our AppointmentService
as a dependency and use the doesPatientExist
method:
public async scheduleAppointment( appointmentData: AppointmentInput, ): Promise<Appointment> { // .. start and end time validation // These lines below were added const patientExists = await this.patientService.doesPatientExist( appointmentData.patientId, ); if (!patientExists) { throw new Error('Patient does not exist'); } return { ...appointmentData, confirmed: false, };}
🟢 With this bit of addition, we executed the tests once more, and everything is green again.
Success! We can strike trough the last requirement:
✅ We should be able to say when a patient exists.✅ We should be able to register a patient.✅ The patientId should be validated.
Further fixes and improvements
There is plenty of information to absorb already, but I hope this article has helped you to have a better grasp on how TDD further evolves when developing an application and the need for integration tests arise. However, there are mainly 2 points of improvement/small fixes that I deem worth it mentioning here:
1. Fixing the AppointmentModule
We imported the PatientModule
in our test file for the AppointmentService
. However, if we were really going to use this application, it would be necessary to create an AppointmentModule
and import it as well:
import { Module } from '@nestjs/common';import { PatientModule } from '../patient/patient.module';import { AppointmentService } from './appointment.service';@Module({ imports: [PatientModule], providers: [AppointmentService],})export class AppointmentModule {}
And also, add it to the AppModule
so we can initialize it:
import { Module } from '@nestjs/common';import { AppController } from './app.controller';import { AppService } from './app.service';import { PatientModule } from './patient/patient.module';import { AppointmentModule } from './appointment/appointment.module';@Module({ imports: [PatientModule, AppointmentModule], controllers: [AppController], providers: [AppService],})export class AppModule {}
With this small addition, we can now initialize our application with nest start
and see it bootstrapping normally, with all dependencies loaded.
2. Fixing the AppointmentService error test cases
If you paid close attention to our steps from the last section, you may have noticed that we still have some test cases for errors that are still working (but they shouldn't). Let's take a look an example of one:
it('should throw an error when end time is before start time', async () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-01T13:00:00Z'); await expect( service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).rejects.toThrowError("appointment's endTime should be after startTime");});
This test isn't considering whether or not the patient exists. The description itself says nothing about the patient, but we could argue here that the patient should exist since the only data that is "wrong" is about the start and end times.
However, the test passes. Why is that?
Let's take a peek at the service's implementation again:
public async scheduleAppointment( appointmentData: AppointmentInput, ): Promise<Appointment> { if (appointmentData.endTime <= appointmentData.startTime) { throw new Error("appointment's endTime should be after startTime"); } if (this.endTimeIsInTheNextDay(appointmentData)) { throw new Error( "appointment's endTime should be in the same day as start time's", ); } // The patient is validated only HERE! const patientExists = await this.patientService.doesPatientExist( appointmentData.patientId, ); if (!patientExists) { throw new Error('Patient does not exist'); } return { ...appointmentData, confirmed: false, };}
As we can see here, the patientId
is only validated after the validation of start and end times. If we were to change this method and put the patient validation to occur before the time validation, all of our tests validating the time would fail. This is what we call "Temporal Dependency", because the tests only pass due to an implicit temporal dependency between the validation steps that states that the patientId
should be validated after.
Now, there's a reason for the above. Optimization-wise, it's way better to postpone potential access to a database (patientId validation) in favor of validating data that is already accessible in memory (start and end times). It's up to you to decide, however, if it's worth changing the tests so they aren't aware of this temporal dependency and create the patient each time. The benefit of doing so is that we hide an implementation detail, and the caveat being slower tests (once you integrate them with a database):
it('should throw an error when end time is before start time', async () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-01T13:00:00Z'); // We create the patient even though it isn't strictly necessary: const { id: patientId } = await patientService.register({ name: 'John Doe', }); await expect( service.scheduleAppointment({ patientId, startTime, endTime, }) ).rejects.toThrowError("appointment's endTime should be after startTime");});
Conclusion
If you have come this far, congratulations! 📯
This has been a larger-than-usual article, but at Trilon we think it's worth having pragmatic and practical experience when applying TDD. We've discussed what integration tests are, how to create them using TDD and how to handle all of this using NestJS best practices.
There is, however, one loose end that we should tackle in the next article in this series: Actual integration with databases
.
Although we have used a definition for integration tests that referenced the "existence" of a database, we only used in-memory storage here, this was done mainly for simplicity. Moreover, it's usually a good discipline to start your projects by taking care of the core business rules without worrying too much about which persistence infrastructure you'll use. The best decision may vary, and sticking to a decision early in the development phase can slow you down because you don't yet have enough domain context yet.
Lastly - if you want to understand (in more detail) what the final code used in this article looks like, check out the repository link, which was created using the same strategy, commit-by-commit adding a new test that passes.
Thanks again, and see you in the next one!
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!
- NestJS Advanced Concepts Course now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
- NestJS GraphQL Course (code-first & schema-first approaches) are now LIVE!
- NestJS Authentication / Authorization Course now LIVE!