CRUD Rest API with Express, TypeOrm and Jest for Testing — part 2

Andre Ho
25 min readNov 12, 2023

--

Let’s take a note along the way

This is the second part of the earlier story about creating CRUD Rest API with Express, TypeOrm and Jest for testing. This session will be about adding authorization and integration testing for our app and the final part of this stories.

The first part can be found here: CRUD Rest API with Express, TypeOrm and Jest for testing — part 1

Adding Authentication to our application

Now everything is pretty much working fine already, let’s get into access control. Here we will modify so user will have to login first and make it so that only current user can view their corresponding expenses.

We will be using classing email and password authentication and using jwt for user access.

Let’s install our dependencies!

npm install bcrypt passport passport-jwt passport-local jsonwebtoken

npm install -D @types/passport @types/passport-jwt @types/passport-local @types/jsonwebtoken

Let’s add our jwt secret key to .env file

JWT_SECRET_KEY=iCqNmHQGtPOuFiRd5ZzryP7Oy53r6XT4

Let’s modify our user model again

import {
BaseEntity,
BeforeInsert,
BeforeUpdate,
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from "typeorm";
import Expense from "../expense/expense.model";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
export interface IUser {
id?: number;
name: string;
email: string;
password: string;
created_at?: Date;
updated_at?: Date;
deletedAt?: Date;
expense?: Expense[];
}
@Entity()
export default class User extends BaseEntity implements IUser {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column({ unique: true })
email: string;
@Column()
password: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@DeleteDateColumn({ type: "timestamp", default: null, nullable: true })
deletedAt: Date;
@OneToMany(() => Expense, (expense) => expense.user)
expense: Expense[];
@BeforeInsert()
@BeforeUpdate()
async hashPassword() {
if (this.password) {
const saltRounds = 10;
this.password = await bcrypt.hash(this.password, saltRounds);
}
}
async validatePassword(password: string): Promise<boolean> {
return bcrypt.compare(password, this.password);
}
async generateToken(): Promise<string> {
const user: User = this;
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET_KEY);
return token;
}
toJson(): User {
delete this.created_at;
delete this.deletedAt;
delete this.updated_at;
delete this.password;
return this;
}
}

To summarize our changes, we added email and password column, along with several methods including one to hash the password before insertion and update, generate json web token and we also added custom toJson() implementation for dropping unnecessary fields.

Next we will add validator for our user schema. Let’s create user.validator.ts

import Joi from "joi";
export const createUserValidator = Joi.object({
name: Joi.string().required().messages({
"any.required": "is required",
}),
email: Joi.string().required().email().messages({
"any.required": "is required",
"string.email": "format is not valid",
}),
password: Joi.string()
.required()
.min(8)
.messages({
"any.required": "is required",
"string.min": "should be at least 8 characters",
"string.pattern.base": "must contain letter, number and symbol",
})
.pattern(new RegExp(/^(?=.*[A-Z])(?=.*\d)(?=.*\W).+/)),
});
export const updateUserValidator = Joi.object({
name: Joi.string().optional().messages({
"any.required": "is required",
}),
password: Joi.string()
.optional()
.min(8)
.messages({
"any.required": "is required",
"string.min": "should be at least 8 characters",
"string.pattern.base": "must contain letter, number and symbol",
})
.pattern(new RegExp(/^(?=.*[A-Z])(?=.*\d)(?=.*\W).+/)),
});

Now let’s implement both passport jwt and local strategies. First we will create services/auth directory inside src. Then we will create jwtAuth.ts and localAuth.ts

// jwtAuth.ts
import passport from "passport";
import { Strategy as JwtStrategy, ExtractJwt } from "passport-jwt";
import User from "../../modules/user/user.model";
import { RequestHandler } from "express";

passport.use(
new JwtStrategy(
{
secretOrKey: process.env.JWT_SECRET_KEY,
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
},
async (payload: any, done: any) => {
try {
const user = await User.findOneBy({
id: payload.userId,
});

if (!user) {
return done(null, false);
}

return done(null, user);
} catch (error) {
return done(error);
}
}
)
);

// Middleware to authenticate requests
export const authenticate: RequestHandler = (req, res, next) => {
passport.authenticate("jwt", { session: false })(req, res, next);
};
// localAuth.ts
import { RequestHandler } from "express";
import passport from "passport";
import { Strategy as LocalStrategy } from "passport-local";
import User from "../../modules/user/user.model";

passport.use(
new LocalStrategy(
{
usernameField: "email",
},
async (email: string, password: string, done: any) => {
try {
const user: User = await User.findOneBy({ email: email });

if (!user) {
return done(null, false);
}

const isValidPassword = await user.validatePassword(password);

if (isValidPassword) {
return done(null, user);
} else {
return done(null, false);
}
} catch (error) {
return done(error);
}
}
)
);

// Middleware to authenticate requests
export const authenticate: RequestHandler = (req, res, next) => {
passport.authenticate("local", { session: false })(req, res, next);
};

We will also add new static method called sendResponseOk to our baseController

 public static sendResponseOk(res: Response, data: any): void {
res.status(200).json({
message: "success",
data,
});
}

We will remove our unused controller and routes implementation. Let’s modify them.

// user.controller.ts
import { Request, Response } from "express";
import { FindOneOptions } from "typeorm";
import BaseController from "../baseController";
import User from "./user.model";
import { createUserValidator, updateUserValidator } from "./user.validator";

export default class UserController extends BaseController<User> {
constructor() {
super(new User());
}

public async getSingleUser(req: Request, res: Response) {
const user: User = req.user as User;
return BaseController.sendResponseOk(res, user.toJson());
}

public async updateUser(req: Request, res: Response) {
try {
const validation = updateUserValidator.validate(req.body, {
abortEarly: false,
});
if (validation.error) {
return BaseController.sendValidationError(
res,
validation.error.details
);
}

const user: User = req.user as User;
delete req.body.email;
const options: FindOneOptions<User> = {
where: { id: user.id },
};
return this.update(req, res, options);
} catch (error) {
BaseController.sendISE(res, error);
}
}

public async login(req: Request, res: Response) {
const user: User = req.user as User;
const token: string = await user.generateToken();
const response = { ...user.toJson(), token };
BaseController.sendResponseOk(res, response);
}

public async register(req: Request, res: Response) {
try {
const validation = createUserValidator.validate(req.body, {
abortEarly: false,
});
if (validation.error) {
return BaseController.sendValidationError(
res,
validation.error.details
);
}
const user: User = new User();
user.name = req.body.name;
user.password = req.body.password;
user.email = req.body.email;
return this.create(req, res, user);
} catch (error) {
BaseController.sendISE(res, error);
}
}
}
// user.routes.ts
import express from "express";
import Controller from "./user.controller";

import { authenticate as localAuth } from "../../services/auth/localAuth";
import { authenticate as jwtAuth } from "../../services/auth/jwtAuth";

const router = express.Router();
const UserController = new Controller();

router.post("/register", UserController.register.bind(UserController));

router.post("/login", localAuth, UserController.login.bind(UserController));

router.get("/", jwtAuth, UserController.getSingleUser.bind(UserController));

router.put("/", jwtAuth, UserController.updateUser.bind(UserController));

export default router;

We removed the endpoint to get all users and delete user. Since currently I only wanted the user to be able to register, login, get their own data and update their own data.

Next let’s apply our authentication on expenses routes too. We will limit so all expenses endpoints can only be access by authorized user.

// expense.routes.ts
import express from "express";
import Controller from "./expense.controller";

import { authenticate as jwtAuth } from "../../services/auth/jwtAuth";

const router = express.Router();
const ExpenseController = new Controller();

router.post(
"/",
jwtAuth,
ExpenseController.createExpense.bind(ExpenseController)
);

router.get(
"/",
jwtAuth,
ExpenseController.getAllExpense.bind(ExpenseController)
);

router.get(
"/:id",
jwtAuth,
ExpenseController.getSingleExpense.bind(ExpenseController)
);

router.put(
"/:id",
jwtAuth,
ExpenseController.updateExpense.bind(ExpenseController)
);

router.delete(
"/:id",
jwtAuth,
ExpenseController.deleteExpense.bind(ExpenseController)
);

export default router;
// expense.controller.ts
import { Request, Response } from "express";
import { FindOneOptions } from "typeorm";
import BaseController from "../baseController";
import Expense from "./expense.model";
import { createExpenseValidator } from "./expense.validator";
import User from "../user/user.model";

export default class ExpenseController extends BaseController<Expense> {
constructor() {
super(new Expense());
}

public async createExpense(req: Request, res: Response) {
const user: User = req.user as User;
const validationFunction = async (): Promise<void> => {
return createExpenseValidator.validateAsync(req.body, {
abortEarly: false,
});
};

const expense: Expense = new Expense();
expense.amount = req.body.amount;
expense.note = req.body.note;
expense.type = req.body.type;
expense.user = user;
return this.createWithValidator(req, res, validationFunction, expense);
}

public async getSingleExpense(req: Request, res: Response) {
const user: User = req.user as User;
const options: FindOneOptions<Expense> = {
where: { id: req.params.id },
};
try {
const entity = await this.getRepository().findOne(options);
if (this.haveAccess(res, entity, user)) {
res.status(200).json({
status: "ok",
message: "retrieved",
data: entity,
});
}
} catch (error) {
BaseController.sendISE(res, error);
}
}

public async getAllExpense(req: Request, res: Response) {
const user: User = req.user as User;
const options: FindOneOptions<Expense> = {
where: {
user: { id: user.id },
},
order: {
created_at: "DESC",
},
};
return this.getAll(req, res, options);
}

public async deleteExpense(req: Request, res: Response) {
const user: User = req.user as User;

const options: FindOneOptions<Expense> = {
where: { id: req.params.id },
};

try {
const entity = await this.getRepository().findOne(options);
if (this.haveAccess(res, entity, user)) {
entity.remove();
res.status(200).json({
status: "ok",
message: "deleted",
data: entity,
});
}
} catch (error) {
BaseController.sendISE(res, error);
}
}

public async updateExpense(req: Request, res: Response) {
const user: User = req.user as User;
const options: FindOneOptions<Expense> = {
where: { id: req.params.id },
};

try {
const entity = await this.getRepository().findOne(options);
if (this.haveAccess(res, entity, user)) {
Object.assign(entity, req.body);
await entity.save();

res.status(200).json({
status: "ok",
message: "updated",
data: entity,
});
}
} catch (error) {
BaseController.sendISE(res, error);
}
}

private haveAccess(res: Response, expense: Expense, user: User): boolean {
if (!expense) {
res.status(404).json({
status: "not_found",
message: "Entity not found",
});
return false;
}

console.log("exuser", expense);

if (expense.userId != user.id) {
res.status(403).json({
status: "ok",
message: "forbidden",
});
return false;
}
return true;
}
}

Now you can test that each user only have access to their own expenses and cannot modify nor view other user’s expenses.

Added Integration Test

As we made new changes to our codes, we should test our application again to make sure that it worked the way that we hoped it to be. This means that even if the changes are small, we have to make sure it worked fine by testing all the endpoints again. Currently our application is small enough to be verified manually one by one. But as our app grew bigger, it will become a hassle to verify every changes to code by manually hitting and inspecting the endpoint. Now this is where automated integration testing shines. We will be using supertest and jest to test our endpoint for us.

Jest have an advantage that it can run the test in parallel, thus speeding up testing time. But since the test are running in parallel, using same database will be a problem since there will be data locking and multiple tests can write on the same table at the same time, making assertion difficult.

Since our application can take DataSourceOptions as arguments, we can alleviate this problem by passing and creating different database for each corresponding test. Making sure each test ran on their own database.

But first we still have some modifications to make

Let’s modify our app.ts to use class instead and add method to stop listening for graceful exit.

// app.ts
import express, { Express } from "express";
import apiRoutes from "./modules/routes";
import { DataSourceOptions } from "typeorm";
import DatabaseManager from "./databaseManager";

class ExpressApp {
public app: Express;
public dbManager: DatabaseManager;

private server: any;

constructor(datasourceOptions: DataSourceOptions) {
this.app = express();
this.dbManager = new DatabaseManager(datasourceOptions);
}

public startListening(port: number): void {
this.server = this.app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
}

public stopListening(): void {
if (this.server) {
this.server.close(() => {});
}
}

private async connectToDb(): Promise<void> {
try {
await this.dbManager.initializeDataSource();
} catch (error) {
console.log("error");
}
}

private setupMiddleware(): void {
this.app.use(express.json());
}

private setupRoutes(): void {
this.app.use("/api/v1", apiRoutes);

this.app.get("/", async (req, res) => {
res.send("It works, congratulations");
});
}

public async initializeApp(): Promise<void> {
await this.connectToDb();
this.setupMiddleware();
this.setupRoutes();
}
}

export default ExpressApp;

Create a new file called appInstance.ts in src directory. This will be the class that we use to maintain our app state and keeping our ExpressApp singleton during our run.

import { DataSourceOptions } from "typeorm";
import ExpressApp from "./app";

let expressAppInstance: ExpressApp;

export const createExpressAppInstance = (
datasourceOptions: DataSourceOptions
): ExpressApp => {
expressAppInstance = new ExpressApp(datasourceOptions);
return expressAppInstance;
};

export const getExpressAppInstance = (): ExpressApp => expressAppInstance;

And since we have modified our Express App implementation. Then we should modify baseController.ts and index.ts too.

In baseController.ts, let’s import getExpressAppInstance from appInstance.

Then we will add dbManager property and refer to use that instead when trying to get the repository

import { getExpressAppInstance } from "../appInstance";
// rest of our imports
export default abstract class BaseController<MODEL extends BaseEntity> {
protected entityRepository: Repository<MODEL>;
protected entity: MODEL;
private dbManager;

constructor(entity: MODEL) {
this.entity = entity;
}

protected getRepository() {
if (!this.dbManager) {
this.dbManager = getExpressAppInstance()?.dbManager;
}

if (!this.entityRepository) {
this.entityRepository = this.dbManager
.getDataSource()
.getRepository(this.entity.constructor as EntityTarget<MODEL>);
}
return this.entityRepository;
}
// rest of our codes
}

Changes in our index.ts involves making this changes like importing createExpressAppInstance and getExpressAppInstance then initialize the app and start listening from it.

import dotenv from "dotenv";
// Load environment variables from .env file
dotenv.config();

import { DataSourceOptions } from "typeorm";
import { createExpressAppInstance, getExpressAppInstance } from "./appInstance";

// Read the port from environment variables or use a default port
const port: number = process.env.PORT ? parseInt(process.env.PORT) : 3000;

// Initialize data source option
const datasourceOptions: DataSourceOptions = {
type: "mysql",
host: process.env.DB_HOST,
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
synchronize:
process.env.DB_SYNCHRONIZE &&
process.env.DB_SYNCHRONIZE.toLowerCase() === "true",
logging:
process.env.DB_LOGGING && process.env.DB_LOGGING.toLowerCase() === "true",
};

const initAppAndListen = async () => {
let expressApp = getExpressAppInstance();

if (!expressApp) {
expressApp = createExpressAppInstance(datasourceOptions);
await expressApp.initializeApp();
}

await expressApp.startListening(port);
};

initAppAndListen();

To make sure our app will finish successfully without any open handles left, we should also add method to end our database connection. Let’s add the following methods to databaseManager.ts

And since our application will always create the database if it does not exists. Then will also add method to drop the database after all tests is finished so our test database will not litter our environment.

//Add this to databaseManager.ts
public async dropDatabaseIfExists() {
const access: ConnectionOptions = {
user: process.env.ADMIN_USER,
password: process.env.ADMIN_PASSWORD,
database: process.env.ADMIN_DEFAULT_DB,
};

const conn = await mysql.createConnection(access);
await conn.query(
`DROP DATABASE IF EXISTS ${this.datasourceOptions.database}`
);
await conn.end();
}

public async disconnectDataSource() {
if (this.dataSource) {
await this.dataSource.dropDatabase();
await this.dataSource.destroy();
}
}

We have finished our preparation, now let’s get started on adding tests. First we’ll install our dependencies.

npm install -D jest @types/jest ts-jest supertest

Let’s create a new .env for test called .env.test and jest.config.js

Inside .env.test we will fill the environment values that we will use for our test. It is important to use different database from our main .env to prevent data conflict and having our local data wiped out.

// .env.test
PORT=3000

ADMIN_USER=root
ADMIN_PASSWORD=root
ADMIN_DEFAULT_DB=mysql

DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=root
DB_NAME=typeormdb_test
DB_SYNCHRONIZE=true
DB_LOGGING=false

JWT_SECRET_KEY=iCqNmHQGtPOuFiRd5ZzryP7Oy53r6XT4
// jest.config.js
const dotenv = require("dotenv");

// Load environment variables from .env file
dotenv.config({ path: ".env.test" });

process.env.NODE_ENV = "UNIT_TEST";

module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFiles: ["dotenv/config"],
modulePathIgnorePatterns: ["utils", "ignored"],
};

setupFiles: [“dotenv/config”] will make sure that dotenv configuration will be called before our test, making sure we are using the correct .env for test.

Meanwhile modulePathIgnorePatterns: [“utils”, “ignored”] will dictate which pattern to ignore for our test. By default jest will run every test files inside the __tests__ directory. By doing this we are telling Jest to ignore directory “utils” and “ignored” so the files inside those directory will not be ran.

Now let’s create __test__ directory at the root of our project directory.

We’ll create our first user test. Lets create users.test.ts inside __test__ directory.

import { DataSourceOptions, Repository } from "typeorm";
import {
createExpressAppInstance,
getExpressAppInstance,
} from "../src/appInstance"; // Import the utility functions
import User, { IUser } from "../src/modules/user/user.model";
import ExpressApp from "../src/app";
import DatabaseManager from "../src/databaseManager";

import request, { Response } from "supertest";

let expressApp: ExpressApp;

beforeAll(async () => {
// Initialize data source option
const datasourceOptions: DataSourceOptions = {
type: "mysql",
host: process.env.DB_HOST,
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME + "_USER",
synchronize: true,
};

// Create or get the existing ExpressApp instance
expressApp = createExpressAppInstance(datasourceOptions);

await expressApp.initializeApp();

// Generate a random port from 3000 - 8000
const min = 3000;
const max = 8000;

const port = Math.floor(Math.random() * (max - min + 1)) + min;
expressApp.startListening(port);
});

beforeEach(async () => {
await cleanUpDatabase();
});

afterAll(async () => {
await expressApp.stopListening();
await getDatabaseManager().disconnectDataSource();
await getDatabaseManager().dropDatabaseIfExists();
});

const getDatabaseManager = (): DatabaseManager => {
return getExpressAppInstance().dbManager;
};

const getUserRepository = (): Repository<User> => {
return getDatabaseManager().getDataSource().getRepository(User);
};

const getApp = () => {
return expressApp.app;
};

const cleanUpDatabase = async () => {
await getUserRepository()
.createQueryBuilder("users")
.delete()
.from(User)
.execute();
};

const USERNAME = "username";
const NEW_USERNAME = "username_new";

const EMAIL = "user@mail.com";
const WRONG_EMAIL = "usermail.com";
const NEW_EMAIL = "newmail@mail.com";

const PASSWORD = "Password123!";
const SHORT_PASSWORD = "pass";
const NEW_PASSWORD = "Password123!New";

describe("User Register Test", () => {
it("should success to register", async () => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/register`)
.send(body);

expect(response.statusCode).toBe(201);

const responseData: any = response.body.data;
expect(responseData.name).toBe(USERNAME);
expect(responseData.email).toBe(EMAIL);
expect(responseData.password).not.toBe(PASSWORD);
expect(await getUserRepository().count()).toBe(1);
});

it("should fail to register with wrong email format", async () => {
let body: IUser = {
name: USERNAME,
email: WRONG_EMAIL,
password: SHORT_PASSWORD,
};

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/register`)
.send(body);

expect(response.statusCode).toBe(400);

const responseData: any = response.body.error;
expect(responseData["password"]).toContain("should be at least 8 characters");
expect(responseData["password"]).toContain("must contain letter, number and symbol");
expect(responseData["email"]).toContain("format is not valid");
});

it("should fail to register with existing email", async () => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, body);
await getUserRepository().save(user);

expect(await getUserRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/register`)
.send(body);

expect(response.statusCode).toBe(500);
});
});

describe("User Login Test", () => {
it("should login successfully", async () => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, body);
await getUserRepository().save(user);

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/login`)
.send({ ...body, name: null });

expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData).not.toHaveProperty("password");
expect(responseData).toHaveProperty("token");
});

it("should fail to login with wrong password", async () => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, body);
await getUserRepository().save(user);

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/login`)
.send({ ...body, password: SHORT_PASSWORD, name: null });

expect(response.statusCode).toBe(401);
});
});

describe("Get own user data", () => {
it("should get data successfully with token", async () => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, body);
user = await getUserRepository().save(user);

const token: string = await user.generateToken();

const response: Response<any> = await request(getApp())
.get(`/api/v1/users`)
.set("Authorization", `Bearer ${token}`);

expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.email).toBe(user.email);
expect(responseData.id).toBe(user.id);
expect(responseData.name).toBe(user.name);
});

it("should fail to get detail without token", async () => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, body);
await getUserRepository().save(user);

const response: Response<any> = await request(getApp()).get(
`/api/v1/users`
);

expect(response.statusCode).toBe(401);
});
});

describe("Update own user data", () => {
it("should update data successfully with token", async () => {
let existingData: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, existingData);
user = await getUserRepository().save(user);

const token: string = await user.generateToken();

let newBody: IUser = {
email: NEW_EMAIL,
name: NEW_USERNAME,
password: NEW_PASSWORD,
};

const response: Response<any> = await request(getApp())
.put(`/api/v1/users`)
.set("Authorization", `Bearer ${token}`)
.send({ name: newBody.name, password: newBody.password });

expect(response.statusCode).toBe(200);

const userInDb: User | null = await getUserRepository().findOneBy({
id: user.id,
});
if (userInDb) {
expect(userInDb.email).toBe(existingData.email);
expect(userInDb.name).toBe(newBody.name);
expect(userInDb.password).not.toBe(user.password);
} else {
throw new Error(`user with ID ${user.id} not found in the database`);
}
});

it("should not allow any update with email", async () => {
let existingData: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, existingData);
user = await getUserRepository().save(user);

const token: string = await user.generateToken();

let newBody: IUser = {
name: NEW_USERNAME,
email: NEW_EMAIL,
password: NEW_PASSWORD,
};

const response: Response<any> = await request(getApp())
.put(`/api/v1/users`)
.set("Authorization", `Bearer ${token}`)
.send(newBody);

expect(response.statusCode).toBe(400);
const responseData: any = response.body.error;
expect(responseData["email"]).toContain("email is not allowed");
});

it("should fail to update data without token", async () => {
let existingData: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, existingData);
user = await getUserRepository().save(user);

let newBody: IUser = {
name: NEW_USERNAME,
email: NEW_EMAIL,
password: NEW_PASSWORD,
};

const response: Response<any> = await request(getApp())
.put(`/api/v1/users`)
.send(newBody);

expect(response.statusCode).toBe(401);
});
});

beforeAll test are ran, we will import and initialize our server app and initialize the database then started listening for requests on random port from 3000 to 8000. After that all the tests we described will run. Before each test are run however, funtionalities inside beforeEach will be executed in which we will clear the database. Here we are using queryBuilder because currently typeOrm does not come with deleteAll() functionality trying to use clear() will also results in exception because it will use TRUNCATE which will fail in mysql since we are trying to truncate a table with possible foreign key reference. After all tests are finished functionalities inside afterAll will be executed during then our app will stop listening and the test database will be dropped.

Then we’ll modify our package.json script, in which we can run npm run test afterwards.

// add this to scripts in package.json
"scripts": {
"dev": "nodemon",
"test": "jest"
},

We will get the following prompt after the test is finished.

PASS  __tests__/users.test.ts (7.652 s)
User Register Test
√ should success to register (179 ms)
√ should fail to register with wrong email format (16 ms)
√ should fail to register with existing email (290 ms)
User Login Test
√ should login successfully (209 ms)
√ should fail to login with wrong password (199 ms)
Get own user data
√ should get data successfully with token (115 ms)
√ should fail to get detail without token (110 ms)
Update own user data
√ should update data successfully with token (221 ms)
√ should not allow any update with email (118 ms)
√ should fail to update data without token (117 ms)

Test Suites: 1 passed, 1 total
Tests: 10 passed, 10 total
Snapshots: 0 total
Time: 7.779 s, estimated 61 s

You probably noticed that if we try to update user with email field we will receive bad request stating that “email is not allowed” just like what we expected in our test. This is a feature of Joi that will fail the request should the request contains other keys than what we specified in our validator. This can be easily circumvented by adding .options({ stripUnknown: true }) after our Joi object declaration just like what we have done with expense validator.

For now I will keep it without the options so the request will fail when we passed other unnecessary keys so the code will be a bit more diverse and serves as another case example. But should you decide to add stripUnknown to the validator please don’t forget to modify the test according to the extent of your modification.

Great, now that our user test is finished let’s add another test for expenses module which we’ll name expenses.test.ts. We can start by copying and modifying users.test.ts to suit the cases for expenses. But wait. There is a better option instead of duplicating our users.test.ts and modifying it. Since we know we’ll share many of the set up steps, we can just move the commonly used setup steps to separate file instead. We’ll also separate commonly used values and commonly used initialization steps into separate files for easier maintenance.

We will create following files in __tests__/utils

// testRunner.ts
import { Express } from "express";
import {
BaseEntity,
DataSourceOptions,
DeleteResult,
Repository,
} from "typeorm";
import ExpressApp from "../../src/app";
import {
createExpressAppInstance,
getExpressAppInstance,
} from "../../src/appInstance";
import DatabaseManager from "../../src/databaseManager";

export let expressApp: ExpressApp;

export const startApp = async (testName: String) => {
const datasourceOptions: DataSourceOptions = {
type: "mysql",
host: process.env.DB_HOST,
port: process.env.DB_PORT ? parseInt(process.env.DB_PORT, 10) : 3306,
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: (process.env.DB_NAME as string) + testName,
synchronize: true,
};

// Create or get the existing ExpressApp instance
expressApp = createExpressAppInstance(datasourceOptions);

await expressApp.initializeApp();

// Generate a random port from 3000 - 8000
const min = 3000;
const max = 8000;

const port = Math.floor(Math.random() * (max - min + 1)) + min;
expressApp.startListening(port);
};

export const stopApp = async () => {
await expressApp.stopListening();
await getDatabaseManager().disconnectDataSource();
await getDatabaseManager().dropDatabaseIfExists();
};

export const getDatabaseManager = (): DatabaseManager => {
return getExpressAppInstance().dbManager;
};

export const getRepository = <T extends BaseEntity>(
entity: new () => T
): Repository<T> => {
return getDatabaseManager().getDataSource().getRepository(entity);
};

export const getApp = (): Express => {
return expressApp.app;
};

export const cleanUpDatabase = async <T extends BaseEntity>(
entity: new () => T
): Promise<DeleteResult> => {
return getRepository(entity)
.createQueryBuilder()
.delete()
.from(entity)
.execute();
};

In testRunner.ts we have created new function called getRepository with generic T extending BaseEntity. entityClass: new () => T: in argument means that entity is expected to be a constructor function for an instance of type T. Meaning getRepository() expects a data model that we defined with BaseEntity.

// testVariables.ts
export const USERNAME = "username";
export const NEW_USERNAME = "username_new";

export const EMAIL = "user@mail.com";
export const WRONG_EMAIL = "usermail.com";
export const NEW_EMAIL = "newmail@mail.com";

export const PASSWORD = "Password123!";
export const NEW_PASSWORD = "Password123!New";
export const SHORT_PASSWORD = "pass";

this file is pretty straight forward and only exports constant variables we declared

//testDataInitializer.ts
import User, { IUser } from "../../src/modules/user/user.model";

import { getRepository } from "./testRunner";
import { EMAIL, PASSWORD, USERNAME } from "./testVariables";

export const initializeUser = async (): Promise<User> => {
let body: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};
let user: User = new User();
Object.assign(user, body);
return await getRepository(User).save(user);
};

export const initializeUserWithBody = async (body: IUser): Promise<User> => {
let user: User = new User();
Object.assign(user, body);
return await getRepository(User).save(user);
};

export const initializeUserAndGetTOken = async (): Promise<String> => {
const user: User = await initializeUser();
return await user.generateToken();
};

This will provide functionalities for us to initialize the needed data without manually initializing it over and over.

// testDataConstructor.ts
import { IUser } from "../../src/modules/user/user.model";
import {
EMAIL,
NEW_EMAIL,
NEW_PASSWORD,
NEW_USERNAME,
PASSWORD,
USERNAME,
} from "./testVariables";

export const commonUserBody: IUser = {
name: USERNAME,
email: EMAIL,
password: PASSWORD,
};

export const updatedUserBody: IUser = {
name: NEW_USERNAME,
email: NEW_EMAIL,
password: NEW_PASSWORD,
};

This is also pretty straighforward since it only exports the object that will be used in our request body.

Finally let’s shorten our users.test.ts by importing the needed items from those separated utils modules

import { Repository } from "typeorm";
import User, { IUser } from "../src/modules/user/user.model";

import request, { Response } from "supertest";

import { commonUserBody, updatedUserBody } from "./utils/testDataConstructor";
import { initializeUser } from "./utils/testDataInitializer";
import {
cleanUpDatabase,
getApp,
getRepository,
startApp,
stopApp,
} from "./utils/testRunner";
import {
EMAIL,
PASSWORD,
SHORT_PASSWORD,
USERNAME,
WRONG_EMAIL,
} from "./utils/testVariables";

const TEST_NAME = "USER";

beforeAll(async () => {
await startApp(TEST_NAME);
});

beforeEach(async () => {
await cleanUpDatabase(User);
});

afterAll(async () => {
await stopApp();
});

const getUserRepository = (): Repository<User> => {
return getRepository(User);
};

describe("User Register Test", () => {
it("should success to register", async () => {
const response: Response<any> = await request(getApp())
.post(`/api/v1/users/register`)
.send(commonUserBody);
expect(response.statusCode).toBe(201);

const responseData: any = response.body.data;
expect(responseData.name).toBe(USERNAME);
expect(responseData.email).toBe(EMAIL);
expect(responseData.password).not.toBe(PASSWORD);
expect(await getUserRepository().count()).toBe(1);
});

it("should fail to register with wrong email format", async () => {
let body: IUser = {
...commonUserBody,
email: WRONG_EMAIL,
password: SHORT_PASSWORD,
};

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/register`)
.send(body);
expect(response.statusCode).toBe(400);

const responseData: any = response.body.error;
expect(responseData.password).toContain("should be at least 8 characters");
expect(responseData.password).toContain(
"must contain letter, number and symbol"
);
expect(responseData.email).toContain("format is not valid");
});

it("should fail to register with existing email", async () => {
await initializeUser();
expect(await getUserRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/register`)
.send(commonUserBody);
expect(response.statusCode).toBe(500);
});
});

describe("User Login Test", () => {
it("should login successfully", async () => {
await initializeUser();

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/login`)
.send({ ...commonUserBody, name: null });
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData).not.toHaveProperty("password");
expect(responseData).toHaveProperty("token");
});

it("should fail to login with wrong password", async () => {
await initializeUser();

const response: Response<any> = await request(getApp())
.post(`/api/v1/users/login`)
.send({ ...commonUserBody, password: SHORT_PASSWORD, name: null });
expect(response.statusCode).toBe(401);
});
});

describe("Get own user data", () => {
it("should get data successfully with token", async () => {
const user: User = await initializeUser();
const token: string = await user.generateToken();

const response: Response<any> = await request(getApp())
.get(`/api/v1/users`)
.set("Authorization", `Bearer ${token}`);
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.email).toBe(user.email);
expect(responseData.id).toBe(user.id);
expect(responseData.name).toBe(user.name);
});

it("should fail to get detail without token", async () => {
await initializeUser();
const response: Response<any> = await request(getApp()).get(
`/api/v1/users`
);

expect(response.statusCode).toBe(401);
});
});

describe("Update own user data", () => {
it("should update data successfully with token", async () => {
const user: User = await initializeUser();
const token: string = await user.generateToken();

const response: Response<any> = await request(getApp())
.put(`/api/v1/users`)
.set("Authorization", `Bearer ${token}`)
.send({ name: updatedUserBody.name, password: updatedUserBody.password });
expect(response.statusCode).toBe(200);

const userInDb: User | null = await getUserRepository().findOneBy({
id: user.id,
});
if (userInDb) {
expect(userInDb.email).toBe(commonUserBody.email);
expect(userInDb.name).toBe(updatedUserBody.name);
expect(userInDb.password).not.toBe(user.password);
} else {
throw new Error(`user with ID ${user.id} not found in the database`);
}
});

it("should not allow any update with email", async () => {
const user: User = await initializeUser();
const token: string = await user.generateToken();

let newBody: IUser = updatedUserBody;
const response: Response<any> = await request(getApp())
.put(`/api/v1/users`)
.set("Authorization", `Bearer ${token}`)
.send(newBody);

expect(response.statusCode).toBe(400);
const responseData: any = response.body.error;
expect(responseData.email).toContain("email is not allowed");
});

it("should fail to update data without token", async () => {
await initializeUser();
let newBody: IUser = updatedUserBody;
const response: Response<any> = await request(getApp())
.put(`/api/v1/users`)
.send(newBody);

expect(response.statusCode).toBe(401);
});
});

Let’s finalize our project by adding test for expenses. We’ll add some new variables to use in testVariables.ts and testDataConstructor.ts

// add following values to testVariables.ts  
export const ALT_USERNAME = "alt user";
export const ALT_EMAIL = "alt_user@mail.com";
// add following values to testDataConstructor.ts
export const altUserBody: IUser = {
name: ALT_USERNAME,
email: ALT_EMAIL,
password: PASSWORD,
};

export const expenseBody: IExpense = {
amount: 10,
note: "some notes",
type: ExpenseType.EXPENSE,
};

export const incomeBody: IExpense = {
amount: 7,
note: "lucky seven",
type: ExpenseType.INCOME,
};

export const updateExpenseBody: IExpense = {
amount: 17,
note: "changed notes and type",
type: ExpenseType.INCOME,
};

Next we’ll add alternate user initializer and other functions we will use later to testDataInitializer.ts

// add the following functions to testDataInitializer.ts
export const initializeAltUser = async (): Promise<User> => {
return await initializeUserWithBody(altUserBody);
};
export const initializeUserAndToken = async (): Promise<[User, String]> => {
const user: User = await initializeUser();
return [user, await user.generateToken()];
};

export const initializeExpense = async (user: User): Promise<Expense> => {
return await initializeExpenseWithBody({ ...expenseBody, userId: user.id });
};

export const initializeExpenseWithBody = async (
body: IExpense
): Promise<Expense> => {
let expense: Expense = new Expense();
Object.assign(expense, body);
return await getRepository(Expense).save(expense);
};

Finally we can create our expense.test.ts to test the expense module

import { Repository } from "typeorm";
import Expense, { IExpense } from "../src/modules/expense/expense.model";
import User from "../src/modules/user/user.model";

import request, { Response } from "supertest";

import { expenseBody, updateExpenseBody } from "./utils/testDataConstructor";
import {
initializeAltUser,
initializeExpense,
initializeUserAndToken,
} from "./utils/testDataInitializer";
import {
cleanUpDatabase,
getApp,
getRepository,
startApp,
stopApp,
} from "./utils/testRunner";

const TEST_NAME = "EXPENSE";

beforeAll(async () => {
await startApp(TEST_NAME);
});

beforeEach(async () => {
await cleanUpDatabase(Expense);
await cleanUpDatabase(User);
});

afterAll(async () => {
await stopApp();
});

const getUserRepository = (): Repository<User> => {
return getRepository(User);
};

const getExpenseRepository = (): Repository<Expense> => {
return getRepository(Expense);
};

const initializeMultipleExpenses = async (user: User): Promise<Expense[]> => {
let expenses: Expense[] = [];

for (let index = 0; index < 5; index++) {
let tempBody: IExpense = expenseBody;
let tempExpense: Expense = new Expense();
Object.assign(tempExpense, {
...tempBody,
amount: tempBody.amount + index,
userId: user.id,
});
expenses.push(tempExpense);
}

return await getExpenseRepository().save(expenses);
};

describe("Expense Creation Test", () => {
it("should success to create expense", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const response: Response<any> = await request(getApp())
.post(`/api/v1/expenses`)
.set("Authorization", `Bearer ${userAndToken[1]}`)
.send(expenseBody);
expect(response.statusCode).toBe(201);

const responseData: any = response.body.data;
expect(await getExpenseRepository().count()).toBe(1);

const expenseInDb: Expense[] = await getExpenseRepository().findBy({});
let expense: Expense = expenseInDb[0];

expect(expense.amount).toBe(expenseBody.amount);
expect(expense.note).toBe(expenseBody.note);
expect(expense.type).toBe(expenseBody.type);
});

it("should fail to create expense without token", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const response: Response<any> = await request(getApp())
.post(`/api/v1/expenses`)
.send(expenseBody);
expect(response.statusCode).toBe(401);

expect(await getExpenseRepository().count()).toBe(0);
});

it("should validate type", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const response: Response<any> = await request(getApp())
.post(`/api/v1/expenses`)
.set("Authorization", `Bearer ${userAndToken[1]}`)
.send({ ...expenseBody, type: "nothing" });
expect(response.statusCode).toBe(400);

const responseData: any = response.body.error;
expect(responseData.type).toContain(
"type must be one of [expense, income]"
);

expect(await getExpenseRepository().count()).toBe(0);
});

it("should validate amount", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const response: Response<any> = await request(getApp())
.post(`/api/v1/expenses`)
.set("Authorization", `Bearer ${userAndToken[1]}`)
.send({ ...expenseBody, amount: "a" });
expect(response.statusCode).toBe(400);

const responseData: any = response.body.error;
expect(responseData.amount).toContain("amount must be a number");

expect(await getExpenseRepository().count()).toBe(0);
});
});

describe("Get All Expenses Test", () => {
it("should success to create expense and not get other user's expenses", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
await initializeMultipleExpenses(userAndToken[0]);

const altUser: User = await initializeAltUser();
await initializeMultipleExpenses(altUser);

expect(await getExpenseRepository().count()).toBe(10);

const response: Response<any> = await request(getApp())
.get(`/api/v1/expenses`)
.set("Authorization", `Bearer ${userAndToken[1]}`);
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.length).toBe(5);

responseData.forEach((expense) => {
expect(expense.userId).toBe(userAndToken[0].id);
});

const altResponse: Response<any> = await request(getApp())
.get(`/api/v1/expenses`)
.set("Authorization", `Bearer ${await altUser.generateToken()}`);
expect(altResponse.statusCode).toBe(200);

const altResponseData: any = altResponse.body.data;
expect(responseData.length).toBe(5);

altResponseData.forEach((expense) => {
expect(expense.userId).toBe(altUser.id);
});
});

it("should fail to get expenses without token", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
await initializeMultipleExpenses(userAndToken[0]);

const altUser: User = await initializeAltUser();
await initializeMultipleExpenses(altUser);

expect(await getExpenseRepository().count()).toBe(10);

const response: Response<any> = await request(getApp()).get(
`/api/v1/expenses`
);
expect(response.statusCode).toBe(401);
});
});

describe("Get Single Expense Test", () => {
it("should success to create expense and not get other user's expenses", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.get(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${userAndToken[1]}`);
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.userId).toBe(userAndToken[0].id);
expect(responseData.amount).toBe(expenseBody.amount);
expect(responseData.note).toBe(expenseBody.note);
expect(responseData.type).toBe(expenseBody.type);
});

it("should fail to get expense without access token", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp()).get(
`/api/v1/expenses/${expense.id}`
);
expect(response.statusCode).toBe(401);
});

it("should be forbidden when accessing other user's expense", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

const altUser: User = await initializeAltUser();

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.get(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${await altUser.generateToken()}`);
expect(response.statusCode).toBe(403);
});
});

describe("Update Expense Test", () => {
it("should success to update own expense", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.put(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${userAndToken[1]}`)
.send(updateExpenseBody);
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.amount).toBe(updateExpenseBody.amount);
expect(responseData.note).toBe(updateExpenseBody.note);
expect(responseData.type).toBe(updateExpenseBody.type);

const expenseInDb: Expense | null = await getExpenseRepository().findOneBy({
id: expense.id,
});
if (expenseInDb) {
expect(expenseInDb.amount).toBe(updateExpenseBody.amount);
expect(expenseInDb.note).toBe(updateExpenseBody.note);
expect(expenseInDb.type).toBe(updateExpenseBody.type);
}
});

it("update should not be able to change userId", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

const altUser: User = await initializeAltUser();

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.put(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${userAndToken[1]}`)
.send({ ...updateExpenseBody, userId: altUser.id });
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.amount).toBe(updateExpenseBody.amount);
expect(responseData.note).toBe(updateExpenseBody.note);
expect(responseData.type).toBe(updateExpenseBody.type);

const expenseInDb: Expense | null = await getExpenseRepository().findOneBy({
id: expense.id,
});
if (expenseInDb) {
expect(expenseInDb.amount).toBe(updateExpenseBody.amount);
expect(expenseInDb.note).toBe(updateExpenseBody.note);
expect(expenseInDb.type).toBe(updateExpenseBody.type);
expect(expenseInDb.userId).toBe(userAndToken[0].id);
}
});

it("should fail to update expense without access token", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.put(`/api/v1/expenses/${expense.id}`)
.send(updateExpenseBody);
expect(response.statusCode).toBe(401);
});

it("should be forbidden when updating other user's expense", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

const altUser: User = await initializeAltUser();

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.put(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${await altUser.generateToken()}`)
.send(updateExpenseBody);
expect(response.statusCode).toBe(403);
});
});

describe("Delete Expense Test", () => {
it("should success to delete own expense", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.delete(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${userAndToken[1]}`);
expect(response.statusCode).toBe(200);

const responseData: any = response.body.data;
expect(responseData.userId).toBe(userAndToken[0].id);
expect(responseData.amount).toBe(expenseBody.amount);
expect(responseData.note).toBe(expenseBody.note);
expect(responseData.type).toBe(expenseBody.type);

expect(await getExpenseRepository().count()).toBe(0);
});

it("should fail to delete expense without access token", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp()).delete(
`/api/v1/expenses/${expense.id}`
);
expect(response.statusCode).toBe(401);

expect(await getExpenseRepository().count()).toBe(1);
});

it("should be forbidden when trying to delete other user's expense", async () => {
const userAndToken: [User, String] = await initializeUserAndToken();
const expense: Expense = await initializeExpense(userAndToken[0]);

const altUser: User = await initializeAltUser();

expect(await getExpenseRepository().count()).toBe(1);

const response: Response<any> = await request(getApp())
.delete(`/api/v1/expenses/${expense.id}`)
.set("Authorization", `Bearer ${await altUser.generateToken()}`);
expect(response.statusCode).toBe(403);

expect(await getExpenseRepository().count()).toBe(1);
});
});

Now we can run our test again, execute npm run test on the terminal and let Jest run the test. Upon running our test, there are bound to be failure. You will notice that sometimes “should success to delete own expense” are unstable and will fail sometimes. “update should not be able to change userId” will also always fail.

Turns out our test are informing us about issues that we are unaware of or missed during the development.

Let’s take a look at our deleteExpense method which handles expense deletion in expense.controller.ts. Turns out we forgot to await for entity.remove() to finish. Let’s add await in entity.remove() so it will wait for the remove process to finish before returning the result.

public async deleteExpense(req: Request, res: Response) {
const user: User = req.user as User;

const options: FindOneOptions<Expense> = {
where: { id: req.params.id },
};

try {
const entity = await this.getRepository().findOne(options);
if (this.haveAccess(res, entity, user)) {
await entity.remove();
res.status(200).json({
status: "ok",
message: "deleted",
data: entity,
});
}
} catch (error) {
BaseController.sendISE(res, error);
}
}

For the latter we should check the updateExpense() method in our expense.controller.test. There we go, we are currently using Object.assign(entity, req.body) to copy the values from request body to our expense entity. This means that every values present in body will replace the value in our entity if the key is present.

The easiest fix we can do is to add delete req.body.userId so the userId will be removed from request before value assignment. We can also make it fail the validator (turning it to bad request) by modifying validator and removing stripUnknown or setting it to false.

public async updateExpense(req: Request, res: Response) {
const user: User = req.user as User;
const options: FindOneOptions<Expense> = {
where: { id: req.params.id },
};

try {
const entity = await this.getRepository().findOne(options);
if (this.haveAccess(res, entity, user)) {
delete req.body.userId;
Object.assign(entity, req.body);
await entity.save();

res.status(200).json({
status: "ok",
message: "updated",
data: entity,
});
}
} catch (error) {
BaseController.sendISE(res, error);
}
}

Now let’s run npm run test again and finally all of our test passes and matching our expected results.

Finally we are done with this project. This marks the end of this lengthy documentation about creating CRUD REST API with Express and TypeOrm and Testing with Jest In Typescript. Of course this is just a small piece of what you can actually achieve. There are also so many aspects that we can improve yet, such as handling and throwing proper error instead of 500 Internal Server Error when user are trying to create user with duplicate email. Or we can also add more validations to make sure the code will run fine and making sure the data integrity is guaranteed. Of course you might be thinking that this code can still be improved or have any improvement suggestion and I will say go ahead and do it, you can also drop in comment of what you are doing or what you might do so others who are following this thread can also read your suggestion.

Thank you so much for reading and following this to the end. It aint much but I hope it provides information and aids you in your learning process. Keep on Learning and improving.

Repository for this project: https://github.com/AndreHrs/ExpressTypeOrmAPI

I will take my leave then

--

--