TDD with Typescript and Jest: Url shortener
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 passreq, res
toController.redirectUrl
. - Register
POST /
to the router, calls, and passreq, res
toController.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
- IfshortId
does not exist in the request, response with status 400, messageshortId is not provide
- IfshortId
does not exist in the DB, response with status 400, messageshortId is invalid
- Else, redirect the request to the destination url.
- If something went wrong, response with status 500, messageSome thing went wrong!
- Function
shortenUrl
- Ifurl
does not exist in the request, response with status 400, messageurl is not provided
- Ifurl
is not a valid url, response with status 400, messageurl is invalid
- Else, ifurl
already exists in the DB, response with status 200, body response includesurl
and existedshortId
- Else, ifurl
not exist in the DB, create a new url item, save it to the DB, and response with status 200, body response includesurl
and newshortId
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!