- Part 1: TDD in NestJS - Unit Tests (this article)
- Part 2: TDD in NestJS - Integration Tests
- ... more to come ...
Introduction
In a previous article, I presented a handy tool to deal with complex test dependencies: mocking and stubbing with the help of @golevelup/ts-jest. Although a valuable subject to discuss, it was narrowly scoped (with the good intention of making it easy to read). Nevertheless, there is another tool that I deem of higher priority to unlock the full potential of automated tests: Test-Driven Development (TDD).
TDD is not as difficult as it may sound if you have never tried it before. With practice, this methodology greatly improves your productivity, your confidence in your code, and your love for programming. There is an undeniable rush of dopamine when you see your red tests becoming green with every step, hence your algorithm emerging from that.
This will be the first article of a TDD series. Here, we aim to expose the fundamentals of TDD and how to apply it to the Nest ecosystem. In the upcoming posts, we'll dive deeper into integration test challenges and how a BDD mindset helps us with that.
As a final note, you can check this github repository containing all the code used within this article, commit by commit, to help you in case you miss the big picture.
What is TDD?
TDD is a discipline that helps you not only to create automated tests for your code but mainly to drive your implementation, guided by the tests' specifications. There are a few caveats about creating tests after the fact, and these are some of the problems that TDD tries to tackle:
- Your production code can be hard to test since it was not initially designed to be testable.
- You cannot prove your tests ensure a part of the system works as expected. To do that, you would have to change the current implementation to something else, see the test failing, and change it back to the original state.
- It can be boring to create tests after you've done all the mental work. You have already thought about the whole algorithm, which can feel like a duplication of work.
- You end up biased with the current implementation, affecting the quality of your tests.
According to TDD, before creating any production code, you should always start your code with a failing test. Then, you work your way to create that code that makes this failing test pass. This approach to development ensures that code is written to meet the test requirements. It also forces the developer to think about the requirements before writing their implementations, which are more likely to meet the user's needs. There are 3 laws that describe how TDD should be done in practical terms. Here's a snippet from Uncle Bob's Clean Coder book:
- You are not allowed to write any production code until you have first written a failing unit test.
- You are not allowed to write more of a unit test than is sufficient to fail—and not compiling is failing.
- You are not allowed to write more production code that is sufficient to pass the currently failing unit test
If you follow the rules above, you'll end up in short cycles of perhaps 30 seconds each. You first write a test describing the expected behavior, then write the class or function to make this single test pass, and then you can move on to the next test. Optionally, you can refactor your code to make it cleaner after making a test pass. If you follow these rules with perseverant discipline, your application will always be functional every cycle, with all tests passing in every commit. This is a powerful tool that even helps you to adopt Continuous Integration (CI).
The cycles described above are also commonly known as red-green-blue. The following diagram helps to understand (and memorize) the gist of TDD:
TDD in practice with NestJS
Before we dive into the code details, it is important to mention that TDD isn't a “silver bullet”: It has to be properly applied to be useful and efficient. I suggest you read this article to cement your understanding of testing best practices. Of most importance for this article is to bear in mind that we are better off with behavioral tests, that is, tests that describe the expected behavior of a code from the client's perspective. If our tests have to deal with implementation details that would otherwise be encapsulated, that's a red flag.
Now, to put into practice the statements above, let's start with some domain requirements that we'll develop here, step-by-step, using TDD. The goal is to build a simple service that helps us create medical appointments:
The simple pre-conceptual schema above illustrates which attributes and rules an Appointment should have. Let's write down a few requirements that can be extracted from the picture to help us track our progress:
[ ] 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[ ] The patientId should be validated?
Creating the project and starting with a test
To start our experiment here, you'll need the following:
- Node.js installed (preferably version 16 or greater)
- Nest CLI installed globally.
- A basic knowledge about NestJS and its testing tools.
With the above tools in hand, you can start a new Nest project with the following command:
nest new nest-tdd
When you execute that, you'll be prompted to choose a package manager of your liking. After selecting it, we can finally begin our TDD journey, and to do that, let's remind ourselves of the first rule of TDD:
You are not allowed to write any production code until you have first written a failing test
Alright, so we have to start with a failing test. The next question is: what should we test? Well, our main goal here is to accomplish the requirements to schedule an **Appointment,** so let's start with this: “An unconfirmed schedule should be created on success”. To do so, follow the steps:
- Create a new service and its test that should be responsible for scheduling appointments with the CLI command:
nest g service Appointment
- After executing the command above, the CLI will scaffold both an
appointments.service
and anappointments.service.spec.ts
file. - Change the test file to create our first failing test. It should look like the one below:
import { Test, TestingModule } from '@nestjs/testing';import { AppointmentService } from './appointment.service';describe('AppointmentService', () => { let service: AppointmentService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [AppointmentService], }).compile(); service = module.get<AppointmentService>(AppointmentService); }); it('should be defined', () => { expect(service).toBeDefined(); }); it('should schedule an unconfirmed appointment for a user on success', () => { 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, }); });});
Notice: We took a brief shortcut here to be pragmatic. When we executed
nest g service
it automatically created both the class and the initial test. Usually, you start with only a test, and then you create the class or function that should implement the requirement. However, since this was simultaneously generated for us, it's okay to move on.
As you can see above, the test describes how we would expect the appointment to be created with:
- The given
patientId
- Start and end times
- An attribute specifying whether it's confirmed or not.
Moreover, it is as simple as it could be, following the second rule of TDD:
“You are not allowed to write more of a unit test than is sufficient to fail”
When you start with that, you focus on understanding how the service should be consumed. This helps us create applications with simpler and more meaningful interfaces. Finally, we just have to execute the test above to complete the first rule of TDD:
npm run test
At that point, our test will obviously fail with a message like the one below:
● Test suite failed to run src/appointment/appointment.service.spec.ts:23:36 - error TS2339: Property 'scheduleAppointment' does not exist on type 'AppointmentService'.● AppointmentService › should schedule an unconfirmed appointment for a user on success expect(received).toEqual(expected) // deep equality Expected: {"confirmed": false, "endTime": 2022-01-01T15:00:00.000Z, "patientId": 1, "startTime": 2022-01-01T14:00:00.000Z} Received: undefined
We get an error here because our service doesn't even define such a method scheduleAppointment
. So, our next goal is to make this test pass, and to do it, we can implement the simplest possible code. Here's an example:
import { Injectable } from '@nestjs/common';@Injectable()export class AppointmentService { public scheduleAppointment(appointmentData: Record<string, any>) { return { ...appointmentData, confirmed: false, }; }}
Notice: We didn't specify
appointmentData
's type correctly because we want the shortest path to making the test pass. However, we'll refactor it in a bit to rectify that.
Now, we execute the test and bingo:
PASS src/appointment/appointment.service.spec.ts
This ensures we followed the final rule of TDD: “You are not allowed to write more production code that is sufficient to pass the currently failing test”. After this point, we've passed through the red and green steps of the TDD cycle, and the only additional work we could do here is to refactor the existing solution (blue). We'll take this opportunity to create clearer interfaces for our service's input and output:
- Create an interface within
src/appointment/appointment.service.ts
calledAppointmentInput
, and use it to define thescheduleAppointment
's argument. - Create a file named
appointment.model.ts
and export an interface:
export interface Appointment { patientId: number; startTime: Date; endTime: Date; confirmed: boolean;}
- Use both interfaces in the
appointment.service.ts
file, overriding what we used before:
import { Appointment } from './appointment.model';export interface AppointmentInput { patientId: number; startTime: Date; endTime: Date;}export class AppointmentService { public scheduleAppointment(appointmentData: AppointmentInput): Appointment { // ... implementation }}
After that, we should run the tests again to ensure our changes didn't break anything. As you'll notice, it works fine, and we can finally commit our changes.
Considerations about our first cycle
Before we go to the next steps, there are a few things I would like to highlight about TDD's benefits. Imagine that you are working on your day-to-day development chores as usual, but this time, using TDD. You pick an issue to work on, read the requirements and start by creating a failing test as described above. After you finish your first cycle (red-green-blue), you commit this change with a nice description as “feat: ensure an unconfirmed appointment is created on success”. At this point, you can already push your changes (either to master if you are developing with a trunk-based strategy or to your feature branch) and mark your changes as reviewable.
If anyone else from the team pulls the change and executes the tests, everything will work as expected. You can be certain that you are one step closer to finishing your task. If, for some reason, you had to stop your work today and get back the next morning, you can undisturbedly return to it, knowing everything still works, and you should focus on getting the next test case to pass.
Alternatively, you can review your work from yesterday, find a more elegant solution and implement it, relying on the existing test to ensure it works as intended. This is a powerful leverage that you can use to speed up a lot of your daily work (yes, creating good automated tests increases your speed). As Uncle Bob once said: “The only way to go fast is to go well.”
Moving forward to our next tests
Let's recap what the requirements were and what we've accomplished so far:
[x] 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[ ] The patientId should be validated?
We've guaranteed the happy path, the scenario where everything goes well. Now, we can move on to the next test. You could choose any of the remaining requirements above, but let's go with the second option.
it('should throw an error when end time is before start time', () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-01T13:00:00Z'); /** * We have to wrap our "scheduleAppointment" function in another arrow function * because we expect an error to be thrown. If we don't do that, * Jest won't be able to properly handle the error and it will accuse that the test failed. */ expect(() => service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).toThrowError("appointment's endTime should be after startTime");});
This time, we simply created a test with the endTime
being a date before the startTime
, and we expect it to throw us an error describing the issue. We execute the test, and it fails. Our goal now is to make it pass, and with the simplest solution possible. Luckily, javascript's Dates can be compared using arithmetic operators. Therefore we can achieve that with the following code:
@Injectable()export class AppointmentService { public scheduleAppointment(appointmentData: AppointmentInput): Appointment { if (appointmentData.endTime < appointmentData.startTime) { throw new Error("appointment's endTime should be after startTime"); } return { ...appointmentData, confirmed: false, }; }}
We execute the tests again, and it's passing now! At this point, you can review the code above and make any refactors you deem valuable, but here I'll move forward for simplicity's sake. Once again, we check our progress so far:
[x] An unconfirmed schedule should be created on success[x] The end time should not be before the start time[ ] The end time should be after the start time[ ] The patientId should be validated?
Two out of four requirements were already implemented! Now that you are getting the hang of it, you'll start iterating faster. Create the next failing test:
it('should throw an error when end time is equal to start time', () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = startTime; expect(() => service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).toThrowError("appointment's endTime should be after startTime");});
Again, we execute the test to ensure it's failing as expected. After that, the simplest solution to make it work is to add the equal “=” operator to our date's comparison:
@Injectable()export class AppointmentService { public scheduleAppointment(appointmentData: AppointmentInput): Appointment { if (appointmentData.endTime <= appointmentData.startTime) { throw new Error("appointment's endTime should be after startTime"); } return { ...appointmentData, confirmed: false, }; }}
Execute the tests again after the addition above, and it should pass now. Wonderful! We've done most of our requirements so far. However, as soon as you finish this test, you could ask yourself (or some business expert) if this is all. I could argue here that we're missing a requirement, for instance, that an appointment should happen within the same day. This means the endTime
cannot be on a different day than the one used in startTime
, which is a reasonable assumption, so let's add it to our list:
[x] An unconfirmed schedule should be created on success[x] The end time should not be before the start time[x] The end time should be after the start time[ ] An appointment start and end time should be within the same day (NEW)[ ] The patientId should be validated?
An important lesson here is that often times you won't be aware of all the requirements for whatever feature you are working on. Nonetheless, you can still successfully apply TDD, in fact, it will help you to implement additional requirements because:
- You can rely on the existing tests to guard you against regressions
- You can develop additional requirements in small steps, making it easy even for complex cases.
Implementing the additional requirement
In the last section, we concluded that we were missing another requirement: “An appointment start and end time should be within the same day”. Let's start by creating a failing test for that and see how the code unfolds. The first test for that can be a simple scenario when the endTime
is at 00:00h of the very next day:
it('should throw an error when end time is in the next day', () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-01-02T00:00:00Z'); expect(() => service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).toThrowError( "appointment's endTime should be in the same day as start time's" );});
Once more, we execute this test and see it fail. Since the endTime
here happens after the startTime
, our code still considers it as a valid date. Let's change this behavior now:
public scheduleAppointment( appointmentData: AppointmentInput, ): Appointment { if (appointmentData.endTime <= appointmentData.startTime) { throw new Error("appointment's endTime should be after startTime"); } // Added the verification below if ( appointmentData.endTime.getUTCDate() !== appointmentData.startTime.getUTCDate() ) { throw new Error( "appointment's endTime should be in the same day as start time's", ); } return { ...appointmentData, confirmed: false, };}
The code above relies on the Date's getUTCDate method. This will return the day of the month for given dates (ranging from 1 to 31). If the startTime
and endTime
have different days, they are certainly invalid from our requirements' perspective. Now, we execute the tests again, and it passes! We commit this change and are one step closer to the end.
You may have noticed this is a naive solution. We could have a scenario where the startTime
and endTime
have the same day of the month, but in different months, for instance, 2022-01-01 and 2022-02-01. Both would return 1 as a result for getUTCDate()
, making our code see it as valid dates. Let's create a test to prove this is not covered yet:
it('should throw an error when end time is in same day and hour of next month', () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2022-02-01T14:00:00Z'); expect(() => service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).toThrowError( "appointment's endTime should be in the same day as start time's" );});
When we execute this test, it fails as expected. Now, let's add the piece of code that fixes that:
public scheduleAppointment( appointmentData: AppointmentInput, ): Appointment { if (appointmentData.endTime <= appointmentData.startTime) { throw new Error("appointment's endTime should be after startTime"); } // Added the verification below if ( appointmentData.endTime.getUTCDate() !== appointmentData.startTime.getUTCDate() || // Now, we check for the months as well appointmentData.endTime.getUTCMonth() !== appointmentData.startTime.getUTCMonth() ) { throw new Error( "appointment's endTime should be in the same day as start time's", ); } return { ...appointmentData, confirmed: false, };}
With this addition, we execute the tests, and now they pass, albeit with less-than-ideal readability. Let's iterate the **refactor****** step of TDD to make this code a little bit better by extracting the if
logic into a private method:
public scheduleAppointment( appointmentData: AppointmentInput, ): Appointment { if (appointmentData.endTime <= appointmentData.startTime) { throw new Error("appointment's endTime should be after startTime"); } // Added the verification below if ( this.endTimeIsInTheNextDay(appointmentData) ) { throw new Error( "appointment's endTime should be in the same day as start time's", ); } return { ...appointmentData, confirmed: false, };}private endTimeIsInTheNextDay(appointmentData: AppointmentInput): boolean { const differentDays = appointmentData.endTime.getUTCDate() !== appointmentData.startTime.getUTCDate(); const differentMonths = appointmentData.endTime.getUTCMonth() !== appointmentData.startTime.getUTCMonth(); return differentDays || differentMonths;}
We execute the tests again to guarantee that everything still works. Now, we have a way cleaner public method with the low-level details in the private method. But we are not finished yet. If we analyze this code with the same strategy we used before, you'll notice we still have a final edge case: when both startTime
and endTime
are on the same day of the same month but in different years. Something like:
startTime: 2022-01-01
endTime: 2023-01-01
Since our code doesn't check for the date's year, it will wrongly assume the dates above are okay. Let's create a test to confirm that:
it('should throw an error when end time is in same day, hour and month of the next year', () => { const startTime = new Date('2022-01-01T14:00:00Z'); const endTime = new Date('2023-01-01T14:00:00Z'); expect(() => service.scheduleAppointment({ patientId: 1, startTime, endTime, }) ).toThrowError( "appointment's endTime should be in the same day as start time's" );});
We execute npm run test
, and it shows us a failed test. Right, our assumption was correct. Let's write the final implementation to make it work. We just have to change the private method's code:
private endTimeIsInTheNextDay(appointmentData: AppointmentInput): boolean { const differentDays = appointmentData.endTime.getUTCDate() !== appointmentData.startTime.getUTCDate(); const differentMonths = appointmentData.endTime.getUTCMonth() !== appointmentData.startTime.getUTCMonth(); // Now we also check for years const differentYears = appointmentData.endTime.getUTCFullYear() !== appointmentData.startTime.getUTCFullYear(); return differentDays || differentMonths || differentYears;}
And with this simple addition, all of our tests are passing now. Great! we can check this requirement from our list:
[x] An unconfirmed schedule should be created on success[x] The end time should not be before the start time[x] The end time should be after the start time[x] An appointment start and end time should be within the same day (NEW)[ ] The patientId should be validated?
The only missing requirement here is to validate a patient's id. However, this would be an integration test that requires a different test strategy than what we've been doing in this post, so we'll discuss that in a future article. There is plenty of information to absorb, and I don't want to overwhelm you with a single read.
Conclusion and further reading
We've seen in practice how to gradually implement the business requirements, starting with simple tests, creating the code that made them pass, and refactoring where it seemed necessary. Although the scenario here was oversimplified, the intention was to focus on the TDD's steps. As you can see, when we adhere to it, Test Driven Development helps us worry about a single problem per iteration. Moreover, you don't have to do an upfront design because the code emerges from the test requirements.
In the next articles of this series, we'll explore the most challenging (and possibly most interesting) aspects of TDD when working with integration tests. In those cases, we have to work with test doubles to control the state and interaction of collaborator classes, often working with interfaces to accomplish that.
I hope this article has helped you to understand the basic concepts of TDD and why it is a good discipline to try out. Of course, as I mentioned before, there have been many discussions around TDD and some caveats if not done properly. Finally, this series of articles from Martin Fowler is a good starting point for understanding such nuances. Nonetheless, regardless of what strategy you choose, remember that automated testing is always a good practice.
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!