TDD with Typescript and Jest: Url shortener

Hoang Dinh
Level Up Coding
Published in
7 min readMar 7, 2021

--

This is a simple project — Writing an API service running on Nodejs. The service is a URL shortening service. The service also is an example of TDD style using Typescript, Jest, Express, and Mongoose.

Create and initialize the project

This project will be base on TDD with Typescript and Jest: Starter project.

Just clone the starter project from Github:

$ git clone https://github.com/hoangsetup/ts-jest-tdd-starter.git url-shortener
$ cd url-shortener
$ npm ci

With url-shortener is the name of this project. You also can update the project name in package.json file.

{
"name": "url-shortener",
"version": "1.0.0",
"description": "",
"main": "index.js",
...

Install required dependencies

First of all, let’s install some external dependence packages which we need to build our service.

$ npm install dotenv express mongoose shortid validator -S

And we need their type definitions:

$ npm install @types/dotenv @types/express @types/mongoose @types/shortid @types/validator supertest nodemon -D

MongoDB service

This is an optional part, this article talking about TDD, I try to focus on “How to write unit test?”.

I use docker to run a MongoDB service instance, you can feel free to create the DB service instance by your way.

$ docker run -it --rm -p 27017:27017 mongo

Simple express server and mongoose model

In this step, we will create an API server with Express and set up a development environment.

Api application class

This class will provide an instance of express, the instance will be exported with completed features.

Create src/ApiApp.ts file:

We can get the express instance via getApplication function.

Server and setup for development

As you can see, we need some logic to start an HTTP server base on the express app.

Create src/server.ts

This file is the starter point of service. We try to connect to The MongoDB service, then serve the application on a port that comes from PORT environment variable, or the default port — 3000.

It also requires MONGO_URI environment variable. You can pass these environment variables by creating .env file with content like this:

PORT=3000
MONGO_URI="mongodb://localhost:27017/url-shortener"

Try to start the server, at first we have to build TS to JS code:

$ npm build
$ node ./dist/server.js

If everything works fine, you will see a message like this:

listening on port 3000

And, send GET / request you will get a greeting message:

$ curl --request GET 'http://localhost:3000'
{"message":"Welcome to our service!"}

TDD

We will come high-level class and go down to the dependency class at the lower level.

With each file or class we define its requirements, then try to implement the requirements by following TDD style.

Overall, the user story for a minimum shortening URL service is:

  • Shorten an URL — The user sends a “long” URL to our service, the service will return a short id string.
  • Reverse a “short” URL — The user uses a browser to request with a short id string, if the id mapping with a destination url then redirect the browser to the destination url. If not, respond with an error.

Our project will have a structure like this:

Router -> Controller -> Model
  • Router: Register HTTP request handler, it calls Controller function with arguments arerequest, response .
  • Controller: Main logic for our service, get URL information by using Model.
  • Model: Define URL document schema to working with urls documents in MongoDB.

Url model

The model class just like a configuration, then no need to write testing for this class.

The main attributes for an URL record:

  • url — String, the destination url.
  • shortId — String, the shorten id for the destination url.

Create src/models/UrlModel.ts :

Default export — UrlModel , used to working with url documents in MongoDB.

IUrlDocument — Type of a url item that returns from UrlModel queries.

Router

The requirements for this class are:

  • Export and express router instance
  • Register GET /:shortId to the router, calls, and pass req, res to Controller.redirectUrl .
  • Register POST / to the router, calls, and pass req, res to Controller.shortenUrl .

At first, get an Red code by creating spec file — src/routers/ShortenerUrl.spec.ts

import ShortenerRoute from './ShortenerRoute';

RED: Cannot find module ‘./ShortenerRoute’ or its corresponding type declarations.

Fix RED code by creating production code file — src/routers/ShortenUrl.ts

class ShortenerRoute {}export default new ShortenerRoute();

Export an instance of ShortenerUrl class.

Because the router class will call to Controller function, but Controller is another unit, then in this spec file we have to mock some function of Controller.

To do that, we will use jest.mock function — Mocks a module with an auto-mocked version when it is being required.

Update to the spec file:

import ShortenerRoute from './ShortenerRoute';import UrlController from '../controllers/ShortenerController';
jest.mock('../controllers/ShortenerController');

RED: Cannot find module ‘../controllers/ShortenerController’ or its corresponding type declarations.

Fix RED code by creating production code file — src/controllers/ShortenerController.ts

class ShortenerController {

}
export default new ShortenerController();

We use supertest to trigger a registered request handle in an express router.

Let’s write the first test block in the spec file - “ShortenerRoute”

import express, { Application } from 'express';
import { MockedObject, mocked } from 'ts-jest/dist/utils/testing';
import supertest from 'supertest';
import ShortenerRoute from './ShortenerRoute';
import UrlController from '../controllers/ShortenerController';
jest.mock('../controllers/ShortenerController');describe('ShortenerRoute', () => {
const shortId = 'short-id';
const expectedResponse = expect.anything();
let app: Application;
let request: supertest.SuperTest<supertest.Test>;
let UrlControllerMock: MockedObject<typeof UrlController>;
beforeEach(() => {
UrlControllerMock = mocked(UrlController);
UrlControllerMock.redirectUrl.mockImplementation(async (_, res) => {
return res.json({});
});
app = express();
app.use(express.json());
app.use('/', ShortenerRoute.getRouter());
request = supertest(app);
});
});

In the “beforeEach” block, we reimplement redirectUrl function of the Controller — Just finish a request. And, we have to create an express application instance, attach ShortenerRouter into the application, the test the router via app by suprertest .

RED: Property ‘redirectUrl’ does not exist on type ‘MockedObject<ShortenerController>’.

Red: Property ‘getRouter’ does not exist on type ‘ShortenerRoute’

The best practice is: Stop immediately when you got an error and fix it before continuing. But in this article, I will try to finish a completed part in a file (spec file) before going to another file.

To fix the RED code, just create redirectUrl function for the Controller, and getRouter function for the router.

class ShortenerController {
async redirectUrl(req: Request, res: Response) {
throw new Error('Not implemented yet!');
}
}
export default new ShortenerController();

That enough for Controller. At this time, don't care about the logic of redirectUrl function.

import { Router } from 'express';import UrlController from '../controllers/ShortenerController';class ShortenerRoute {
private router: Router;
constructor() {
this.router = Router();
this.setupRouter();
}
private setupRouter() { } getRouter() {
return this.router;
}
}
export default new ShortenerRoute();

Create a private property — router is an instance of an express router, we export this variable via getRouter function.

The first test case — We expect redirectUrl will be called with req object what includes shortId in its params object.

it('should call UrlController.redirectUrl function when GET /:shortId', async () => {
await request.get(`/${shortId}`);
expect(UrlControllerMock.redirectUrl)
.toHaveBeenCalledWith(
expect.objectContaining({
params: { shortId },
}),
expectedResponse,
);
});

We don't get any error on the IDE, time to run test script:

npm run test -- --watch

--watch flag helps us watch any change in the project and rerun the test script.

If everything work “well” you will get a result like this:

We expect the Controller function will be called, but Number of calls: 0 .

Fix it, register a handler for GET /:shortId request. Update setupRouter function of the router class

private setupRouter() {
this.router.get('/:shortId', async (req, res) => {
await UrlController.redirectUrl(req, res);
});
}

Save the file, the test result should be like this:

Do the same for the second route,

Test spec:

it('should call UrlController.shortenUrl function when POST /', async () => {
const body = { url: 'too-long-url' };
await request.post(`/`).send(body);expect(UrlControllerMock.shortenUrl)
.toHaveBeenCalledWith(
expect.objectContaining({
body,
}), expectedResponse,
);
});

Production code:

this.router.post('/', async (req, res) => {
await UrlController.shortenUrl(req, res);
});

We have done for ShortenerRouter class.

Controller

Requirements for this class:

  • Function redirectUrl
    - If shortId does not exist in the request, response with status 400, message shortId is not provide
    - If shortId does not exist in the DB, response with status 400, message shortId is invalid
    - Else, redirect the request to the destination url.
    - If something went wrong, response with status 500, message Some thing went wrong!
  • Function shortenUrl
    - If url does not exist in the request, response with status 400, message url is not provided
    - If url is not a valid url, response with status 400, message url is invalid
    - Else, if url already exists in the DB, response with status 200, body response includes url and existed shortId
    - Else, if url not exist in the DB, create a new url item, save it to the DB, and response with status 200, body response includes url and new shortId

To avoid a long article, I will push the completed spec file and production file.

src/controllers/ShortenerController.spec.ts

The production file:

src/controllers/ShortenerController.ts

Final test result:

When executing the test with --coverage flag:

npm run test -- --coverage

Apply the router for the express application

Because for now, we don't write the test for src/ApiApp.ts then can not use unit testing to detect a missing — We not use ShortenerRouter class yet.

The missing will be detected when we use a manual test. Now, let’s use it. Update setupRouters function of ApiApp class:

import router from './routers/ShortenerRoute';...
private setupRouters() {
this.application.get('/', (_, res) => {
res.json({ message: 'Welcome to our service!'});
});
this.application.use('/urls', router.getRouter());
}
...

That’s all!

Summary

Just update your production code when you are getting at least one “Red code”.

The completed code of this project has been published on Github.

Thank you for reading!

--

--