Covering our project with tests can help us ensure that our application works as expected and is reliable. While unit tests play a significant role, they are not enough. Therefore, this article explains the significance of integration tests and implements them in a NestJS project that uses raw SQL queries.
The significance of integration tests
The role of a unit test is to cover an individual piece of code and verify if it works as expected. For example, a particular unit test suite can test a single function or a class.
When all our unit tests pass, it means that each part of our system works well on its own. However, that does not yet mean that all parts of the application interact with each other correctly. To verify the above, we need integration tests. Their job is to ensure that two or more pieces of our system integrate correctly.
Testing NestJS services
Writing integration tests does not mean we don’t mock any part of the application. For example, in our integration tests, we won’t be using an actual database.
Tests that verify the application from start to finish are referred to as end-to-end (E2E) tests. They should mimic a real system as close as possible.
1import PostDto from './post.dto';
2import { Test } from '@nestjs/testing';
3import DatabaseService from '../database/database.service';
4import { PostsService } from './posts.service';
5import PostWithCategoryIdsModel, {
6 PostWithCategoryIdsModelData,
7} from './postWithCategoryIds.model';
8import PostsRepository from './posts.repository';
9import PostsStatisticsRepository from './postsStatistics.repository';
10import PostsSearchRepository from './postsSearch.repository';
11
12describe('The PostsService', () => {
13 describe('when calling the create method with category ids', () => {
14 it('should return an instance of the PostWithCategoryIdsModel', async () => {
15 const postData: PostDto = {
16 title: 'Hello world!',
17 content: 'Lorem ipsum',
18 categoryIds: [1, 2, 3],
19 };
20
21 const sqlQueryResult: PostWithCategoryIdsModelData = {
22 id: 1,
23 author_id: 2,
24 title: postData.title,
25 post_content: postData.content,
26 category_ids: postData.categoryIds,
27 };
28
29 const runQueryMock = jest.fn();
30 runQueryMock.mockResolvedValue({
31 rows: [sqlQueryResult],
32 });
33
34 const module = await Test.createTestingModule({
35 providers: [
36 PostsService,
37 PostsRepository,
38 PostsStatisticsRepository,
39 PostsSearchRepository,
40 {
41 provide: DatabaseService,
42 useValue: {
43 runQuery: runQueryMock,
44 },
45 },
46 ],
47 }).compile();
48
49 const postsService = await module.get(PostsService);
50
51 const result = await postsService.createPost(postData, 1);
52
53 expect(result instanceof PostWithCategoryIdsModel).toBe(true);
54 });
55 });
56});The PostsService when calling the create method with category ids ✓ should return an instance of the PostWithCategoryIdsModel
Above, we test if the PostsService class works well with the PostsRepository.
1import { Injectable } from '@nestjs/common';
2import PostsRepository from './posts.repository';
3import PostDto from './post.dto';
4
5@Injectable()
6export class PostsService {
7 constructor(
8 private readonly postsRepository: PostsRepository,
9 ) {}
10
11 createPost(postData: PostDto, authorId: number) {
12 if (postData.categoryIds?.length) {
13 return this.postsRepository.createWithCategories(postData, authorId);
14 }
15 return this.postsRepository.create(postData, authorId);
16 }
17
18 // ...
19}In our test, we only mock the DatabaseService. Thanks to that, we test if the PostService integrates correctly with the PostsRepository.
1import {
2 BadRequestException,
3 Injectable,
4} from '@nestjs/common';
5import DatabaseService from '../database/database.service';
6import PostModel from './post.model';
7import PostDto from './post.dto';
8import PostWithCategoryIdsModel from './postWithCategoryIds.model';
9import PostgresErrorCode from '../database/postgresErrorCode.enum';
10import { isDatabaseError } from '../types/databaseError';
11
12@Injectable()
13class PostsRepository {
14 constructor(private readonly databaseService: DatabaseService) {}
15
16 async create(postData: PostDto, authorId: number) {
17 try {
18 const databaseResponse = await this.databaseService.runQuery(
19 `
20 // ...
21 `,
22 [postData.title, postData.content, authorId],
23 );
24 return new PostModel(databaseResponse.rows[0]);
25 } catch (error) {
26 if (
27 !isDatabaseError(error) ||
28 !['title', 'post_content'].includes(error.column)
29 ) {
30 throw error;
31 }
32 if (error.code === PostgresErrorCode.NotNullViolation) {
33 throw new BadRequestException(
34 `A null value can't be set for the ${error.column} column`,
35 );
36 }
37 throw error;
38 }
39 }
40
41 async createWithCategories(postData: PostDto, authorId: number) {
42 const databaseResponse = await this.databaseService.runQuery(
43 `
44 // ...
45 `,
46 [postData.title, postData.content, authorId, postData.categoryIds],
47 );
48 return new PostWithCategoryIdsModel(databaseResponse.rows[0]);
49 }
50
51 // ...
52}
53
54export default PostsRepository;Mocking differently in each test
So far, in our test, we put all of our logic in a single it function. An alternative for that is moving the setup code into the beforeEach functions.
1import PostDto from './post.dto';
2import { Test } from '@nestjs/testing';
3import DatabaseService from '../database/database.service';
4import { PostsService } from './posts.service';
5import PostWithCategoryIdsModel, { PostWithCategoryIdsModelData } from "./postWithCategoryIds.model";
6import PostsRepository from './posts.repository';
7import PostsStatisticsRepository from './postsStatistics.repository';
8import PostsSearchRepository from './postsSearch.repository';
9
10describe('The PostsService', () => {
11 let postData: PostDto;
12 let runQueryMock: jest.Mock;
13 let postsService: PostsService;
14 beforeEach(async () => {
15 runQueryMock = jest.fn();
16
17 const module = await Test.createTestingModule({
18 providers: [
19 PostsService,
20 PostsRepository,
21 PostsStatisticsRepository,
22 PostsSearchRepository,
23 {
24 provide: DatabaseService,
25 useValue: {
26 runQuery: runQueryMock,
27 },
28 },
29 ],
30 }).compile();
31
32 postsService = await module.get(PostsService);
33 });
34 describe('when calling the create method with category ids', () => {
35 let sqlQueryResult: PostWithCategoryIdsModelData;
36 beforeEach(() => {
37 postData = {
38 title: 'Hello world!',
39 content: 'Lorem ipsum',
40 categoryIds: [1, 2, 3],
41 };
42 sqlQueryResult = {
43 id: 1,
44 author_id: 2,
45 title: postData.title,
46 post_content: postData.content,
47 category_ids: postData.categoryIds
48 }
49 runQueryMock.mockResolvedValue({
50 rows: [sqlQueryResult],
51 });
52 });
53 it('should return an instance of the PostWithCategoryIdsModel', async () => {
54 const result = await postsService.createPost(postData, 1);
55
56 expect(result instanceof PostWithCategoryIdsModel).toBe(true);
57 });
58 it('should return an object with the correct properties', async () => {
59 const result = await postsService.createPost(postData, 1) as PostWithCategoryIdsModel;
60
61 expect(result.id).toBe(sqlQueryResult.id);
62 expect(result.authorId).toBe(sqlQueryResult.author_id);
63 expect(result.title).toBe(sqlQueryResult.title);
64 expect(result.content).toBe(sqlQueryResult.post_content);
65 expect(result.categoryIds).toBe(sqlQueryResult.category_ids);
66 })
67 });
68});The PostsService when calling the create method with category ids ✓ should return an instance of the PostWithCategoryIdsModel ✓ should return an object with the correct properties
Thanks to using beforEach, we can set up multiple tests using the same piece of code and avoid repeating it. We can take it further and change how we mock the DatabaseService at each test.
1import PostDto from './post.dto';
2import { Test } from '@nestjs/testing';
3import DatabaseService from '../database/database.service';
4import { PostsService } from './posts.service';
5import PostWithCategoryIdsModel, {
6 PostWithCategoryIdsModelData,
7} from './postWithCategoryIds.model';
8import PostsRepository from './posts.repository';
9import PostsStatisticsRepository from './postsStatistics.repository';
10import PostsSearchRepository from './postsSearch.repository';
11import PostModel, { PostModelData } from './post.model';
12
13describe('The PostsService', () => {
14 let postData: PostDto;
15 let runQueryMock: jest.Mock;
16 let postsService: PostsService;
17 beforeEach(async () => {
18 runQueryMock = jest.fn();
19
20 const module = await Test.createTestingModule({
21 providers: [
22 PostsService,
23 PostsRepository,
24 PostsStatisticsRepository,
25 PostsSearchRepository,
26 {
27 provide: DatabaseService,
28 useValue: {
29 runQuery: runQueryMock,
30 },
31 },
32 ],
33 }).compile();
34
35 postsService = await module.get(PostsService);
36 });
37 describe('when calling the create method with category ids', () => {
38 let sqlQueryResult: PostWithCategoryIdsModelData;
39 beforeEach(() => {
40 postData = {
41 title: 'Hello world!',
42 content: 'Lorem ipsum',
43 categoryIds: [1, 2, 3],
44 };
45 sqlQueryResult = {
46 id: 1,
47 author_id: 2,
48 title: postData.title,
49 post_content: postData.content,
50 category_ids: postData.categoryIds,
51 };
52 runQueryMock.mockResolvedValue({
53 rows: [sqlQueryResult],
54 });
55 });
56 it('should return an instance of the PostWithCategoryIdsModel', async () => {
57 const result = await postsService.createPost(postData, 1);
58
59 expect(result instanceof PostWithCategoryIdsModel).toBe(true);
60 });
61 it('should return an object with the correct properties', async () => {
62 const result = (await postsService.createPost(
63 postData,
64 1,
65 )) as PostWithCategoryIdsModel;
66
67 expect(result.id).toBe(sqlQueryResult.id);
68 expect(result.authorId).toBe(sqlQueryResult.author_id);
69 expect(result.title).toBe(sqlQueryResult.title);
70 expect(result.content).toBe(sqlQueryResult.post_content);
71 expect(result.categoryIds).toBe(sqlQueryResult.category_ids);
72 });
73 });
74 describe('when calling the create method without category ids', () => {
75 let sqlQueryResult: PostModelData;
76 beforeEach(() => {
77 postData = {
78 title: 'Hello world!',
79 content: 'Lorem ipsum',
80 };
81 sqlQueryResult = {
82 id: 1,
83 author_id: 2,
84 title: postData.title,
85 post_content: postData.content,
86 };
87 runQueryMock.mockResolvedValue({
88 rows: [sqlQueryResult],
89 });
90 });
91 it('should return an instance of the PostModel', async () => {
92 const result = await postsService.createPost(postData, 1);
93
94 expect(result instanceof PostModel).toBe(true);
95 });
96 it('should return an object with the correct properties', async () => {
97 const result = await postsService.createPost(postData, 1);
98
99 expect(result.id).toBe(sqlQueryResult.id);
100 expect(result.authorId).toBe(sqlQueryResult.author_id);
101 expect(result.title).toBe(sqlQueryResult.title);
102 expect(result.content).toBe(sqlQueryResult.post_content);
103 });
104 });
105});The PostsService when calling the create method with category ids ✓ should return an instance of the PostWithCategoryIdsModel ✓ should return an object with the correct properties when calling the create method without category ids ✓ should return an instance of the PostMode ✓ should return an object with the correct properties
Testing controllers
Another approach to integration tests is to include our controllers by performing HTTP requests. By doing that, we can more closely mimic how our application works in a real environment.
1npm install supertest @types/supertestSince our application uses the ValidationPipe, we need to add it explicitly.
1import { Test } from '@nestjs/testing';
2import { AuthenticationController } from './authentication.controller';
3import { AuthenticationService } from './authentication.service';
4import DatabaseService from '../database/database.service';
5import { INestApplication, ValidationPipe } from '@nestjs/common';
6import * as request from 'supertest';
7import UsersService from '../users/users.service';
8import { ConfigModule } from '@nestjs/config';
9import { JwtModule } from '@nestjs/jwt';
10import UsersRepository from '../users/users.repository';
11import RegisterDto from './dto/register.dto';
12import { UserModelData } from '../users/user.model';
13
14describe('The AuthenticationController', () => {
15 let runQueryMock: jest.Mock;
16 let app: INestApplication;
17 beforeEach(async () => {
18 runQueryMock = jest.fn();
19 const module = await Test.createTestingModule({
20 imports: [
21 ConfigModule.forRoot(),
22 JwtModule.register({
23 secretOrPrivateKey: 'Secret key',
24 }),
25 ],
26 controllers: [AuthenticationController],
27 providers: [
28 AuthenticationService,
29 UsersRepository,
30 UsersService,
31 {
32 provide: DatabaseService,
33 useValue: {
34 runQuery: runQueryMock,
35 },
36 },
37 ],
38 }).compile();
39
40 app = module.createNestApplication();
41 app.useGlobalPipes(new ValidationPipe());
42 await app.init();
43 });
44 describe('when making the /register POST request', () => {
45 describe('and using an incorrect email', () => {
46 it('should throw an error', () => {
47 return request(app.getHttpServer())
48 .post('/authentication/register')
49 .send({
50 email: 'not-an-email',
51 name: 'John',
52 password: 'strongPassword',
53 })
54 .expect(400);
55 });
56 });
57 describe('and using the correct data', () => {
58 let registrationData: RegisterDto;
59 let userModelData: UserModelData;
60 beforeEach(() => {
61 registrationData = {
62 email: 'john@smith.com',
63 name: 'John',
64 password: 'strongPassword',
65 };
66
67 userModelData = {
68 id: 1,
69 email: registrationData.email,
70 name: registrationData.name,
71 password: registrationData.password,
72 address_id: null,
73 address_country: null,
74 address_city: null,
75 address_street: null,
76 };
77
78 runQueryMock.mockResolvedValue({
79 rows: [userModelData],
80 });
81 });
82 it('should result with the 201 status', () => {
83 return request(app.getHttpServer())
84 .post('/authentication/register')
85 .send(registrationData)
86 .expect(201);
87 });
88 it('should respond with the data without the password', () => {
89 return request(app.getHttpServer())
90 .post('/authentication/register')
91 .send(registrationData)
92 .expect({
93 id: userModelData.id,
94 name: userModelData.name,
95 email: userModelData.email,
96 });
97 });
98 });
99 });
100});The AuthenticationController when making the /register POST request and using an incorrect email ✓ should throw an error and using the correct data ✓ should result with the 201 status ✓ should respond with the data without the password
The SuperTest library is quite powerful and allows us to verify the response differently. For example, we can ensure the response headers are correct. For a complete list of the features, check out the documentation.
Summary
In this article, we’ve gone through the idea of integration tests. We’ve described why they’re important and how they can benefit our application. We’ve also implemented integration tests with two different approaches. One of them required us to install the supertest library to simulate actual HTTP requests. All of the above allowed us to test our API more thoroughly and increase the confidence that we’re creating a reliable product.