- 1. TypeScript Express tutorial #1. Middleware, routing, and controllers
- 2. TypeScript Express tutorial #2. MongoDB, models and environment variables
- 3. TypeScript Express tutorial #3. Error handling and validating incoming data
- 4. TypeScript Express tutorial #4. Registering users and authenticating with JWT
- 5. TypeScript Express tutorial #5. MongoDB relationships between documents
- 6. TypeScript Express tutorial #6. Basic data processing with MongoDB aggregation
- 7. TypeScript Express tutorial #7. Relational databases with Postgres and TypeORM
- 8. TypeScript Express tutorial #8. Types of relationships with Postgres and TypeORM
- 9. TypeScript Express tutorial #9. The basics of migrations using TypeORM and Postgres
- 10. TypeScript Express tutorial #10. Testing Express applications
- 11. TypeScript Express tutorial #11. Node.js Two-Factor Authentication
- 12. TypeScript Express tutorial #12. Creating a CI/CD pipeline with Travis and Heroku
- 13. TypeScript Express tutorial #13. Using Mongoose virtuals to populate documents
- 14. TypeScript Express tutorial #14. Code optimization with Mongoose Lean Queries
- 15. TypeScript Express tutorial #15. Using PUT vs PATCH in MongoDB with Mongoose
An essential part of developing a fully functional software is testing. In this article, we focus on making our application more testable and implementing unit and integration tests using Jest and a library called SuperTest. This part of the TypeScript Express testing tutorial needs some basic understanding of tests and the knowledge of tools like Jest and I encourage you to check out the first part of the JavaScript testing tutorial.
As always, to code for this tutorial is in the express-typescript repository. Since this tutorial uses Postgres, the code for it is in the postgres branch.
Testing Express with unit tests
At the beginning of the first part of the JavaScript testing tutorial, we mention unit testing. In our current application architecture, it might be challenging to separate distinct units to tests, and because of that, the first concept to get to know is a service. It can implement complex logic so that the route handlers can use them instead of doing the heavy lifting. An additional advantage to them, so significant in this article, is that we can call them anytime, anywhere, and they can be used not only when a request is made, but we can also easily use them from within our Express tests.
Let’s expand on an example of registering users that we implemented before:
src/authentication/tests/authentication.controller.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
import * as express from 'express'; import Controller from '../interfaces/controller.interface'; import validationMiddleware from '../middleware/validation.middleware'; import CreateUserDto from '../user/user.dto'; import AuthenticationService from './authentication.service'; class AuthenticationController implements Controller { public path = '/auth'; public router = express.Router(); private authenticationService = new AuthenticationService(); constructor() { this.initializeRoutes(); } private initializeRoutes() { this.router.post(`${this.path}/register`, validationMiddleware(CreateUserDto), this.registration); } private registration = async (request: express.Request, response: express.Response, next: express.NextFunction) => { const userData: CreateUserDto = request.body; try { const { cookie, user, } = await this.authenticationService.register(userData); response.setHeader('Set-Cookie', [cookie]); response.send(user); } catch (error) { next(error); } } } export default AuthenticationController; |
Thanks to moving the logic to a service, the registration method of our Authentication controller is a lot shorter than before. Now, the service does most of the work:
src/authentication/authentication.service.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
import * as bcrypt from 'bcrypt'; import * as jwt from 'jsonwebtoken'; import { getRepository } from 'typeorm'; import UserWithThatEmailAlreadyExistsException from '../exceptions/UserWithThatEmailAlreadyExistsException'; import DataStoredInToken from '../interfaces/dataStoredInToken'; import TokenData from '../interfaces/tokenData.interface'; import CreateUserDto from '../user/user.dto'; import User from '../user/user.entity'; class AuthenticationService { private userRepository = getRepository(User); public async register(userData: CreateUserDto) { if ( await this.userRepository.findOne({ email: userData.email }) ) { throw new UserWithThatEmailAlreadyExistsException(userData.email); } const hashedPassword = await bcrypt.hash(userData.password, 10); const user = this.userRepository.create({ ...userData, password: hashedPassword, }); await this.userRepository.save(user); user.password = undefined; const tokenData = this.createToken(user); const cookie = this.createCookie(tokenData); return { cookie, user, }; } public createCookie(tokenData: TokenData) { return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn}`; } public createToken(user: User): TokenData { const expiresIn = 60 * 60; // an hour const secret = process.env.JWT_SECRET; const dataStoredInToken: DataStoredInToken = { id: user.id, }; return { expiresIn, token: jwt.sign(dataStoredInToken, secret, { expiresIn }), }; } } export default AuthenticationService; |
Since we’ve got our logic moved outside of the controller, we can start writing unit tests. The first thing to do is to install ts-jest that lets us use Jest to test projects with TypeScript:
1 |
npm install -D jest ts-jest @types/jest |
1 2 3 4 5 6 |
"scripts": { "dev": "ts-node ./src/server.ts", "lint": "tslint -p tsconfig.json -c tslint.json", "typeorm:cli": "ts-node ./node_modules/typeorm/cli -f ./src/ormconfig.ts", "test": "jest" } |
The only thing that is left to do is to create a Jest configuration file. The ts-jest library can do it for us:
1 |
npx ts-jest config:init |
Once we got that down, we can begin testing Express! Let’s start with something simple:
src/authentication/tests/authentication.service.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import TokenData from '../interfaces/tokenData.interface'; import AuthenticationService from './authentication.service'; describe('The AuthenticationService', () => { const authenticationService = new AuthenticationService(); describe('when creating a cookie', () => { const tokenData: TokenData = { token: '', expiresIn: 1, }; it('should return a string', () => { expect(typeof authenticationService.createCookie(tokenData)) .toEqual('string'); }) }); }) |
You can execute it with npm run test
ConnectionNotFoundError: Connection “default” was not found.
And we have an error! The reason of it is the fact that in the service we access a TypeORM repository, but we don’t establish any connection. We need to fake the TypeORM functionalities so that our unit tests do not attempt to connect to a real database.
src/authentication/tests/authentication.service.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import * as typeorm from 'typeorm'; import TokenData from '../interfaces/tokenData.interface'; import AuthenticationService from './authentication.service'; (typeorm as any).getRepository = jest.fn(); describe('The AuthenticationService', () => { describe('when creating a cookie', () => { it('should return a string', () => { const tokenData: TokenData = { token: '', expiresIn: 1, }; (typeorm as any).getRepository.mockReturnValue({}); const authenticationService = new AuthenticationService(); expect(typeof authenticationService.createCookie(tokenData)) .toEqual('string'); }); }); }); |
With the usage of the mockReturnValue function, we can mock our repository mock per test. It does mean that we can change it in every test. For this simple test, we don’t need anything more than just an empty object. We need to overwrite some of the TypeScript typings using any because by default the library functions are read-only.
If you want to know more about mocking functions with Jest, check out part four of the testing tutorial
Now our test passes! Let’s dig deeper and test the register function.
src/authentication/tests/authentication.service.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import * as typeorm from 'typeorm'; import UserWithThatEmailAlreadyExistsException from '../exceptions/UserWithThatEmailAlreadyExistsException'; import CreateUserDto from '../user/user.dto'; import AuthenticationService from './authentication.service'; (typeorm as any).getRepository = jest.fn(); describe('The AuthenticationService', () => { describe('when registering a user', () => { describe('if the email is already taken', () => { it('should throw an error', async () => { const userData: CreateUserDto = { fullName: 'John Smith', email: 'john@smith.com', password: 'strongPassword123', }; (typeorm as any).getRepository.mockReturnValue({ findOne: () => Promise.resolve(userData), }); const authenticationService = new AuthenticationService(); await expect(authenticationService.register(userData)) .rejects.toMatchObject(new UserWithThatEmailAlreadyExistsException(userData.email)); }); }); }); }); |
The first thing we check here is if the register function throws an error if the user with that email is found. This situation happens if the findOne function returns something else than undefined. We make it return the user data, and that causes the service to throw the UserWithThatEmailAlreadyExistsException.
Since we are at it, we can test the opposite situation: if the service doesn’t find the user, an error should not be thrown:
src/authentication/tests/authentication.service.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import * as typeorm from 'typeorm'; import CreateUserDto from '../user/user.dto'; import AuthenticationService from './authentication.service'; (typeorm as any).getRepository = jest.fn(); describe('The AuthenticationService', () => { describe('when registering a user', () => { describe('if the email is not taken', () => { it('should not throw an error', async () => { const userData: CreateUserDto = { fullName: 'John Smith', email: 'john@smith.com', password: 'strongPassword123', }; process.env.JWT_SECRET = 'jwt_secret'; (typeorm as any).getRepository.mockReturnValue({ findOne: () => Promise.resolve(undefined), create: () => ({ ...userData, id: 0, }), save: () => Promise.resolve(), }); const authenticationService = new AuthenticationService(); await expect(authenticationService.register(userData)) .resolves.toBeDefined(); }); }); }); }); |
As you can see above, we need to mock more functions of the TypeORM repository. This is due to the fact that if the registration is successful, the User object is created and saved into the database.
After putting all the tests together, we can see the output:
1 2 3 4 5 6 7 8 9 |
PASS src/authentication/tests/authentication.service.test.ts The AuthenticationService when creating a cookie ✓ should return a string (4ms) when registering a user if the email is already taken ✓ should throw an error (1ms) if the email is not taken ✓ should not throw an error (67ms) |
As you can see, thanks to not connecting to any database, our tests are quite fast and don’t depend on external factors.
Thanks to that, they are more reliable.
Integration tests with SuperTest
Testing just the services might prove not to be enough at some point. With the SuperTest library, we can make requests to our application and test its behavior. Let’s install it!
1 |
npm install -D supertest @types/supertest |
One of the tasks that our controller does is attaching the Set-Cookie header with the token to the response. We can make a request to test that.
src/authentication/tests/authentication.service.test.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import * as typeorm from 'typeorm'; import App from "../../app"; import AuthenticationController from "../authentication.controller"; import CreateUserDto from "../../user/user.dto"; import * as request from 'supertest'; (typeorm as any).getRepository = jest.fn(); describe('The AuthenticationController', () => { describe('POST /auth/register', () => { describe('if the email is not taken', () => { it('response should have the Set-Cookie header with the Authorization token', () => { const userData: CreateUserDto = { fullName: 'John Smith', email: 'john@smith.com', password: 'strongPassword123', }; process.env.JWT_SECRET = 'jwt_secret'; (typeorm as any).getRepository.mockReturnValue({ findOne: () => Promise.resolve(undefined), create: () => ({ ...userData, id: 0, }), save: () => Promise.resolve(), }); const authenticationController = new AuthenticationController(); const app = new App([ authenticationController, ]); return request(app.getServer()) .post(`${authenticationController.path}/register`) .send(userData) .expect('Set-Cookie', /^Authorization=.+/) }) }) }) }); |
In the example above we expect the Set-Cookie header to begin with the token specified using a regular expression. The ^ character means that it should be the very beginning of a string and the .+ means that there should be one or more characters afterward.
If you want to know more about regular expressions in JavaScript, check out my RegExp course
Thanks to mocking the connection to the database, our test passes!
1 2 3 4 5 |
PASS src/authentication/tests/authentication.controller.test.ts The AuthenticationController POST /auth/register if the email is not taken ✓ response should have the set-cookie header with the Authorization token (208ms) |
As you can see such tests, even without the database connection, are noticeably slower. This because we need to initialize a bigger part of our application, make it working and perform a request. Since we don’t use a real database connection, we don’t perform end-to-end Express testing here.
With the SuperTest library, you can test all types of requests as there are many possibilities. You can find more examples and the API specification in the documentation.
Summary
In this article, we covered writing unit and integration tests for our Express application with Jest and SuperTest. To avoid connecting to a real database, we mocked the functionalities of the TypeORM. If you are using something different, for example, Mongoose and MongoDB, you can surely do that as well. For the sake of making testing Express easier, we separated a part of our logic into a service. Hopefully, with implementing all of the above, your application can become more reliable making your job easier!
How can we connect to a real database before testing?
For me i did not follow the tutorial, i tried and found a great library called mongodb-memory-server which allows you do database tests in a mock db.
In this case you do not even have to open a real mongodb server, instead it will downloads a mongodb binary server in your node_modules/.cache, you just follow the documentation and connect that memory server using mongoose and its done, simple and straightforward.
I would not recommend test with your original database, so this lib give you an option to do mongodb tests quickly.
Hi,
Thank you so much for your great job. When you mock the repository (typeorm as any).getRepository = jest.fn(), it means what all findOne de any repositories will be the same. I want to mock different responses from findOne depending of the repository, cause the same test touch deferents repositories. I Hope you can answer my questions.
Cheers