Test Driven Development with NestJS - Integration Tests(Part 2)

Thiago Martins | Trilon Consulting
Thiago Martins
Part 2 in a series of Articles about Test Driven Development (TDD) in NestJS.

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:

Appointment model conceptual schema

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 patientfolder 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.tsand 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 the exports 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.ts
export 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.ts
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> {
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 above
it('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 above
it('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
#NodeJS
#Tests
#TDD

Share this Post!

📬 Trilon Newsletter

Stay up to date with all the latest Articles & News!

More from the Trilon Blog .

Jay McDoniel | Trilon Consulting
Jay McDoniel

NestJS Metadata Deep Dive

In this article we'll be doing a deep-dive and learning about how NestJS uses Metadata internally for everything from dependency injection, to decorators we use everyday!

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

NestJS v10 is now available

Today I am excited to announce the official release of Nest 10: A progressive Node.js framework for building efficient and enterprise-grade, server-side applications.

Read More
Manuel Carballido | Trilon Consulting
Manuel Carballido

Implementing data source agnostic services with NestJS

Learn how to implement data source logic in an agnostic way in yours NestJS applications.

Read More

What we do at Trilon .

At Trilon, our goal is to help elevate teams - giving them the push they need to truly succeed in today's ever-changing tech world.

Trilon - Consulting

Consulting .

Let us help take your Application to the next level - planning the next big steps, reviewing architecture, and brainstorming with the team to ensure you achieve your most ambitious goals!

Trilon - Development and Team Augmentation

Development .

Trilon can become part of your development process, making sure that you're building enterprise-grade, scalable applications with best-practices in mind, all while getting things done better and faster!

Trilon - Workshops on NestJS, Node, and other modern JavaScript topics

Workshops .

Have a Trilon team member come to YOU! Get your team up to speed with guided workshops on a huge variety of topics. Modern NodeJS (or NestJS) development, JavaScript frameworks, Reactive Programming, or anything in between! We've got you covered.

Trilon - Open-source contributors

Open-source .

We love open-source because we love giving back to the community! We help maintain & contribute to some of the largest open-source projects, and hope to always share our knowledge with the world!

Explore more

Write us a message .

Let's talk about how Trilon can help your next project get to the next level.

Rather send us an email? Write to:

hello@trilon.io
© 2019-2023 Trilon.