TDD Typescript NestJS API Layers with Jest Part 3: Repository Unit Test
This is a 3 part series for unit testing the controller, service, and repository layers in a typical REST architecture. The final part shows testing the Repository 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
Following on from part 2 of testing the service, the minimum was done to move on to the repository. As before, you can following along.
Set the scene
Story: 'The information about the space ship needs to be saved.'
We now have the parsed and valid data to a SpaceShipEntity
. We want to save this to the DB.
Let's go!
Unit Testing the Repository
The repository is responsible for DB interactions in this case. As before we can think:
Given — a SpaceShipEntity.
When — the entity is saved.
Then — it should exist in the DB.
And — it should return the entity.
Testing the repository can be done many ways. We can mock out the calls and defer the actual testing for integration tests. I prefer to use an in-memory DB so we can check it saves as expected.
We have our basic repo, entity and test provided by NestJS from before:
x
@Injectable()
export class SpaceShipRepository {
save(spaceShipEntity: SpaceShipEntity): Promise<SpaceShipEntity> {
return Promise.resolve({} as SpaceShipEntity);
}
}
// test
describe('SpaceShipRepository', () => {
let provider: SpaceShipRepository;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SpaceShipRepository],
}).compile();
provider = module.get<SpaceShipRepository>(SpaceShipRepository);
});
it('should be defined', () => {
expect(provider).toBeDefined();
});
});
//entity
@Entity({ name: 'space_ship' })
export class SpaceShipEntity {
@PrimaryColumn({ name: 'id' })
spaceShipId: string;
@Column({ name: 'name' })
spaceShipName: string;
@Column({ name: 'space_ship_number' })
spaceShipNumber: number;
@Column({ name: 'is_faster_than_light' })
isFasterThanLight: boolean;
@Column({ name: 'date_created', type: 'timestamp' })
@CreateDateColumn()
public dateCreated?: Date;
}
A little boilerplate is required in order for the in-memory DB to work. First install the SQLite3:
npm install --save-dev sqlite3@5.0.0
An entity can be injected directly into a service without the need for a repository class. While this is convenient it is limiting to the default function that TypeOrm provide. A good pattern that allows for more flexibility in custom repositories. We'll implement one now and inject it into our SpaceShipRepository
:
xxxxxxxxxx
@Injectable()
export class SpaceShipRepository {
constructor(
@InjectRepository(SpaceShipEntity)
private readonly dbRepository: Repository<SpaceShipEntity>,
) {}
}
This allows us to keep the mock in the service and an easy way to test the repo. The boilerplate for the test now requires this config:
xxxxxxxxxx
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
dropSchema: true,
entities: [SpaceShipEntity],
synchronize: true,
autoLoadEntities: true,
keepConnectionAlive: false,
}),
TypeOrmModule.forFeature([SpaceShipEntity]),
],
providers: [SpaceShipRepository],
}).compile();
provider = module.get<SpaceShipRepository>(SpaceShipRepository);
entity = module.get(getRepositoryToken(SpaceShipEntity));
});
afterEach(async () => {
await entity.manager.connection.close();
});
So we setup the TypeOrmModule
setting the SQLite type and the DB as in memory. The other fields are configured to use the entities and setup the DB schema.
An important setting to note is the keepConnectionAlive
. This needs to be false in order for the DB to clear after each test — also linking in with the afterEach
function to ensure each test runs a fresh new DB.
This same config is required for each subsequent DB test where all that would change will be the entity list — it could be extracted out into a helper as your tests grow.
So Let's Create a Test!
x
it('should save an entity', async () => {
const spaceShipEntity: SpaceShipEntity = {
isFasterThanLight: false,
spaceShipName: 'some ship',
spaceShipNumber: 0,
spaceShipId: 'abc-000-ship',
};
const savedSpaceShip = await provider.save(spaceShipEntity);
expect(savedSpaceShip).toBe(spaceShipEntity);
expect(savedSpaceShip.dateCreated).toBeTruthy();
const count = await entity.query(
'select count(id) as rows from space_ship',
);
expect(count[0].rows).toBe(1);
});
Essentially we can use async and await to process the Promise. Save the entity and check the return value has the same fields and that the dateCreated has been auto-populated as expected when saving.
Custom queries can also be used to test correct logic in the DB.
The test now fails so let's update the repo:
xxxxxxxxxx
@Injectable()
export class SpaceShipRepository {
constructor(
@InjectRepository(SpaceShipEntity)
private readonly dbRepository: Repository<SpaceShipEntity>,
) {}
save(spaceShipEntity: SpaceShipEntity): Promise<SpaceShipEntity> {
return this.dbRepository.save(spaceShipEntity);
}
}
Now we have a passing test that fulfils the requirement of saving. We don't need to test any of the error conditions as this is the TypeOrm implementation and its will returns its own error. But it is possible if required just to check if it fails when it should or if there are any special conditions for your app:
it('should not save an entity and throw error if id missing', async () => {
const spaceShipEntity: SpaceShipEntity = {} as SpaceShipEntity;
const savedSpaceShip = () => provider.save(spaceShipEntity);
await expect(savedSpaceShip).rejects.toThrow(QueryFailedError);
});
And details can be seen such as
Received message: "SQLITE_CONSTRAINT: NOT NULL constraint failed: space_ship.id"
Many more tests to cover edge conditions can be carried out in the test.
Repository Test Summary
The approach here is a slightly extended unit test as we are using the in-memory DB but I think it's worth the extra effort. It provides even more confidence and security that what has been written does what it's supposed to.
In this post, we have injected a custom repository into the repository that gives more flexibility as not only can we access the built-in entity functions but we can extract out more complex queries as they develop. This is counter-intuitive at the moment as the requirement is not there — YAGNI but with this insight it makes the architecture altogether more cleaner.
This Is Not the End — Putting It All Together
So now we have completed all the tests and expect the system run smoothly. Doing TDD it's possible to spend days without running the server. A lot of developers will start off with the server running and build up the system from an API call and just follow the path until the 'full slice' of functionality has been completed. And of course no tests.
Let's see if we can run the project. Given the nature of the Nest module pattern it's fairly common to require imports into other modules at this stage. Let's see.
nest start
Yep as expected:
Nest can't resolve dependencies of the SpaceShipRepository (?). Please make sure that the argument SpaceShipEntityRepository at index [0] is available in the SpaceShipModule context.
Potential solutions: - If SpaceShipEntityRepository is a provider, is it part of the current SpaceShipModule? - If SpaceShipEntityRepository is exported from a separate @Module, is that module imported within SpaceShipModule? @Module({ imports: [ /* the Module containing SpaceShipEntityRepository */ ] })
First we need to update the SpaceShipModule to add the entity and providers:
xxxxxxxxxx
@Module({
imports: [TypeOrmModule.forFeature([SpaceShipEntity])],
controllers: [SpaceShipController],
providers: [SpaceShipService, SpaceShipRepository, SpaceShipConverter],
})
export class SpaceShipModule {}
And we also need to setup the DB for the project — given that we have done this already in the test, we can use that for the sake of the demo. Update the app module:
xxxxxxxxxx
@Module({
imports: [
SpaceShipModule,
TypeOrmModule.forRoot({
type: 'sqlite',
database: ':memory:',
dropSchema: true,
entities: [SpaceShipEntity],
synchronize: true,
autoLoadEntities: true,
keepConnectionAlive: false,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
Now we can start up the app and call the endpoint using Postman to call it:
xxxxxxxxxx
POST: http://localhost:3000/space-ship
BODY:
{
"spaceShipId": "abc-123-ship",
"spaceShipName": "Star Harvester",
"spaceShipNumber": 42,
"isFasterThanLight": true
}
And we get a response of SpaceShip
:
xxxxxxxxxx
{
"isFasterThanLight": true,
"spaceShipId": {
"id": "abc-123-ship"
},
"spaceShipName": "Star Harvester",
"spaceShipNumber": 42
}
We can also test the ID by sending an incorrect spaceShipId
and see the expected response:
xxxxxxxxxx
{
"statusCode": 400,
"message": "Validation failed",
"error": "Bad Request"
}
Conclusion
This concludes the 3 part series for unit testing the API layers. We have examples of how to test various parts of the system. There are many ways to also do the same, maybe after another couple of years, I may do it differently.
TDD is a difficult practise to follow and master. Trying to not run the server and just coding the solution straight off is a hard habit to break. But sometimes this also helps and anything that does help should be factored in. There is no reason not to do this, but try let the tests drive the development instead as well.
We saw that only towards the end did the response change which forced a refactor. The starting point of TDD could change, you could work from the repository backwards. You may already know that a promise would be returned. It depends for each feature and requirement as and when they change.
The road is not always clear but doing TDD enforces the code being written to be able to be changed and updated easily with the confidence that changing something won't break other parts of the system as tested code is already in place.
It's a great feeling when fixing a bug only to be able to write a failing test first, then easily change the code to make fix and pass the test.
I hope I was able to share some techniques in these areas and start you on the path to realising and practising the advantages of TDD. Comments, views and improvements always welcome.
Thanks for reading!
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.
Opinions expressed by DZone contributors are their own.
Comments