├── tests ├── jest-globals-teardown.ts ├── setup.ts ├── unit │ ├── sample.spec.ts │ └── exception │ │ └── handler.exception.spec.ts └── integration │ ├── health.spec.ts │ └── unknown.spec.ts ├── .prettierrc ├── .gitignore ├── src ├── common │ ├── interface │ │ ├── config.interface.ts │ │ └── route.interface.ts │ └── builder │ │ ├── success-response.builder.ts │ │ └── error-response.builder.ts ├── config │ ├── app │ │ ├── app.dev.ts │ │ └── app.pro.ts │ └── index.ts ├── exception │ ├── interface │ │ └── exception.interface.ts │ ├── app.exception.ts │ ├── response │ │ ├── server.exception.ts │ │ └── client.exception.ts │ ├── custom.exception.ts │ └── handler.exception.ts ├── module │ └── health │ │ ├── health.controller.ts │ │ └── health.route.ts ├── route │ └── app.route.ts ├── app.process.ts ├── index.ts ├── app.terminator.ts └── app.ts ├── .eslintignore ├── .prettierignore ├── nodemon.json ├── tsup.config.ts ├── .eslintrc ├── tsconfig.json ├── .vscode └── settings.json ├── jest.config.ts ├── package.json └── README.md /tests/jest-globals-teardown.ts: -------------------------------------------------------------------------------- 1 | export default () => { 2 | process.exit(0); 3 | }; 4 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | process.env = { 2 | ...process.env, 3 | ENV_NAME: "DEV", 4 | }; 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "printWidth": 100 5 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | #outDir 4 | dist 5 | 6 | #secret 7 | .env 8 | 9 | #coverage 10 | coverage -------------------------------------------------------------------------------- /tests/unit/sample.spec.ts: -------------------------------------------------------------------------------- 1 | test("My first test", () => { 2 | const res = 1; 3 | expect(res).toBe(1); 4 | }); 5 | -------------------------------------------------------------------------------- /src/common/interface/config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IConfig { 2 | server: IServer; 3 | } 4 | 5 | export interface IServer { 6 | port: number; 7 | } 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | coverage 3 | dist 4 | node_modules 5 | 6 | .env 7 | .eslintignore 8 | jest.config.ts 9 | nodemon.json 10 | 11 | tsconfig.json 12 | tsup.config.ts -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | coverage 3 | dist 4 | node_modules 5 | 6 | .env 7 | .eslintignore 8 | jest.config.ts 9 | nodemon.json 10 | 11 | tsconfig.json 12 | tsup.config.ts -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src"], 3 | "ignore": ["node_modules"], 4 | "ext": "ts,js,json", 5 | "exec": "tsup && node -r tsconfig-paths/register dist/index.js", 6 | "legacyWatch": true 7 | } -------------------------------------------------------------------------------- /src/common/builder/success-response.builder.ts: -------------------------------------------------------------------------------- 1 | const SuccessResponse = (statusCode: number, payload: any) => { 2 | return { statusCode, payload }; 3 | }; 4 | 5 | export default SuccessResponse; 6 | -------------------------------------------------------------------------------- /src/config/app/app.dev.ts: -------------------------------------------------------------------------------- 1 | import { IConfig } from "@common/interface/config.interface"; 2 | 3 | const appDev: IConfig = { 4 | server: { 5 | port: 5000, 6 | }, 7 | }; 8 | 9 | export default appDev; 10 | -------------------------------------------------------------------------------- /src/config/app/app.pro.ts: -------------------------------------------------------------------------------- 1 | import { IConfig } from "@common/interface/config.interface"; 2 | 3 | const appPro: IConfig = { 4 | server: { 5 | port: 5001, 6 | }, 7 | }; 8 | 9 | export default appPro; 10 | -------------------------------------------------------------------------------- /src/common/interface/route.interface.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | 3 | interface IRouter { 4 | readonly path: string; 5 | readonly router: Router; 6 | initRoute(): void; 7 | } 8 | 9 | export default IRouter; 10 | -------------------------------------------------------------------------------- /src/exception/interface/exception.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ICustomExceptionArgs { 2 | errorCode: number; 3 | errorName: string; 4 | errorMessage: string; 5 | errorRawMessage?: unknown; 6 | errorOperational?: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/module/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | class HealthCheckController { 4 | public getHealth = (req: Request, res: Response): void => { 5 | res.status(200).json({ status: "healthy" }); 6 | }; 7 | } 8 | 9 | export default HealthCheckController; 10 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | 3 | export default defineConfig({ 4 | platform: "node", 5 | target: "es2022", 6 | entry: ["src/**/*.ts"], 7 | format: ["cjs"], 8 | sourcemap: "inline", 9 | treeshake: true, 10 | metafile: true, 11 | skipNodeModulesBundle: true, 12 | }); 13 | -------------------------------------------------------------------------------- /tests/integration/health.spec.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import App from "../../src/app"; 3 | 4 | describe("01. Integration : GET : /health", () => { 5 | it("01. Should return a 200 status", async () => { 6 | const res = await request(new App().getServer()).get("/api/health"); 7 | expect(res.status).toBe(200); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /tests/integration/unknown.spec.ts: -------------------------------------------------------------------------------- 1 | import request from "supertest"; 2 | import App from "../../src/app"; 3 | 4 | describe("01. Integration : GET : /unknown", () => { 5 | it("01. Should return a 404 status", async () => { 6 | const res = await request(new App().getServer()).get("/api/unknown"); 7 | expect(res.status).toBe(404); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/exception/app.exception.ts: -------------------------------------------------------------------------------- 1 | import { Application, NextFunction, Request, Response } from "express"; 2 | 3 | import HandlerException from "./handler.exception"; 4 | 5 | const appException = (app: Application) => { 6 | app.use((error: Error, request: Request, response: Response, next: NextFunction) => { 7 | new HandlerException(error, request, response); 8 | }); 9 | }; 10 | 11 | export default appException; 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "prettier", 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "prettier", 10 | "eslint:recommended", 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "rules": { 15 | "prettier/prettier": 2, 16 | "@typescript-eslint/no-explicit-any": "off", 17 | "@typescript-eslint/no-unused-vars": "off" 18 | } 19 | } -------------------------------------------------------------------------------- /src/common/builder/error-response.builder.ts: -------------------------------------------------------------------------------- 1 | const ErrorResponseBuilder = (output: any) => { 2 | return { 3 | statusCode: output?.statusCode, 4 | payload: { 5 | errorCode: output?.payload?.errorCode, 6 | errorName: output?.payload?.errorName, 7 | errorMessage: output?.payload?.errorMessage, 8 | ...(output?.payload?.errorRawMessage && { 9 | errorRawMessage: output?.payload?.errorRawMessage, 10 | }), 11 | }, 12 | }; 13 | }; 14 | 15 | export default ErrorResponseBuilder; 16 | -------------------------------------------------------------------------------- /src/module/health/health.route.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | 3 | import HealthCheckController from "@module/health/health.controller"; 4 | 5 | class HealthCheckRoute { 6 | public path = "/health"; 7 | public router = Router(); 8 | public controller: any; 9 | 10 | constructor() { 11 | this.controller = new HealthCheckController(); 12 | this.initRoute(); 13 | } 14 | 15 | public initRoute(): void { 16 | this.router.get(this.path, this.controller.getHealth); 17 | } 18 | } 19 | export default HealthCheckRoute; 20 | -------------------------------------------------------------------------------- /src/exception/response/server.exception.ts: -------------------------------------------------------------------------------- 1 | import status from "http-status"; 2 | 3 | import CustomException from "@exception/custom.exception"; 4 | 5 | export class InternalServeError extends CustomException { 6 | constructor(errorRawMessage?: unknown) { 7 | super({ 8 | errorCode: Number(status["INTERNAL_SERVER_ERROR"]), 9 | errorName: String(status[`${status.INTERNAL_SERVER_ERROR}_NAME`]), 10 | errorMessage: String(status[`${status.INTERNAL_SERVER_ERROR}_MESSAGE`]), 11 | errorRawMessage: errorRawMessage, 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/route/app.route.ts: -------------------------------------------------------------------------------- 1 | import { Application, NextFunction, Request, Response } from "express"; 2 | 3 | import HealthCheckRoute from "../module/health/health.route"; 4 | import { NotFound } from "../exception/response/client.exception"; 5 | 6 | export const appModuleRoute = (app: Application) => { 7 | const moduleRoute = () => [new HealthCheckRoute()]; 8 | 9 | moduleRoute().forEach((appRoute) => { 10 | app.use("/api", appRoute.router); 11 | }); 12 | }; 13 | 14 | export const appDefaultRoute = (app: Application) => { 15 | app.use("*", (req: Request, res: Response, next: NextFunction) => { 16 | throw new NotFound(); 17 | }); 18 | }; 19 | -------------------------------------------------------------------------------- /src/app.process.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | import AppTerminator from "./app.terminator"; 4 | 5 | process.on("EACCES", () => { 6 | console.log(`Process ${process.pid} received EACCES`); 7 | new AppTerminator().handleExit(1); 8 | }); 9 | 10 | process.on("EADDRINUSE", () => { 11 | console.log(`Process ${process.pid} received EADDRINUSE`); 12 | new AppTerminator().handleExit(1); 13 | }); 14 | 15 | process.on("SIGTERM", () => { 16 | console.log(`Process ${process.pid} received SIGTERM`); 17 | new AppTerminator().handleExit(0); 18 | }); 19 | 20 | process.on("SIGINT", () => { 21 | console.log(`Process ${process.pid} received SIGINT`); 22 | new AppTerminator().handleExit(0); 23 | }); 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import "express-async-errors"; 2 | import moduleAlias from "module-alias"; 3 | import http from "http"; 4 | import { createHttpTerminator } from "http-terminator"; 5 | 6 | import "./config"; 7 | import "./app.process"; 8 | import App from "./app"; 9 | 10 | const sourcePath = __dirname; 11 | 12 | const moduleAliasPath = { 13 | "@common": `${sourcePath}/common`, 14 | "@exception": `${sourcePath}/exception`, 15 | "@module": `${sourcePath}/module`, 16 | "@route": `${sourcePath}/route`, 17 | }; 18 | 19 | moduleAlias.addAliases(moduleAliasPath); 20 | 21 | export const server = http.createServer(new App().getServer()); 22 | export const httpTerminator = createHttpTerminator({ server }); 23 | 24 | (async () => new App().serverListen())(); 25 | -------------------------------------------------------------------------------- /src/app.terminator.ts: -------------------------------------------------------------------------------- 1 | import { httpTerminator, server } from "./index"; 2 | 3 | class AppTerminator { 4 | public async handleExit(code: number): Promise { 5 | try { 6 | console.log(`Attempting a graceful shutdown with code ${code}`); 7 | 8 | if (server.listening) { 9 | console.log("Terminating HTTP connections"); 10 | await httpTerminator.terminate(); 11 | } 12 | 13 | console.log(`Exiting gracefully with code ${code}`); 14 | process.exit(code); 15 | } catch (error) { 16 | console.log("Error shutting down gracefully"); 17 | console.log(error); 18 | console.log(`Forcing exit with code ${code}`); 19 | process.exit(code); 20 | } 21 | } 22 | } 23 | 24 | export default AppTerminator; 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "types": ["node", "jest"], 5 | "lib": ["es2022"], 6 | "module": "commonjs", 7 | "resolveJsonModule": true, 8 | "allowJs": true, 9 | "outDir": "dist", 10 | "baseUrl": ".", 11 | "alwaysStrict": true, 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "noImplicitAny": true, 16 | "moduleResolution": "Node", 17 | "paths": { 18 | "@common/*": ["src/common/*"], 19 | "@exception/*": ["src/exception/*"], 20 | "@module/*": ["src/module/*"], 21 | "@route/*": ["src/route/*"] 22 | } 23 | }, 24 | "include": ["src/**/*.ts", "tests/**/*.spec.ts"], 25 | "exclude": ["coverage", "dist", "node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/exception/response/client.exception.ts: -------------------------------------------------------------------------------- 1 | import status from "http-status"; 2 | 3 | import CustomException from "@exception/custom.exception"; 4 | 5 | export class NotFound extends CustomException { 6 | constructor() { 7 | super({ 8 | errorCode: Number(status["NOT_FOUND"]), 9 | errorName: String(status[`${status.NOT_FOUND}_NAME`]), 10 | errorMessage: String(status[`${status.NOT_FOUND}_MESSAGE`]), 11 | }); 12 | } 13 | } 14 | 15 | export class BadRequest extends CustomException { 16 | constructor(errorRawMessage?: unknown) { 17 | super({ 18 | errorCode: Number(status["BAD_REQUEST"]), 19 | errorName: String(status[`${status.BAD_REQUEST}_NAME`]), 20 | errorMessage: String(status[`${status.BAD_REQUEST}_MESSAGE`]), 21 | errorRawMessage: errorRawMessage, 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "window.zoomLevel": 0, 3 | "files.eol": "\n", 4 | "[javascript][typescript][json]": { 5 | "editor.tabSize": 2 6 | }, 7 | "editor.wordWrap": "on", 8 | "editor.insertSpaces": true, 9 | "editor.detectIndentation": false, 10 | "editor.formatOnSave": true, 11 | "terminal.integrated.defaultProfile.windows": "GitBash", 12 | "terminal.integrated.profiles.windows": { 13 | "PowerShell": { 14 | "source": "PowerShell", 15 | "icon": "terminal-powershell" 16 | }, 17 | "CommandPrompt": { 18 | "path": [ 19 | "${env:windir}\\Sysnative\\cmd.exe", 20 | "${env:windir}\\System32\\cmd.exe" 21 | ], 22 | "args": [], 23 | "icon": "terminal-cmd" 24 | }, 25 | "GitBash": { 26 | "source": "Git Bash", 27 | "path": [ 28 | "C:\\Program Files\\Git\\bin\\bash.exe" 29 | ], 30 | "icon": "terminal-bash" 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path"; 2 | import { cwd } from "node:process"; 3 | import { existsSync } from "node:fs"; 4 | 5 | import * as dotenv from "dotenv"; 6 | import { cleanEnv, str } from "envalid"; 7 | 8 | import appDev from "./app/app.dev"; 9 | import appPro from "./app/app.pro"; 10 | 11 | let appConfig: any = {}; 12 | 13 | const envReqVar = { 14 | ENV_NAME: str(), 15 | }; 16 | 17 | const setEnvConfig = () => { 18 | if (process.env.ENV_NAME === "DEV" && existsSync(".env")) { 19 | dotenv.config({ path: resolve(cwd(), ".env") }); 20 | } 21 | 22 | cleanEnv(process.env, envReqVar); 23 | }; 24 | 25 | const setAppConfig = () => { 26 | switch (process.env["ENV_NAME"]) { 27 | case "DEV": 28 | appConfig = appDev; 29 | break; 30 | case "PRO": 31 | appConfig = appPro; 32 | break; 33 | default: 34 | appConfig = appDev; 35 | } 36 | }; 37 | 38 | setEnvConfig(); 39 | setAppConfig(); 40 | 41 | export default appConfig; 42 | -------------------------------------------------------------------------------- /src/exception/custom.exception.ts: -------------------------------------------------------------------------------- 1 | import { ICustomExceptionArgs } from "@exception/interface/exception.interface"; 2 | 3 | class CustomException extends Error { 4 | public readonly errorCode!: number; 5 | public readonly errorName!: string; 6 | public readonly errorMessage!: string; 7 | public readonly errorRawMessage: unknown; 8 | public readonly errorOperational: boolean = true; 9 | 10 | constructor(customExceptionArgs: ICustomExceptionArgs) { 11 | super(customExceptionArgs.errorMessage); 12 | 13 | Object.setPrototypeOf(this, new.target.prototype); 14 | 15 | this.errorCode = customExceptionArgs.errorCode; 16 | this.errorName = customExceptionArgs.errorName; 17 | this.errorMessage = customExceptionArgs.errorMessage; 18 | 19 | if (customExceptionArgs.errorRawMessage) { 20 | this.errorRawMessage = customExceptionArgs.errorRawMessage; 21 | } 22 | 23 | if (customExceptionArgs.errorOperational !== undefined) { 24 | this.errorOperational = customExceptionArgs.errorOperational; 25 | } 26 | 27 | //Error.captureStackTrace(this); 28 | } 29 | } 30 | 31 | export default CustomException; 32 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import cors from "cors"; 3 | 4 | import appConfig from "./config"; 5 | import { appDefaultRoute, appModuleRoute } from "@route/app.route"; 6 | import appException from "@exception/app.exception"; 7 | 8 | class App { 9 | private serverPort = Number(appConfig.server.port); 10 | private app: express.Application; 11 | 12 | constructor() { 13 | this.app = express(); 14 | 15 | this.initAppMiddlewares(); 16 | this.initAppRoutes(); 17 | this.initAppException(); 18 | } 19 | 20 | public getServer() { 21 | return this.app; 22 | } 23 | 24 | public serverListen() { 25 | return this.getServer().listen(this.serverPort, () => { 26 | console.log(`App listening port : ${this.serverPort}`); 27 | console.log(`App listening environment : ${process.env.ENV_NAME}`); 28 | }); 29 | } 30 | 31 | private initAppMiddlewares() { 32 | this.app.use(cors()); 33 | this.app.use(express.json()); 34 | this.app.use(express.urlencoded({ extended: true })); 35 | } 36 | 37 | private initAppRoutes() { 38 | appModuleRoute(this.app); 39 | appDefaultRoute(this.app); 40 | } 41 | 42 | private initAppException() { 43 | appException(this.app); 44 | } 45 | } 46 | 47 | export default App; 48 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | const rootDirectory = __dirname; 3 | 4 | import type { Config } from "@jest/types"; 5 | 6 | const config: Config.InitialOptions = { 7 | preset: "ts-jest", 8 | testEnvironment: "node", 9 | verbose: true, 10 | detectOpenHandles: true, 11 | rootDir: rootDirectory, 12 | roots: [rootDirectory], 13 | moduleDirectories: ["node_modules"], 14 | moduleFileExtensions: ["ts", "js", "json"], 15 | moduleNameMapper: { 16 | "@common(.*)$": `${rootDirectory}/src/common$1`, 17 | "@exception(.*)$": `${rootDirectory}/src/exception$1`, 18 | "@module(.*)$": `${rootDirectory}/src/module$1`, 19 | "@route(.*)$": `${rootDirectory}/src/route$1`, 20 | }, 21 | clearMocks: true, 22 | collectCoverage: true, 23 | coverageDirectory: "coverage", 24 | coverageProvider: "v8", 25 | coverageThreshold: { 26 | global: { 27 | statements: 75, 28 | branches: 75, 29 | functions: 75, 30 | lines: 75 31 | }, 32 | }, 33 | setupFilesAfterEnv: [`${rootDirectory}/tests/setup.ts`], 34 | testPathIgnorePatterns: [ 35 | `${rootDirectory}/tests/setup.ts`, 36 | `${rootDirectory}/tests/jest-globals-teardown.ts`, 37 | ], 38 | transform: { 39 | "^.+\\.{ts}?$": [ 40 | "ts-jest", 41 | { 42 | babel: true, 43 | tsConfig: path.resolve(rootDirectory, "tsconfig.json"), 44 | }, 45 | ], 46 | }, 47 | testRegex: ["((/tests/.*)|(\\.|/)(test|spec))\\.ts?$"], 48 | forceExit: true, 49 | }; 50 | 51 | export default config; 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-jest", 3 | "version": "1.0.0", 4 | "description": "Express + Typescript + Jest", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "lint": "eslint . --ext .ts", 8 | "prettier:format": "prettier --config .prettierrc src/**/*.ts --write", 9 | "prettier:watch": "onchange src/**/*.ts -- prettier --write {{changed}}", 10 | "app:build": "rimraf ./dist && tsup", 11 | "dev:watch": "cross-env ENV_NAME=DEV nodemon", 12 | "dev:start": "cross-env ENV_NAME=DEV node dist/index.js", 13 | "test": "jest --runInBand", 14 | "test:watch": "jest --runInBand --watchAll" 15 | }, 16 | "keywords": [], 17 | "author": "Sudhakar Jonnakuti", 18 | "license": "ISC", 19 | "dependencies": { 20 | "cors": "^2.8.5", 21 | "dotenv": "^16.3.1", 22 | "express": "^4.18.2", 23 | "express-async-errors": "^3.1.1", 24 | "http-status": "^1.6.2", 25 | "http-terminator": "^3.2.0" 26 | }, 27 | "devDependencies": { 28 | "@types/cors": "^2.8.13", 29 | "@types/express": "^4.17.17", 30 | "@types/jest": "^29.5.3", 31 | "@types/module-alias": "^2.0.1", 32 | "@types/node": "^20.4.1", 33 | "@types/supertest": "^2.0.12", 34 | "@typescript-eslint/eslint-plugin": "^6.0.0", 35 | "@typescript-eslint/parser": "^6.0.0", 36 | "cross-env": "^7.0.3", 37 | "envalid": "^7.3.1", 38 | "eslint": "^8.44.0", 39 | "eslint-config-prettier": "^8.8.0", 40 | "eslint-plugin-prettier": "^5.0.0", 41 | "jest": "^29.6.1", 42 | "module-alias": "^2.2.3", 43 | "nodemon": "^3.0.1", 44 | "onchange": "^7.1.0", 45 | "prettier": "^3.0.0", 46 | "rimraf": "^5.0.1", 47 | "supertest": "^6.3.3", 48 | "ts-jest": "^29.1.1", 49 | "ts-node": "^10.9.1", 50 | "tsconfig-paths": "^4.2.0", 51 | "tsup": "^7.1.0", 52 | "typescript": "^5.1.6" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/exception/handler.exception.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | 3 | import CustomException from "@exception/custom.exception"; 4 | import { InternalServeError } from "@exception/response/server.exception"; 5 | import ErrorResponseBuilder from "@common/builder/error-response.builder"; 6 | 7 | class HandlerException { 8 | constructor(error: Error, request: Request, response: Response) { 9 | if (this._isTrustedError(error)) { 10 | this._handleTrustedError(error as CustomException, request, response); 11 | } else { 12 | this._handleUntrustedError(error, request, response); 13 | } 14 | } 15 | 16 | private _isTrustedError(error: Error): boolean { 17 | return error instanceof CustomException ? error.errorOperational : false; 18 | } 19 | 20 | private _handleTrustedError(error: CustomException, request: Request, response: Response): void { 21 | this._handleErrorResponse(error, response); 22 | } 23 | 24 | private _normalizeError(error: Error): Error { 25 | if (typeof error === "object" && error instanceof Error) { 26 | return error; 27 | } else if (typeof error === "string") { 28 | return new Error(error); 29 | } 30 | return new Error(JSON.stringify(error)); 31 | } 32 | 33 | private _handleUntrustedError(error: Error, request: Request, response: Response): void { 34 | const serialized = this._normalizeError(error).message; 35 | error = new InternalServeError(serialized); 36 | this._handleErrorResponse(error as CustomException, response); 37 | } 38 | 39 | private _handleErrorResponse(error: CustomException, response: Response) { 40 | response.status(error.errorCode).send( 41 | ErrorResponseBuilder({ 42 | statusCode: error.errorCode, 43 | payload: { 44 | errorCode: error.errorCode, 45 | errorName: error.errorName, 46 | errorMessage: error.errorMessage, 47 | ...((error.errorRawMessage as CustomException) && { 48 | errorRawMessage: error.errorRawMessage, 49 | }), 50 | }, 51 | }), 52 | ); 53 | } 54 | } 55 | 56 | export default HandlerException; 57 | -------------------------------------------------------------------------------- /tests/unit/exception/handler.exception.spec.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "express"; 2 | import HandlerException from "@exception/handler.exception"; 3 | import CustomException from "@exception/custom.exception"; 4 | import ErrorResponseBuilder from "@common/builder/error-response.builder"; 5 | 6 | describe("01. Unit : HandlerException", () => { 7 | // Mock Express Request and Response objects 8 | const mockRequest = {} as Request; 9 | const mockResponse = { 10 | status: jest.fn(() => mockResponse), 11 | send: jest.fn(), 12 | } as unknown as Response; 13 | 14 | afterEach(() => { 15 | jest.clearAllMocks(); 16 | }); 17 | 18 | describe("01. Trusted Errors", () => { 19 | it("01. should handle trusted errors and send error response", () => { 20 | // Create a trusted error for testing 21 | 22 | const trustedError = new CustomException({ 23 | errorCode: 404, 24 | errorName: "TRUSTED_ERROR", 25 | errorMessage: "Message of TrustedError", 26 | }); 27 | 28 | // Call the HandlerException constructor 29 | new HandlerException(trustedError, mockRequest, mockResponse); 30 | 31 | // Assert that the error response is sent with the correct data 32 | expect(mockResponse.status).toHaveBeenCalledWith(404); 33 | expect(mockResponse.send).toHaveBeenCalledWith( 34 | ErrorResponseBuilder({ 35 | statusCode: 404, 36 | payload: { 37 | errorCode: 404, 38 | errorName: "TRUSTED_ERROR", 39 | errorMessage: "Message of TrustedError", 40 | }, 41 | }), 42 | ); 43 | }); 44 | }); 45 | 46 | describe("02. Untrusted Errors", () => { 47 | it("01. should handle untrusted errors and send error response", () => { 48 | // Create an untrusted error for testing 49 | const untrustedError = new Error("Untrusted Error"); 50 | 51 | // Call the HandlerException constructor 52 | new HandlerException(untrustedError, mockRequest, mockResponse); 53 | 54 | // Assert that the error response is sent with the correct data 55 | expect(mockResponse.status).toHaveBeenCalledWith(500); // Internal Server Error 56 | expect(mockResponse.send).toHaveBeenCalledWith( 57 | ErrorResponseBuilder({ 58 | statusCode: 500, 59 | payload: { 60 | errorCode: 500, 61 | errorName: "INTERNAL_SERVER_ERROR", 62 | errorMessage: 63 | "A generic error message, given when an unexpected condition was encountered and no more specific message is suitable.", 64 | errorRawMessage: "Untrusted Error", 65 | }, 66 | }), 67 | ); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # express-lint 2 | 3 | ``` 4 | Output: 5 | 6 | $ npm run test 7 | 8 | > express-jest@1.0.0 test 9 | > jest --runInBand 10 | 11 | PASS tests/unit/exception/handler.exception.spec.ts 12 | 01. Unit : HandlerException 13 | 01. Trusted Errors 14 | √ 01. should handle trusted errors and send error response (13 ms) 15 | 02. Untrusted Errors 16 | √ 01. should handle untrusted errors and send error response (4 ms) 17 | 18 | PASS tests/integration/unknown.spec.ts 19 | 01. Integration : GET : /unknown 20 | √ 01. Should return a 404 status (150 ms) 21 | 22 | PASS tests/integration/health.spec.ts 23 | 01. Integration : GET : /health 24 | √ 01. Should return a 200 status (47 ms) 25 | 26 | PASS tests/unit/sample.spec.ts 27 | √ My first test (4 ms) 28 | 29 | ----------------------------|---------|----------|---------|---------|------------------- 30 | File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 31 | ----------------------------|---------|----------|---------|---------|------------------- 32 | All files | 93.06 | 90.47 | 93.33 | 93.06 | 33 | src | 89.36 | 100 | 85.71 | 89.36 | 34 | app.ts | 89.36 | 100 | 85.71 | 89.36 | 25-29 35 | src/common/builder | 100 | 100 | 100 | 100 | 36 | error-response.builder.ts | 100 | 100 | 100 | 100 | 37 | src/config | 92.68 | 50 | 100 | 92.68 | 38 | index.ts | 92.68 | 50 | 100 | 92.68 | 31-32,34 39 | src/config/app | 100 | 100 | 100 | 100 | 40 | app.dev.ts | 100 | 100 | 100 | 100 | 41 | app.pro.ts | 100 | 100 | 100 | 100 | 42 | src/exception | 93.87 | 88.88 | 100 | 93.87 | 43 | app.exception.ts | 100 | 100 | 100 | 100 | 44 | custom.exception.ts | 93.54 | 75 | 100 | 93.54 | 24-25 45 | handler.exception.ts | 92.85 | 91.66 | 100 | 92.85 | 28-31 46 | src/exception/response | 81.57 | 100 | 66.66 | 81.57 | 47 | client.exception.ts | 70.83 | 100 | 50 | 70.83 | 17-23 48 | server.exception.ts | 100 | 100 | 100 | 100 | 49 | src/module/health | 100 | 100 | 100 | 100 | 50 | health.controller.ts | 100 | 100 | 100 | 100 | 51 | health.route.ts | 100 | 100 | 100 | 100 | 52 | src/route | 100 | 100 | 100 | 100 | 53 | app.route.ts | 100 | 100 | 100 | 100 | 54 | ----------------------------|---------|----------|---------|---------|------------------- 55 | Test Suites: 4 passed, 4 total 56 | Tests: 5 passed, 5 total 57 | Snapshots: 0 total 58 | Time: 6.531 s 59 | Ran all test suites. 60 | 61 | Reference: 62 | 63 | jest-cheat-sheet 64 | https://github.com/sapegin/jest-cheat-sheet 65 | 66 | Unit testing TypeScript with Jest: Part One — Project setup 67 | https://duncanlew.medium.com/unit-testing-typescript-with-jest-part-one-f39d2392d0f4 68 | 69 | Unit testing TypeScript with Jest: Part Two — CI/CD pipeline setup with GitHub Actions 70 | https://duncanlew.medium.com/unit-testing-typescript-with-jest-part-two-ci-cd-pipeline-setup-with-github-actions-750193931405 71 | 72 | Mocking Express Request with Jest and Typescript using correct types 73 | https://stackoverflow.com/questions/57964299/mocking-express-request-with-jest-and-typescript-using-correct-types 74 | 75 | 76 | ``` 77 | --------------------------------------------------------------------------------