Create Authentication system with NodeJS, ExpressJS, TypeScript and Jest E2E testing -- PART 4.
Testing
We will implement End-to-End testing with Jest to test our API. We will test the email tokens as well, we will need those tokens to be able to test the API completely. For the testing we will use:
Jest: Testing package
nodemailer-mock: Testing email
supertest: Testing the Express API endpoints
cheerio: Parsing the email HTML and getting the tokens
Let's install and set them up
Installing dependencies
npm i cheerio supertest
npm i -D @types/supertest jest nodemailer-mock ts-jest
Setup and configurations
Jest Configurations
Create a file in the root directory named jest.config.js
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: 'node',
transform: {
'^.+.tsx?$': ['ts-jest', {}],
},
preset: 'ts-jest',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts'],
coveragePathIgnorePatterns: ['node_modules', '__tests__'],
coverageReporters: ['json-summary', 'html'],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
testMatch: ['**/__tests__/**/*.test.ts'],
testPathIgnorePatterns: ['node_modules', 'dist', 'prisma'],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
resetModules: true,
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
testTimeout: 30000,
};
create two folders in src
; one for nodemail-mock
named __mocks__
, and one for the test files __tests__
Test API Status
Inside __tests__
create a file named api.test.ts
. In this file we will create a jest suit to test the API status and check if our server is working.
// src/__tests__/api.test.ts
import request from 'supertest';
import app from '@/app';
import appConfig, { parseAPIVersion } from '@/config/app.config';
import HttpStatusCode from '@/utils/HTTPStatusCodes';
describe('Test the API Status', () => {
test('It should response the root GET method', async () => {
// Send a GET request using supertest
const response = await request(app).get(parseAPIVersion(1));
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
// Validating the response body
expect(response.body.name).toEqual(appConfig.apiName);
expect(response.body.version).toEqual(appConfig.apiVersion);
expect(response.body.status).toEqual('RUNNING');
});
});
Testing Auth System
This test suite is large, we will go through it and explain it bit by bit, and I will link the file link to GitHub at the end. Before we start, Let's create the mail mock and some other functions we will use.
Mail mock
inside __mocks__
create a file named nodemailer.ts
.
import nodemailer from 'nodemailer';
// pass it in when creating the mock using getMockFor()
const nodemailermock = require('nodemailer-mock').getMockFor(nodemailer);
// export the mocked module
module.exports = nodemailermock;
This will mock the nodemailer and the emails we send in the API.
Email Token reader
This function will parse the Email HTML and get us the token. Create a file inside utils
named mailerUtils.ts
import { load } from 'cheerio';
export function getTokenFromMail(html: string) {
const $ = load(html);
// We have added an id in the a tags before. We use it to get the links and parse the token.
const link = $('#token-link').attr("href")
const parts = link.split('/')
return parts[parts.length - 1]
}
Truncate DB
After testing, we will empty the DB. That's the purpose of this function; to truncate all the tables. Now, create a file inside utils
named truncateDB.ts
Note: this function only works for Postgres.
import prisma from '@/services/prisma.service';
import { logger } from './winston';
export async function truncateAllTables() {
// truncate only in TEST mode
if (process.env.NODE_ENV === 'production' || process.env.STAGE !== 'TEST') {
throw new Error('This function can only be used in test environment');
}
try {
// Disable triggers
await prisma.$queryRaw`SET session_replication_role = 'replica';`;
// Get all table names
const tables = await prisma.$queryRawUnsafe(`
SELECT tablename FROM pg_tables WHERE schemaname = 'public';
`);
// Truncate each table
for (const { tablename } of tables as { tablename: string }[]) {
await prisma.$queryRawUnsafe(`TRUNCATE TABLE ${tablename} CASCADE;`);
}
// Re-enable triggers
await prisma.$queryRawUnsafe(`SET session_replication_role = 'origin';`);
logger.info('All tables truncated successfully');
} catch (error) {
logger.error('Error truncating tables:', error);
} finally {
await prisma.$disconnect();
}
}
Now we are all set to start working on auth.test.ts
. Create this file in __tests__
. We create a test suite for the Auth system. And after the suite has finished, we truncate the DB.
We start by creating the initial user payload. I created it outside the test functions to make global scope for all the suit. We will add the tokens to it and use them across the tests inside this suite.
In this test suite, we have 23 test. I tried to test all the use cases as possible, And that's the role of testing.
import request from 'supertest';
import app from '@/app';
import appConfig, { parseAPIVersion } from '@/config/app.config';
import { truncateAllTables } from '@/utils/truncateDB';
import HttpStatusCode from '@/utils/HTTPStatusCodes';
import wait from '@/utils/helpers';
import * as nodemailer from 'nodemailer';
import { NodemailerMock } from 'nodemailer-mock';
import { getTokenFromMail } from '@/utils/mailerUtils';
const { mock } = nodemailer as unknown as NodemailerMock;
import prisma from '@/services/prisma.service';
import bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
describe('Test Auth system', () => {
const baseRoute = parseAPIVersion(1) + '/auth';
afterAll(async () => {
await truncateAllTables();
});
const userPayload: any = {
name: 'Mehdi Jai',
phone: '+212610010830',
email: 'mjai@doctime.ma',
password: '12345678',
type: 'DOCTOR',
};
});
Now, we have the skeleton of the Auth Test, Let's add tests one by one. Starting by creating a user.
Test Creating a user
We have 3 tests for creating a user. Simple and correct one, with email conflict, and validating user email.
Test Create User:
test('Test Create User', async () => {
const response = await request(app)
.post(baseRoute + '/register')
.send(userPayload)
.set('Accept', 'application/json');
// Getting the emails sent.
const sentEmails = mock.getSentMail();
// Getting only the emails with "Verify Email" subject.
const verifyEmail = sentEmails.find((email) => email.subject === 'Verify Email');
// Testing if the Verification email is sent and exist
expect(verifyEmail).toBeDefined();
// Getting the token and adding it to the User Payload for next use
userPayload.verificationToken = getTokenFromMail(verifyEmail.html.toString());
// Validating the response body
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.id).toBeDefined();
expect(response.body.data.email).toEqual(userPayload.email);
expect(response.body.data.userType).toEqual(userPayload.type);
// Adding the userId to UserPayload
userPayload.userId = response.body.data.id;
});
We can move the email testing to another function, and call it. Create a function outside the scope of the test suite:
function testEmails(subject: string) {
const sentEmails = mock.getSentMail();
const email = sentEmails.find((email) => email.subject === subject);
expect(email).toBeDefined();
const token = getTokenFromMail(email.html.toString());
expect(token).toBeDefined();
return token;
}
Now update the test:
test('Test Create User', async () => {
// ...
const response = await request(app)
.post(baseRoute + '/register')
.send(userPayload)
.set('Accept', 'application/json');
userPayload.verificationToken = testEmails('Verify Email');
// ...
expect(response.status).toBe(HttpStatusCode.OK);
});
Test Create user with email conflict:
test('Test Create User email conflict', async () => {
// We just re-send the same request, the user has already been created, therefore, this request will trigger Email Conflict
const response = await request(app)
.post(baseRoute + '/register')
.send(userPayload)
.set('Accept', 'application/json');
// Validate the response
expect(response.status).toBe(HttpStatusCode.CONFLICT);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.code).toEqual(HttpStatusCode.CONFLICT);
});
Test Validate user email:
test('Test verify User', async () => {
// We got the token prviously and stored it. Now we use it
const response = await request(app)
.post(baseRoute + '/verify-user')
.send({
token: userPayload.verificationToken,
})
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.status).toEqual(true);
// Verify if the User has been updated
const user = await prisma.user.findUnique({
where: {
id: userPayload.userId,
},
});
expect(user).toBeDefined();
expect(user.verifiedEmail).toEqual(true);
});
Test Login
// Test Login process
test('Test Login', async () => {
const loginPayload = {
email: userPayload.email,
password: userPayload.password,
type: userPayload.type,
};
// Call the API
const response = await request(app)
.post(baseRoute + '/login')
.send(loginPayload)
.set('Accept', 'application/json');
// Validate response
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
// Validate reponse body
expect(response.body.data.accessToken).toBeDefined();
expect(response.body.data.accessToken.token).toBeDefined();
expect(response.body.data.accessToken.refreshToken).toBeDefined();
expect(response.body.data.user).toBeDefined();
expect(response.body.data.user.id).toBeDefined();
expect(response.body.data.user.id).toEqual(userPayload.userId);
expect(response.body.data.user.email).toEqual(userPayload.email);
expect(response.body.data.user.userType).toEqual(userPayload.type);
// Store the tokens
userPayload.accessToken = response.body.data.accessToken.token;
userPayload.refreshToken = response.body.data.accessToken.refreshToken;
});
test('Test Login none existing email', async () => {
// Testing the case of wrong email
const loginPayload = {
email: 'other-mail@mail.com',
password: userPayload.password,
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/login')
.send(loginPayload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.message).toEqual('Credentials Error');
});
test('Test Login wrong password', async () => {
const loginPayload = {
email: userPayload.email,
password: userPayload.password + '78',
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/login')
.send(loginPayload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.message).toEqual('Password not match');
});
// Test Authentication
test('Test unauthenticated user', async () => {
// Send a request to the protected route we created in route index
// We send request without the authorization header
const response = await request(app)
.get(parseAPIVersion(1) + '/protected')
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
});
test('Test authenticated user', async () => {
// We send the authorization header
const response = await request(app)
.get(parseAPIVersion(1) + '/protected')
.set('Authorization', 'Bearer ' + userPayload.accessToken)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body.protected).toEqual(true);
});
Testing Refresh Token
test('Test refresh token', async () => {
// Sending the stored refresh token
const payload = {
refreshToken: userPayload.refreshToken,
};
// Updating the Expiration period. The default one is 15 days.
appConfig.jwt.expiresIn = '2s';
const response = await request(app)
.post(baseRoute + '/refresh-token')
.send(payload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.accessToken).toBeDefined();
expect(response.body.data.refreshToken).toBeDefined();
// Storing the new token
userPayload.newAccessToken = response.body.data.accessToken;
userPayload.newRefreshToken = response.body.data.refreshToken;
});
test('Test new token', async () => {
const response = await request(app)
.get(parseAPIVersion(1) + '/protected')
.set('Authorization', 'Bearer ' + userPayload.newAccessToken)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body.protected).toEqual(true);
});
test('Test token expiration', async () => {
// The new token expires in 2s, so we wait 3s to test it's expiration
await wait(3000);
const response = await request(app)
.get(parseAPIVersion(1) + '/protected')
.set('Authorization', 'Bearer ' + userPayload.newAccessToken)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
});
Testing forgetting password
Note:mock.getSentMail()
returns all the emails send in this instance.
test('Test forget password', async () => {
const response = await request(app)
.post(baseRoute + '/forget-password')
.send({
email: userPayload.email,
type: userPayload.type,
})
.set('Accept', 'application/json');
userPayload.forgotPasswordToken = testEmails('Reset Password');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.status).toEqual(true);
});
test('Test forget password wrong email', async () => {
const response = await request(app)
.post(baseRoute + '/forget-password')
.send({
email: 'fake-' + userPayload.email,
type: userPayload.type,
})
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.NOT_FOUND);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.code).toEqual(HttpStatusCode.NOT_FOUND);
});
test('Test reset password wrong token', async () => {
const response = await request(app)
.post(baseRoute + '/reset-password')
.send({
newPassword: userPayload.password,
token: uuidv4(),
})
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.FORBIDDEN);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.code).toEqual(HttpStatusCode.FORBIDDEN);
expect(response.body.error.message).toEqual('Invalid or expired token');
});
Testing Reset password
test('Test reset password', async () => {
// Create and switch the password
userPayload.oldPassword = userPayload.password;
userPayload.password = '123456789';
const response = await request(app)
.post(baseRoute + '/reset-password')
.send({
newPassword: userPayload.password,
token: userPayload.forgotPasswordToken,
})
.set('Accept', 'application/json');
// Validate the response
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.status).toEqual(true);
// Test if the password is updated in DB
const user = await prisma.user.findUnique({
where: {
id: userPayload.userId,
},
});
expect(user).toBeDefined();
const isValidPassword = await bcrypt.compare(userPayload.password, user.password);
expect(isValidPassword).toEqual(true);
});
test('Test old password', async () => {
// test login with the old password
const loginPayload = {
email: userPayload.email,
password: userPayload.oldPassword,
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/login')
.send(loginPayload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.message).toEqual('Password not match');
});
test('Test new password', async () => {
// Test login with the new password
const loginPayload = {
email: userPayload.email,
password: userPayload.password,
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/login')
.send(loginPayload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
});
Test Update password
test('Test update password -- Wrong password', async () => {
// Sending wrong password
const updatePasswordPayload = {
oldPassword: userPayload.oldPassword,
newPassword: userPayload.password,
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/update-password')
.send(updatePasswordPayload)
.set('Authorization', 'Bearer ' + userPayload.accessToken)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.code).toEqual(HttpStatusCode.UNAUTHORIZED);
expect(response.body.error.message).toEqual('Invalid old password');
});
test('Test update password -- password characters not enough', async () => {
// Sending invalid password. The min password charachters is 8
const updatePasswordPayload = {
oldPassword: userPayload.password,
newPassword: '1234',
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/update-password')
.send(updatePasswordPayload)
.set('Authorization', 'Bearer ' + userPayload.accessToken)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNPROCESSABLE_ENTITY);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.code).toEqual(HttpStatusCode.UNPROCESSABLE_ENTITY);
});
test('Test update password -- unauthorized', async () => {
// Sending the request without the authorization header
const updatePasswordPayload = {
oldPassword: userPayload.password,
newPassword: userPayload.oldPassword,
type: userPayload.type,
};
const response = await request(app)
.post(baseRoute + '/update-password')
.send(updatePasswordPayload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.UNAUTHORIZED);
expect(response.body).toBeDefined();
expect(response.body.data).toBeUndefined();
expect(response.body.error).toBeDefined();
expect(response.body.error.code).toEqual(HttpStatusCode.UNAUTHORIZED);
});
test('Test direct update password', async () => {
// disabling updatePasswordRequireVerification to update password without email confirmation
appConfig.updatePasswordRequireVerification = false;
const updatePasswordPayload = {
oldPassword: userPayload.password,
newPassword: userPayload.oldPassword,
type: userPayload.type,
};
userPayload.password = userPayload.oldPassword;
const response = await request(app)
.post(baseRoute + '/update-password')
.send(updatePasswordPayload)
.set('Authorization', 'Bearer ' + userPayload.accessToken)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.status).toEqual(true);
const user = await prisma.user.findUnique({
where: {
id: userPayload.userId,
},
});
expect(user).toBeDefined();
const isValidPassword = await bcrypt.compare(userPayload.password, user.password);
expect(isValidPassword).toEqual(true);
});
Testing update password with email confirmation:
test('Test confirming update password', async () => {
// re-activating updatePasswordRequireVerification
appConfig.updatePasswordRequireVerification = true;
const updatePasswordPayload = {
oldPassword: userPayload.password,
newPassword: userPayload.oldPassword,
type: userPayload.type,
};
userPayload.password = userPayload.oldPassword;
const response = await request(app)
.post(baseRoute + '/update-password')
.send(updatePasswordPayload)
.set('Authorization', 'Bearer ' + userPayload.accessToken)
.set('Accept', 'application/json');
// Get the confimation token
userPayload.updatePasswordToken = testEmails('Update Password');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.status).toEqual(true);
});
test('Test confirm update password', async () => {
// Send the confirmation token
const confirmUpdatePasswordPayload = {
token: userPayload.updatePasswordToken,
};
const response = await request(app)
.post(baseRoute + '/confirm-update-password')
.send(confirmUpdatePasswordPayload)
.set('Accept', 'application/json');
expect(response.status).toBe(HttpStatusCode.OK);
expect(response.body).toBeDefined();
expect(response.body.data).toBeDefined();
expect(response.body.error).toBeUndefined();
expect(response.body.data.status).toEqual(true);
// Verifying if the password has changed
const user = await prisma.user.findUnique({
where: {
id: userPayload.userId,
},
});
expect(user).toBeDefined();
const isValidPassword = await bcrypt.compare(userPayload.password, user.password);
expect(isValidPassword).toEqual(true);
});
You will find all these functions in one file here
Run testing
npm run test
All tests passed successfully!
Postman Collection
We created the tests for our auth system. And we can use Postman to test even further. I created a postman collection to test these APIs. I might deploy the API and add the endpoint to this Postman's collection. So you can test the API online as a DEMO.
https://documenter.getpostman.com/view/13363083/2sA3kYjLAb
Summary
This article was a rich documentation of creating a real API, this API is a part of a real project I'm working on. We can add more features to this API. Such as MFA, 2FA, SMS and OTPs, Handlebars for beautiful HTML Email UIs... We might work on them in another article in the future. Following the Kaizen approach, we can add small improvement in each step.
See you in the next one 💪
Subscribe to my newsletter
Read articles from Mehdi Jai directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Mehdi Jai
Mehdi Jai
Result-oriented Lead Full-Stack Web Developer & UI/UX designer with 5+ years of experience in designing, developing and deploying web applications.