Introduction to TypeScript API Testing with Jest and SuperTest

on

In my previous tutorial, we created a simple REST API in Node.js Express and used Mocha with the Chai assertion library to verify that the API worked as expected. In this tutorial, we are going to create the same Express API, but using TypeScript instead, and test our code with Jest and SuperTest.

Setup TypeScript compiler

We’ll be using Node.js v20.

node -v
v20.7.0

Before starting we have to setup our TypeScript compiler. If you are a beginner, I recommend that you setup your TypeScript development environment with ESLint and Prettier before continuing. You can have a look at my tutorial for step-by-step instructions.

Install TypeScript as a development dependency.

npm install typescript --save-dev

Create a new TypeScript configuration file tsconfig.json in your project’s root directory.

{
  "compilerOptions": {
    "target": "es2022",
    "module": "commonjs",
    "outDir": "dist",
    "esModuleInterop": true,
  },
  "include": ["src"],
  "exclude": ["node_modules"],
}
  • target: The ECMAScript version to transpile TypeScript to. It should be compatible with our Node.js version.
  • module: Sets the module system for the transpiled code. We’ll be using commonjs, but you could also try nodenext, as recent versions of Node fully support ECMAScript modules.
  • outDir: Output directory for JavaScript code.
  • esModuleInterop: Allows TypeScript to correctly transpile code that imports default exports from some modules.
  • include: Location of our TypeScript source files.
  • exclude: Paths to exclude from processing.

Running npx tsc will transpile TypeScript to JavaScript. We can add our build command to package.json.

{
  ...
  "scripts": {
    "build": "npx tsc"
  },
  ...
}

Create Express API

This will be our project’s directory structure:

.
└─dist
└─src
│ ├─controllers
│ │ └─ColorsController.ts
│ ├─routes
│ │ └─ColorsRoute.ts
│ ├─services
│ │ └─ColorsService.ts
│ ├─app.ts
│ └─start.ts
├─tests
└─package.json
└─tsconfig.json
  • routes: Our Express routes.
  • controllers: Handlers for our Express routes.
  • services: All business logic. Typically, this directory would contain code for exchanging information with a database.
  • tests: Directory for our Jest tests.

Install the Express dependency.

npm install express

Install TypeScript type definitions for Express.

npm install @types/express --save-dev

In the services directory, create ColorsService.ts.

export class ColorsService {
  private colors = ["RED", "GREEN", "BLUE"];

  /**
   * Returns a list of colors
   */
  getColors(): Array<string> {
    return this.colors;
  }

  /**
   * Inserts a new color in the colors array
   */
  addColor(color: string): Array<string> {
    if (!color || this.colors.includes(color)) {
      throw new Error("Cannot add color");
    }

    this.colors.push(color);

    return this.getColors();
  }
}

In the controllers directory, create ColorsController.ts.

import { Request, Response } from "express";
import { ColorsService } from "../services/ColorsService";

interface IReqBody {
  color: string;
}

interface IResBody {
  results: Array<string>;
}

export class ColorsController {
  private colorsService: ColorsService;

  constructor(colorsService: ColorsService) {
    this.colorsService = colorsService;
  }

  getColors(_req: Request, res: Response<IResBody>): void {
    const colors = this.colorsService.getColors();

    res.json({
      results: colors,
    });
  }

  addColor(
    req: Request<unknown, unknown, IReqBody>,
    res: Response<IResBody>
  ): void {
    try {
      if (!req.is("application/json") || typeof req.body?.color !== "string") {
        throw new Error("Invalid request body");
      }

      const { color } = req.body;
      const colors = this.colorsService.addColor(color.trim().toUpperCase());

      // 201 Created
      res.status(201).send({
        results: colors,
      });
    } catch (e) {
      console.error(e);
      // 400 Bad Request
      res.status(400).send();
    }
  }
}

In the routes directory, create ColorsRoute.ts.

import { Router } from "express";
import { ColorsController } from "../controllers/ColorsController";

export class ColorsRoute {
  private colorsController: ColorsController;

  constructor(colorsController: ColorsController) {
    this.colorsController = colorsController;
  }

  createRouter(): Router {
    const router = Router();

    router.get(
      "/colors",
      this.colorsController.getColors.bind(this.colorsController)
    );

    router.post(
      "/colors",
      this.colorsController.addColor.bind(this.colorsController)
    );

    return router;
  }
}

Since in JavaScript the object that this points to depends on how our methods are invoked, we use getColors.bind() and addColor.bind() to make sure it is always set to our class instance. Alternatively, we could have used arrow functions to achieve the same result.

In the root directory, create app.ts.

import express from "express";

import { ColorsController } from "./controllers/ColorsController";
import { ColorsRoute } from "./routes/ColorsRoute";
import { ColorsService } from "./services/ColorsService";

const colorsService = new ColorsService();
const colorsController = new ColorsController(colorsService);
const colorsRoute = new ColorsRoute(colorsController);

const app = express();

app.use(express.json());
app.use(colorsRoute.createRouter());

export default app;

To be able to start our server manually, we should create start.ts.

import app from "./app";

const PORT = 8080;
const HOST = "localhost";

app.listen(PORT, HOST, () => {
  console.log("Listening on %s:%d...", HOST || "*", PORT);
});

We can start our server with node dist/start.js, after running the TypeScript compiler. We can add the command to package.json.

{
  ...
  "scripts": {
    "build": "npx tsc",
    "start": "node dist/start.js"
  },
  ...
}

Testing with Jest and SuperTest

Install the development dependencies for testing.

npm install jest @types/jest ts-jest supertest @types/supertest --save-dev
  • jest: The Jest testing framework.
  • @types/jest: Jest types for writing tests in TypeScript.
  • ts-jest: A TypeScript preprocessor with source map support for Jest.
  • supertest: A module allowing HTTP assertions using superagent.
  • @types/supertest: SuperTest type definitions.

Initialize our Jest configuration for TypeScript.

npx ts-jest config:init

A new file jest.config.js will be created in the root directory. We just need to change the roots property to point to our tests directory.

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ["<rootDir>/tests/"]
};

Let’s also add the jest command to package.json.

{
  ...
  "scripts": {
    "build": "npx tsc",
    "start": "node dist/start.js",
    "test": "jest"
  },
  ...
}

In the tests directory, create our test app.test.ts.

import request from "supertest";
import app from "../src/app";

describe("API endpoint /colors", () => {
  // GET - List all colors
  it("should return all colors", async () => {
    const res = await request(app)
      .get("/colors")
      .expect("Content-Type", /json/);
    expect(res.status).toEqual(200);
    expect(res.body.results).toEqual(["RED", "GREEN", "BLUE"]);
  });

  // GET - Invalid path
  it("should return Not Found", async () => {
    const res = await request(app).get("/INVALID_PATH");
    expect(res.status).toEqual(404);
  });

  // POST - Add new color
  it("should add new color", async () => {
    const res = await request(app)
      .post("/colors")
      .send({
        color: "YELLOW",
      })
      .expect("Content-Type", /json/);
    expect(res.status).toEqual(201);
    expect(res.body.results).toContain("YELLOW");
  });

  // POST - Bad Request
  it("should return Bad Request", async () => {
    const res = await request(app).post("/colors").type("form").send({
      color: "YELLOW",
    });
    expect(res.status).toEqual(400);
  });
});

Finally, we can run our tests with npm test.

npm test
PASS  tests/app.test.ts
  API endpoint /colors
    ✓ should return all colors (26 ms)
    ✓ should return Not Found (6 ms)
    ✓ should add new color (14 ms)
    ✓ should return Bad Request (3 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        2.003 s, estimated 3 s
Ran all test suites.