TDD Typescript NestJS API Layers with Jest Part 1: Controller Unit Test
This is a 3 part series for unit testing the controller, service, and repository layers in a typical REST architecture. This part will test the Controller Unit.
Join the DZone community and get the full member experience.
Join For FreeContext
This is a 3 part series for unit testing the controller, service, and repository layers in a typical REST architecture. There are many approaches to doing this, I'm sharing a pattern that I find very flexible and hopefully, you can pick up some tips and tricks along the way.
Part 1 - Unit Testing the Controller | Git branch can be found here.
Part 2 - Unit Testing the service | Git branch can be found here.
Part 3 - Unit Testing the repository | Git branch can be found here.
Intro
A common pattern amongst projects contain:
- Controllers that are the entry point for API calls.
- Services to handle the business logic.
- Repositories to handle the DB calls.
This architecture is very common and bodes well for separation of concerns, dependency injection, and in turn, easy testing.
This post will test the controller. It shows a step-by-step process so it's possible to follow along if you want to learn hence the length of the article.
Set the scene
Story: 'The information about the space ship needs to be saved.'
- A new API is required.
- Another team is working on sending the data to this API.
Data contract: spaceShipId
is a primary key in this case and it conforms strictly to the format below:
{ "spaceShipId": "abc-123-ship", "spaceShipName": "Star Harvester", "spaceShipNumber": 42, "isFasterThanLight": true }
Let's go!
Unit Testing the Controller
NestJS will be used as it is a nice toolkit that saves a developer lots of time and is very feature-rich, let's start. Run these 3 commands to create and start the project
npm i -g @nestjs/cli nest new tdd-nestjs-api-jest
npm install
This will create a starter project that conveniently has something similar to what we want, run the test:
npm test
The console should show the output of the test - so we know it's running
Let's create a new module and call it space-ship and then create a controller also with the same name:
nest g module space-ship nest g controller space-ship
This creates a module and adds it the app module references. Also creates the controller, conveniently with a test and its boilerplate and adds it to the space-ship module. We can also run the tests again as before and should see it now has 2 tests that both pass.
So Where do we Start TDD?
This is usually the hardest part when starting a new task. It's possible to work from the repo and back to the controller or vice-versa. On this occasion, start from the controller.
Create a failing test for the controller:
Given — space ship data.
When — then the controller is called with the data.
Then — the space ship service should be called with the data.
This can be converted into code:
xxxxxxxxxx
//class
@Controller('space-ship')
export class SpaceShipController {}
//test
it('should call the service', () => {
const spaceShip = {};
controller.save(spaceShip);
expect(spaceShipService.save).toHaveBeenCalled();
});
A failing test has now been achieved but this is more than failing it doesn't have:
- the function in the controller.
- the services do not exist yet.
Sometimes you may want to create the skeleton first and then start wiring the tests but then you have lots of code that potentially could be forgotten about or never tested. Doing it this way allows you to create as you go. Also your IDE may offer assistance in creating the missing functions, creating new classes and offering hints.
Let's fix the first problem and create the save
function in the controller and then create the service with a save function as well and reference that in the test.
nest g service space-ship
Now we have fixed the errors and wired up the logic:
x
@Controller('space-ship')
export class SpaceShipController {
public save(spaceShip: any) { }
}
@Injectable()
export class SpaceShipService {
save(save: any) {
throw new Error("Method not implemented.");
}
}
//test
describe('SpaceShipController', () => {
let controller: SpaceShipController;
let service: SpaceShipService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SpaceShipController],
providers: [SpaceShipService]
}).compile();
controller = module.get<SpaceShipController>(SpaceShipController);
service = module.get<SpaceShipService>(SpaceShipService);
});
it('should call the service', () => {
const spaceShip = {};
controller.save(spaceShip);
expect(service.save).toHaveBeenCalled();
});
});
Now run the test and we have a failing test. The --watch
field can be added to automatically run the tests on every change, which is very handy:
npx jest --watchAll
And of course the test fails because we have not called the service in the controller.
Wire up the controller — inject the service and call save
:
xxxxxxxxxx
@Controller('space-ship')
export class SpaceShipController {
constructor(private service: SpaceShipService) {
}
@Post()
public save(spaceShip: any) {
this.service.save(spaceShip);
}
}
So that's it, it 'should' work but the test still fails. This is because the service needs to be mocked - the actual implementation of the service is being called here, not what is required for unit testing. Simply add this line to the import section of the controller test:
jest.mock('./space-ship.service');
Now we have those wonderful green lights we always like to see:
The test now gives a little more confidence that the controller will call the service. At this point the bare minimum of unit testing has been achieved. However, what it is calling with has been neglected ie. the data being passed in.
Ensuring the Data Is Valid
As the data is known, create the interface and data model. Some good advice, seen time and time again — 'Ensure your data is valid first'. This is key. If you know the data is correct from the start then there is less chance of incurring errors down the line. Also makes the app more secure as will be demonstrated further in the post.
We know what data to expect so let's create an interface for this request in the controller and update the test with data.
xxxxxxxxxx
export interface SaveSpaceShipRequest {
'spaceShipId': string;
'spaceShipName': string;
'spaceShipNumber': number;
'isFasterThanLight': boolean
}
@Controller('space-ship')
export class SpaceShipController {
constructor(private service: SpaceShipService) {
}
@Post()
public save(saveSpaceShipRequest: SaveSpaceShipRequest) {
this.service.save(saveSpaceShipRequest);
}
}
// test
it('should call the service', () => {
const spaceShip: SaveSpaceShipRequest = {
'spaceShipId': 'abc-123-ship',
'spaceShipName': 'Star Harvester',
'spaceShipNumber': 42,
'isFasterThanLight': true,
};
controller.save(spaceShip);
expect(service.save).toHaveBeenCalled();
});
We know what data to expect but by no means does it mean it's valid.
It's good practise to extract IDs out into their own class if they will be used elsewhere in the code such as DB identifiers, passed into functions etc. This provides many benefits such as
- valid if exists,
- adds context and meaning,
- not referred to as an id or string,
- not easily mixed up when passing multiple IDs around.
To validate the data it's tempting to stick a validation function in and assume that its fine, while this works it's limited to this section of code. What if a null pointer occurs in the downstream code? Will a null check deep in the code be required? Yes. And then that is the start of code smells and problems. Let's fix this
Tiny Types, Micro Types, Domain Primitives to Name a Few
These names pretty much describe the same thing. They represent the smallest and meaningful entity in the domain. Let's apply this to the space ship id. The ID should always follow the same pattern, ie., xxx-000-xxx anything else should be invalid.
This can be converted into a SpaceShipId
class that can be created from a string. Notice the private constructor; allowing it only to be created from the from
method.
xxxxxxxxxx
export class SpaceShipId {
public static from(id: string): SpaceShipId {
return new SpaceShipId(id);
}
private static validate(id: string) {
const isValid = true;
if (!isValid) {
throw new Error('Invalid Space ship ID');
}
}
private readonly id: string;
private constructor(code: string) {
SpaceShipId.validate(code);
this.id = code;
}
public value(): string {
return this.id;
}
}
This only allows the return of a valid ID or throws an error.
We can also write some test to check that the input is valid:
x
describe('SpaceShipId', () => {
it('should throw an error', () =>{
const id = 'should-fail';
const spaceShipId = () => SpaceShipId.from(id);
expect(spaceShipId).toThrow(Error);
});
it('should return back a valid id', () =>{
const id = 'abc-0000-xyx';
expect(SpaceShipId.from(id)).toBeTruthy();
});
});
The first test fails as expected as it allows an invalid id. We can update the validation function to add all the validation require eg. regex, smart checks plus many others. For simplicity we will just check that the length is correct but you can really 'go to town' on these check to ensure it's valid and what is expected.
xxxxxxxxxx
private static validate(id: string) {
const isValid = id
&& id.length == 12;
// regex, other checks etc
if (!isValid) {
throw new Error('Invalid Space ship ID');
}
}
Now we can get a guaranteed valid (as valid as your checks are) spaceShipId
that is not only valid as soon as it comes into the system but anytime, anywhere it is used in the code. It will provide a level of assurance that it is correct.
xxxxxxxxxx
const spaceShipId = SpaceShipId.from(saveSpaceShipRequest.spaceShipId);
So the spaceship request now has a valid ID. What about the rest of the data. Of course this should be validated in the front end before it hits the API but to make make our API more secure, it should also have its own validation to prevent potential hacks. We have started to extract the body out and make it more robust:
xxxxxxxxxx
@Post()
public save(saveSpaceShipRequest: SaveSpaceShipRequest) {
const spaceShipId = SpaceShipId.from(saveSpaceShipRequest.spaceShipId);
this.service.save(saveSpaceShipRequest);
}
Before going further, it's now apparent that the save method is starting to violate the single responsibility principle — it's also trying to validate the request. Let's refactor this and extract it out.
Instead of passing the service handler the raw JSON object, create a new class to hold the transformed validated data a DTO if you like. Update the service to accept this object, the next step then processes this object to pass into the service...
Pipes, Validation, and Transformation
NestJS provides the concept of Pipes that allow validation and transformation of object data. In most cases data will need to be transformed in some way or other. There are many techniques to do this including class-validator and class-transformer but I feel implementing the PipeTransform
offers most flexibility and benefit, but requires more code.
nest g pipe spaceShipSaveRequestToSpaceShip --flat
This will create a pipe and a test.
x
//
@Injectable()
export class SpaceShipSaveRequestToSpaceShip implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
return value;
}
}
Joi will be used to validate the incoming request, install it;
npm install joi
npm install --save-dev @types/joi
Let's add tests for the pipe — it should eventually take in a save request, validate it and transform the data to a new SpaceShip object. The first test should fail by throwing an error if the body is empty:
x
export class SpaceShip {
spaceShipId: SpaceShipId;
spaceShipName: string;
spaceShipNumber: number;
isFasterThanLight: boolean
}
// validator and transformer
describe('SpaceShipRequestValidatorPipe', () => {
let transformer;
beforeEach(()=>{
transformer = new SpaceShipSaveRequestToSpaceShip();
})
it('should be defined', () => {
expect(transformer).toBeDefined();
});
it('should throw error if no body', () => {
const response = () => transformer.transform({}, {});
expect(response).toThrow(BadRequestException)
});
});
We can use Joi to validate the object and throw an error if it isn't valid. Also, we can update the class with the types — the request in and the new SpaceShip class out:
xxxxxxxxxx
@Injectable()
export class SpaceShipSaveRequestToSpaceShip implements PipeTransform<SaveSpaceShipRequest, SpaceShip> {
transform(value: SaveSpaceShipRequest, metadata: ArgumentMetadata): SpaceShip {
const schema = Joi.object({
spaceShipId: Joi.string().min(12).max(12).required(),
spaceShipName: Joi.string().max(20).required(),
spaceShipNumber: Joi.number().required(),
isFasterThanLight: Joi.boolean().required(),
});
const {error} = schema.validate(value);
if(error){
throw new BadRequestException('Validation failed');
}
const spaceShip: SpaceShip = {
spaceShipId: SpaceShipId.from(value.spaceShipId),
spaceShipName: value.spaceShipName,
spaceShipNumber: value.spaceShipNumber,
isFasterThanLight: value.isFasterThanLight,
};
return spaceShip;
}
}
It's worth noting as this stage Joi would not run in Jest because of the tsconfig.js had the missing config
"esModuleInterop": trueThis had to be added in order to import CommonJS modules in compliance with ES6 - more info. But add this and it'll work.
Next add another test which tests the conversion is working as expected and a SpaceShip is returned from the incoming request.
xxxxxxxxxx
it('should convert to valid Space Ship', () => {
const spaceShipRequest: SaveSpaceShipRequest = {
'spaceShipId': 'abc-123-ship',
'spaceShipName': 'Star Harvester',
'spaceShipNumber': 42,
'isFasterThanLight': true,
};
const spaceShip: SpaceShip = {
'spaceShipId': SpaceShipId.from(spaceShipRequest.spaceShipId),
'spaceShipName': 'Star Harvester',
'spaceShipNumber': 42,
'isFasterThanLight': true,
};
// @ts-ignore
const parsedSpaceShip = transformer.transform(spaceShipRequest, {});
expect(parsedSpaceShip).toEqual(spaceShip);
});
Many more tests cases and to cover edge conditions can be added at this point to ensure the conversion is done correctly. And best of all it's totally separated from the controller and and service.
Now we can be confident that the request coming in is secure to process. Now, let's update the controller with the transformation and update the service to now take a SpaceShip
object.
The controller is not much neater — with validation taken out of the process. The validation is now done by injecting the SpaceShipSaveRequestToSpaceShip
into the @Body
which will pipe the value back as a SpaceShip
object:
x
@Post()
public save(@Body(new SpaceShipSaveRequestToSpaceShip()) spaceShip: SpaceShip) {
this.service.save(spaceShip);
}
The test will now need to change to accept the SpaceShip
object:
x
it('should call the service', () => {
const spaceShip: SpaceShip = {
'spaceShipId': SpaceShipId.from('abc-123-ship'),
'spaceShipName': 'Star Harvester',
'spaceShipNumber': 42,
'isFasterThanLight': true,
};
controller.save(spaceShip);
expect(service.save).toHaveBeenCalledWith(spaceShip);
});
Also, update the service to take the secure and validated SpaceShip
object:
xxxxxxxxxx
@Injectable()
export class SpaceShipService {
save(save: SpaceShip) {
throw new Error("Method not implemented.");
}
}
Controller Test Summary
This almost seems like an eternity to reach here, lot's of areas have been covered — project creation, controllers and service creation, validation and transformation and all for what?
It would be really easy to just pass the raw JSON object straight into the service and neglect the validity and security of the data.
We we have managed to create a robust API endpoint that is going to be pretty solid and hold up against bad data. It's with this discipline, a robust and clean architecture is starting to emerge.
Part 1 - Unit Testing the Controller | Git branch can be found here.
Next -Part 2 - Unit Testing the service | Git branch can be found here.
Part 3 - Unit Testing the repository | Git branch can be found here.
Opinions expressed by DZone contributors are their own.
Comments