In the last post , we created a RESTful API application for simple CRUD functionalities. In this post, we will enrich it:
- Adding MongoDB support via Mongoose module
- Changing dummy data storage to use MongoDB server
- Cleaning up the testing codes
Let's go.
MongoDB is a leading document-based NoSQL database. Nestjs has official support for MongoDB via its Mongoosejs integration.
Firstly, install the following dependencies.
npm install --save @nestjs/mongoose mongoose
npm install --save-dev @types/mongoose
Declare a MongooseModule
in the top-level AppModule
to activate Mongoose support.
//... other imports
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
//...other modules imports
// add MongooseModule
MongooseModule.forRoot('mongodb://localhost/blog')
],
//... providers, controllers
})
export class AppModule {}
Mongoose requires a Schema definition to describe every document in MongoDB. Nestjs provides a simple to combine the schema definition and document POJO in the same form.
Rename our former post.interface.ts to post.model.ts, the .model suffix means it is a Mongoose managed Model
.
import { SchemaFactory, Schema, Prop } from '@nestjs/mongoose';
import { Document } from 'mongoose';
@Schema()
export class Post extends Document {
@Prop({ required: true })
title: string;
@Prop({ required: true })
content: string;
@Prop()
createdAt?: Date;
@Prop()
updatedAt?: Date;
}
export const PostSchema = SchemaFactory.createForClass(Post);
The annotations @Schema
, @Prop
defines the schema definitions on the document class instead of a mongoose Schema function.
Open PostModule
, declare a posts
collection for storing Post
document via importing a MongooseModule.forFeature
.
import { PostSchema } from './post.model';
//other imports
@Module({
imports: [MongooseModule.forFeature([{ name: 'posts', schema: PostSchema }])],
// providers, controllers
})
export class PostModule {}
When a Mongoose schema (eg. PostSchame
) is mapped to a document collection(eg. posts
), a Mongoose Model
is ready for the operations of this collections in MongoDB.
Open post.service.ts file.
Change the content to the following:
@Injectable()
export class PostService {
constructor(@InjectModel('posts') private postModel: Model<Post>) {}
findAll(keyword?: string, skip = 0, limit = 10): Observable<Post[]> {
if (keyword) {
return from(
this.postModel
.find({ title: { $regex: '.*' + keyword + '.*' } })
.skip(skip)
.limit(limit)
.exec(),
);
} else {
return from(
this.postModel
.find({})
.skip(skip)
.limit(limit)
.exec(),
);
}
}
findById(id: string): Observable<Post> {
return from(this.postModel.findOne({ _id: id }).exec());
}
save(data: CreatePostDto): Observable<Post> {
const createPost = this.postModel.create({ ...data });
return from(createPost);
}
update(id: string, data: UpdatePostDto): Observable<Post> {
return from(this.postModel.findOneAndUpdate({ _id: id }, data).exec());
}
deleteById(id: string): Observable<Post> {
return from(this.postModel.findOneAndDelete({ _id: id }).exec());
}
deleteAll(): Observable<any> {
return from(this.postModel.deleteMany({}).exec());
}
}
In the constructor of PostService
class, use a @InjectMock('posts')
to bind the posts
collection to a parameterized Model handler.
The usage of all mongoose related functions can be found in the official Mongoose docs.
As you see, we also add two classes CreatePostDto
and UpdatePostDto
instead of the original Post
for the case of creating and updating posts.
Following the principle separation of concerns, CreatePostDto
and UpdatePostDto
are only used for transform data from client, add readonly
modifier to keep the data immutable in the transforming progress.
// create-post.dto.ts
export class CreatePostDto {
readonly title: string;
readonly content: string;
}
// update-post.dto.ts
export class UpdatePostDto {
readonly title: string;
readonly content: string;
}
Clean the post.controller.ts
correspondingly.
@Controller('posts')
export class PostController {
constructor(private postService: PostService) {}
@Get('')
getAllPosts(
@Query('q') keyword?: string,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit?: number,
@Query('skip', new DefaultValuePipe(0), ParseIntPipe) skip?: number,
): Observable<BlogPost[]> {
return this.postService.findAll(keyword, skip, limit);
}
@Get(':id')
getPostById(@Param('id') id: string): Observable<BlogPost> {
return this.postService.findById(id);
}
@Post('')
createPost(@Body() post: CreatePostDto): Observable<BlogPost> {
return this.postService.save(post);
}
@Put(':id')
updatePost(
@Param('id') id: string,
@Body() post: UpdatePostDto,
): Observable<BlogPost> {
return this.postService.update(id, post);
}
@Delete(':id')
deletePostById(@Param('id') id: string): Observable<BlogPost> {
return this.postService.deleteById(id);
}
}
Unluckily, Mongoose APIs has no built-in Rxjs's
Observable
support, if you are stick on Rxjs, you have to usefrom
to wrap an existingPromise
to Rxjs'sObservable
. Check this topic on stackoverflow to know more details about the difference bwteen Promise and Observable.
To run the application, a running MongoDB server is required. You can download a copy from MongoDB, and follow the installation guide to install it into your system.
Simply, prepare a docker-compose.yaml to run the dependent servers in the development stage.
version: '3.8' # specify docker-compose version
# Define the services/containers to be run
services:
mongodb:
image: mongo
volumes:
- mongodata:/data/db
ports:
- "27017:27017"
networks:
- backend
volumes:
mongodata:
networks:
backend:
Run the following command to start up a mongo service in a Docker container.
docker-compose up
Execute the following command in the project root folder to start up the application.
npm run start
Now open your terminal and use curl
to test the endpoints, and make sure it works as expected.
>curl http://localhost:3000/posts/
[]
There is no sample data in the MongoDB. Utilizing with the lifecycle events, it is easy to add some sample data for the test purpose.
@Injectable()
export class DataInitializerService implements OnModuleInit, OnModuleDestroy {
private data: CreatePostDto[] = [
{
title: 'Generate a NestJS project',
content: 'content',
},
{
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
title: 'Connect to MongoDB',
content: 'content',
},
];
constructor(private postService: PostService) {}
onModuleInit(): void {
this.data.forEach(d => {
this.postService.save(d).subscribe(saved => console.log(saved));
});
}
onModuleDestroy(): void {
console.log('module is be destroying...');
this.postService
.deleteAll()
.subscribe(del => console.log(`deleted ${del} records.`));
}
}
Register it in PostModule
.
//other imports
import { DataInitializerService } from './data-initializer.service';
@Module({
//imports, controllers...
providers: [//other services...
DataInitializerService],
})
export class PostModule {}
Run the application again. Now you will see some data when hinting http://localhost:3000/posts/.
>curl http://localhost:3000/posts/
[{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb732f","title":"Create CRUD RESTful APIs","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb7330","title":"Connect to MongoDB","content":"content","__v":0}]
>curl http://localhost:3000/posts/5ee49c3115a4e75254bb732e
{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0}
>curl http://localhost:3000/posts/ -d "{\"title\":\"new post\",\"content\":\"content of my new post\"}" -H "Content-Type:application/json" -X POST
{"_id":"5ee49ca915a4e75254bb7331","title":"new post","content":"content of my new post","__v":0}
>curl http://localhost:3000/posts/5ee49ca915a4e75254bb7331 -d "{\"title\":\"my updated post\",\"content\":\"content of my new post\"}" -H "Content-Type:application/json" -X PUT
{"_id":"5ee49ca915a4e75254bb7331","title":"new post","content":"content of my new post","__v":0}
>curl http://localhost:3000/posts
[{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb732f","title":"Create CRUD RESTful APIs","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb7330","title":"Connect to MongoDB","content":"content","__v":0},{"_id":"5ee49ca915a4e75254bb7331","title":"my updated post","content":"content of my new post","__v":0}]
>curl http://localhost:3000/posts/5ee49ca915a4e75254bb7331 -X DELETE
{"_id":"5ee49ca915a4e75254bb7331","title":"my updated post","content":"content of my new post","__v":0}
>curl http://localhost:3000/posts
[{"_id":"5ee49c3115a4e75254bb732e","title":"Generate a NestJS project","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb732f","title":"Create CRUD RESTful APIs","content":"content","__v":0},{"_id":"5ee49c3115a4e75254bb7330","title":"Connect to MongoDB","content":"content","__v":0}]
When switching to real data storage instead of the dummy array, we face the first issue is how to treat with the Mongo database dependency in our post.service.spec.ts.
Jest provides comprehensive mocking features to isolate the dependencies in test cases. Let's have a look at the whole content of the refactored post.service.spec.ts file.
describe('PostService', () => {
let service: PostService;
let model: Model<Post>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PostService,
{
provide: getModelToken('posts'),
useValue: {
new: jest.fn(),
constructor: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
update: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
exec: jest.fn(),
findOneAndUpdate: jest.fn(),
findOneAndDelete: jest.fn(),
},
},
],
}).compile();
service = module.get<PostService>(PostService);
model = module.get<Model<Post>>(getModelToken('posts'));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('getAllPosts should return 3 posts', async () => {
const posts = [
{
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
},
];
jest.spyOn(model, 'find').mockReturnValue({
skip: jest.fn().mockReturnValue({
limit: jest.fn().mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(posts) as any,
}),
}),
} as any);
const data = await service.findAll().toPromise();
expect(data.length).toBe(3);
});
it('getPostById with existing id should return 1 post', done => {
const found = {
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
};
jest.spyOn(model, 'findOne').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(found) as any,
} as any);
service.findById('1').subscribe({
next: data => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(data.title).toEqual('Generate a NestJS project');
},
error: error => console.log(error),
complete: done(),
});
});
it('should save post', async () => {
const toCreated = {
title: 'test title',
content: 'test content',
};
const toReturned = {
_id: '5ee49c3115a4e75254bb732e',
...toCreated,
};
jest.spyOn(model, 'create').mockResolvedValue(toReturned as Post);
const data = await service.save(toCreated).toPromise();
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
expect(model.create).toBeCalledWith(toCreated);
expect(model.create).toBeCalledTimes(1);
});
it('should update post', done => {
const toUpdated = {
_id: '5ee49c3115a4e75254bb732e',
title: 'test title',
content: 'test content',
};
jest.spyOn(model, 'findOneAndUpdate').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce(toUpdated) as any,
} as any);
service.update('5ee49c3115a4e75254bb732e', toUpdated).subscribe({
next: data => {
expect(data._id).toBe('5ee49c3115a4e75254bb732e');
},
error: error => console.log(error),
complete: done(),
});
});
it('should delete post', done => {
jest.spyOn(model, 'findOneAndDelete').mockReturnValue({
exec: jest.fn().mockResolvedValueOnce({
deletedCount: 1,
}),
} as any);
service.deleteById('anystring').subscribe({
next: data => expect(data).toBeTruthy,
error: error => console.log(error),
complete: done(),
});
});
});
In the above codes,
- Use a custom Provider to provide a
PostModel
dependency forPostService
, the Model is provided inuseValue
which hosted a mocked object instance for PostModel at runtime. - In every test case, use
jest.spyOn
to assume some mocked behaviors of PostModel happened before the service is executed. - You can use the
toBeCalledWith
like assertions on mocked object or spied object.
For me, most of time working as a Java/Spring developers, constructing such a simple Jest based test is not easy, jmcdo29/testing-nestjs is very helpful for me to jump into jest testing work.
The jest mock is every different from Mockito in Java. Luckily there is a ts-mockito which port Mockito to the Typescript world, check this link for more details .
OK, let's move to post.controller.spec.ts.
Similarly, PostController
depends on PostService
. To test the functionalities of PostController
, we should mock it.
Like the method we used in post.service.spec.ts
, we can mock it in a Provider
.
describe('Post Controller(useValue jest mocking)', () => {
let controller: PostController;
let postService: PostService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: jest
.fn()
.mockImplementation(
(_keyword?: string, _skip?: number, _limit?: number) =>
of<any[]>([
{
_id: 'testid',
title: 'test title',
content: 'test content',
},
]),
),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
postService = module.get<PostService>(PostService);
});
it('should get all posts(useValue: jest mocking)', async () => {
const result = await controller.getAllPosts('test', 10, 0).toPromise();
expect(result[0]._id).toEqual('testid');
expect(postService.findAll).toBeCalled();
expect(postService.findAll).lastCalledWith('test', 0, 10);
});
});
Instead of the jest mocking, you can use a dummy implementation directly in the Provider
.
describe('Post Controller(useValue fake object)', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useValue: {
findAll: (_keyword?: string, _skip?: number, _limit?: number) =>
of<any[]>([
{ _id: 'testid', title: 'test title', content: 'test content' },
]),
},
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should get all posts(useValue: fake object)', async () => {
const result = await controller.getAllPosts().toPromise();
expect(result[0]._id).toEqual('testid');
});
});
Or use fake class to replace the real PostService
in the tests.
class PostServiceFake {
private posts = [
{
_id: '5ee49c3115a4e75254bb732e',
title: 'Generate a NestJS project',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb732f',
title: 'Create CRUD RESTful APIs',
content: 'content',
},
{
_id: '5ee49c3115a4e75254bb7330',
title: 'Connect to MongoDB',
content: 'content',
},
];
findAll() {
return of(this.posts);
}
findById(id: string) {
const { title, content } = this.posts[0];
return of({ _id: id, title, content });
}
save(data: CreatePostDto) {
return of({ _id: this.posts[0]._id, ...data });
}
update(id: string, data: UpdatePostDto) {
return of({ _id: id, ...data });
}
deleteById(id: string) {
return of({ ...this.posts[0], _id: id });
}
}
describe('Post Controller', () => {
let controller: PostController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: PostService,
useClass: PostServiceFake,
},
],
controllers: [PostController],
}).compile();
controller = module.get<PostController>(PostController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('GET on /posts should return all posts', async () => {
const posts = await controller.getAllPosts().toPromise();
expect(posts.length).toBe(3);
});
it('GET on /posts/1 should return one post ', done => {
controller.getPostById('1').subscribe(data => {
expect(data._id).toEqual('1');
done();
});
});
it('POST on /posts should return all posts', async () => {
const post: CreatePostDto = {
title: 'test title',
content: 'test content',
};
const saved = await controller.createPost(post).toPromise();
expect(saved.title).toEqual('test title');
});
it('PUT on /posts/1 should return all posts', done => {
const post: UpdatePostDto = {
title: 'test title',
content: 'test content',
};
controller.updatePost('1', post).subscribe(data => {
expect(data.title).toEqual('test title');
expect(data.content).toEqual('test content');
done();
});
});
it('DELETE on /posts/1 should return true', done => {
controller.deletePostById('1').subscribe(data => {
expect(data).toBeTruthy();
done();
});
});
});
The above codes are more close the ones in the first article, it is simple and easy to understand.
To ensure the fake PostService has the exact method signature of the real PostService, it is better to use an interface to define the methods if you prefer this apporach.
I have mentioned ts-mockito, for me it is easier to boost up a Mockito like test.
npm install --save-dev ts-mockito
A simple mockito based test looks like this.
// import facilites from ts-mockito
import { mock, verify, instance, anyString, anyNumber, when } from 'ts-mockito';
describe('Post Controller(using ts-mockito)', () => {
let controller: PostController;
const mockedPostService: PostService = mock(PostService);
beforeEach(async () => {
controller = new PostController(instance(mockedPostService));
});
it('should get all posts(ts-mockito)', async () => {
when(
mockedPostService.findAll(anyString(), anyNumber(), anyNumber()),
).thenReturn(
of([
{ _id: 'testid', title: 'test title', content: 'content' },
]) as Observable<Post[]>,
);
const result = await controller.getAllPosts('', 10, 0).toPromise();
expect(result.length).toEqual(1);
expect(result[0].title).toBe('test title');
verify(
mockedPostService.findAll(anyString(), anyNumber(), anyNumber()),
).once();
});
});
Now run the tests again. All tests should pass.
> npm run test
...
PASS src/app.controller.spec.ts
PASS src/post/post.service.spec.ts (10.307 s)
PASS src/post/post.controller.spec.ts (10.471 s)
Test Suites: 3 passed, 3 total
Tests: 17 passed, 17 total
Snapshots: 0 total
Time: 11.481 s, estimated 12 s
Ran all test suites.
In this post, we connected to the real MongoDB instead of the dummy data storage, correspond to the changes , we have refactored all tests, and discuss some approaches to isolate the dependencies in tests. But we have not test all functionalities in a real integrated environment, Nestjs provides e2e testing skeleton, we will discuss it in a future post.
Grab the source codes from my github, switch to branch feat/mongo.