Simple NodeJS Project with TypeScript and Kubernetes / Part 3
In the previous part, we did create a simple scaffolding for developing applications with clean architecture. You can read that here.
In this part, we will add a web server, dependency injection, Postman collections, and also some simple tests
Express
We will install express framework to act as a web server and hold our logic inside. Why express? It’s super fast, minimal, and glorious in the community of JS.
Also, we are going to use tsyringe as a tool for Dependency Injection and service container management.
Let’s install the required packages:
npm install express tsyringe reflect-metadata
npm install --save-dev @types/express
We will create 2 controllers for our app. AppController and NoteController.
AppContoller will just simply return a simple JSON HTTP Response like the one below:
{
"message":"NoteApp is Up & Running",
"timestamp":"2023-04-23T14:57:21.723Z"
}
// src/infra/controllers/AppController.ts
import { type Response, type Request } from 'express'
export default class AppController {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async root (req: Request, res: Response) {
res.json({
message: 'NoteApp is Up & Running',
timestamp: new Date()
})
}
static instantiate (): AppController {
return new AppController()
}
}
NoteController will take care of 5 different routes to Create, Read, Update, and Delete notes (CRUD Operations).
// src/infra/controllers/NoteController.ts
import { type Response, type Request } from 'express'
import 'reflect-metadata'
import { container } from 'tsyringe'
import ListNotesUsecase from '../../core/usecase/ListNotesUsecase'
import GetNoteUsecase from '../../core/usecase/GetNoteUsecase'
import CreateNoteUsecase from '../../core/usecase/CreateNoteUsecase'
import UpdateNoteUsecase from '../../core/usecase/UpdateNoteUsecase'
import DeleteNoteUsecase from '../../core/usecase/DeleteNoteUsecase'
export default class NoteController {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async listNotes (req: Request, res: Response) {
const listNotesUsecase = container.resolve(ListNotesUsecase)
const notes = await listNotesUsecase.execute()
res.json(notes)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async showNote (req: Request, res: Response) {
const getNoteUsecase = container.resolve(GetNoteUsecase)
const id = req.params.id
const note = await getNoteUsecase.execute(id)
res.json(note)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async createNote (req: Request, res: Response) {
const createNoteUsecase = container.resolve(CreateNoteUsecase)
const { title, description } = req.body
const note = await createNoteUsecase.execute(title, description)
res.status(201)
res.json(note)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async updateNote (req: Request, res: Response) {
const updateNoteUsecase = container.resolve(UpdateNoteUsecase)
const id = req.params.id
const { title, description, archive } = req.body
const note = await updateNoteUsecase.execute(id, title, description, archive)
res.json(note)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
async deleteNote (req: Request, res: Response) {
const deleteNoteUsecase = container.resolve(DeleteNoteUsecase)
const id = req.params.id
await deleteNoteUsecase.execute(id)
res.json({ message: 'marked for deleation' })
}
static instantiate (): NoteController {
return new NoteController()
}
}
Now let’s bind the routes to the controller handlers:
// src/infra/routes/routes.ts
import express from 'express'
import AppController from '../controllers/AppController'
import NoteController from '../controllers/NoteController'
const routes = express.Router()
const appController = new AppController()
const noteController = new NoteController()
// eslint-disable-next-line @typescript-eslint/no-misused-promises
routes.get('/', appController.root)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
routes.get('/v1/notes', noteController.listNotes)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
routes.get('/v1/notes/:id', noteController.showNote)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
routes.post('/v1/notes', noteController.createNote)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
routes.put('/v1/notes/:id', noteController.updateNote)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
routes.delete('/v1/notes/:id', noteController.deleteNote)
export default routes
Cool! The last part of the puzzle would be to change the main entry of the app as below:
// src/app.ts
import * as dotenv from 'dotenv'
import express from 'express'
import routes from './infra/routes/routes'
import 'reflect-metadata'
import { container } from 'tsyringe'
import NoteRepositoryInMemory from './infra/repository/NoteRepositoryInMemory'
import NoteRepositoryMongoDB from './infra/repository/NoteRepositoryMongoDB'
import ListNotesUsecase from './core/usecase/ListNotesUsecase'
import GetNoteUsecase from './core/usecase/GetNoteUsecase'
import CreateNoteUsecase from './core/usecase/CreateNoteUsecase'
import UpdateNoteUsecase from './core/usecase/UpdateNoteUsecase'
import DeleteNoteUsecase from './core/usecase/DeleteNoteUsecase'
import type NoteRepository from './core/repository/NoteRepository'
dotenv.config()
const app = express()
const port = process.env?.APP_PORT ?? 3000
const storage = process.env?.APP_STORAGE ?? 'memory'
const noteRepository: NoteRepository = storage === 'memory'
? new NoteRepositoryInMemory()
: new NoteRepositoryMongoDB(process.env?.MONGODB_URL as string, process.env?.MONGODB_DBNAME as string)
container.register<ListNotesUsecase>(ListNotesUsecase, { useValue: new ListNotesUsecase(noteRepository) })
container.register<GetNoteUsecase>(GetNoteUsecase, { useValue: new GetNoteUsecase(noteRepository) })
container.register<CreateNoteUsecase>(CreateNoteUsecase, { useValue: new CreateNoteUsecase(noteRepository) })
container.register<UpdateNoteUsecase>(UpdateNoteUsecase, { useValue: new UpdateNoteUsecase(noteRepository) })
container.register<DeleteNoteUsecase>(DeleteNoteUsecase, { useValue: new DeleteNoteUsecase(noteRepository) })
app.use(express.json())
app.use(routes)
app.listen(port, () => {
console.log(`App is listening on port ${port}`)
})
Let’s run the app again:
npm run dev
The app is up and running, Now let’s create a postman collection for it.
Postman
Postman is an API Platform for developers to design, build, test, and iterate their APIs. I believe sharing Postman collections with the consumer of our API is really professional and makes everything easier. Intuitive sets of API examples make the developer’s life much easier! Imagine instead of scraping the website and trying to find how to work with an API just importing it in Postman and executing it. I’ll always share that with my clients because I love the moment they ask me how to send requests 😁, at that moment I’ll put them in the right direction and tell them to read the README and check the Postman collection. It’s super easy for them to understand and follow.
Let’s start by creating a new environment. In Postman you can define multiple environments and then switch between them. By that, you can run the requests with different parameters easier than changing them before sending them every time. Let’s imagine you are going to work on four different environments (local, testing, staging, and production). That would be so tedious to change the base_url or other parameters every time. So we are going to create our first for local. Just hit the (+) button and name it “Local”. Then add your first variable as below
Now create a new collection, I used “NoteApp”
OK, we are ready to create our first request now
Now set “Local” as your active environment, Choose “GET” method, and enter the {{url}} in the address bar. If you noticed we defined the url before in the environment and we can use them by wrapping them inside {{ and }}
Enter these and push Send button
Hooray! You can see the body and 200 OK as a successful HTTP status code.
You can save this request as below:
- Ctrl + S (On Windows)
- Cmd + S (On Mac)
Listing all Notes
Show a note
Let’s introduce a new variable as below:
I put the ID of my first note, for you it’s different, you can get it from the 1st note in the previous step.
Create a new Note
One of the great features of Postman which I love the most is that you can add scripts for the response to requests. In the example below I created a new request for creating a new Note and then add a test for that as below:
So if the status is Created and the response code is 201 then it will store the id of our new Note in the environment variable. With this, we can easily do things like executing the previous request without touching the environment variable. It will update that value for us and the flow would be much smoother and simpler.
Let’s send the request
Now if we check the environment variables we can see 2 different IDs. One for initialization and one which has been updated by running tests:
Now let’s send the Show request again:
It shows our created Note without touching env, Isn’t that awesome?
Updating a note
It’s easy, just like below we would pass the changeset:
Deleting a note
We will add a simple test to clear the value of the id in the environment because it’s not valid anymore.
We are finished here now.
Tests
Our app is awesome now, I love it but there is a huge change I introduce a bug or an error while adding a new feature. It’s important to adopt tests. Let’s do that
I’ll use jest because it is really good with any type of test scenario.
npm install jest --save-dev
npm install --save-dev @types/jest
Also, it would be good to add this command to your package.json file
{
...
"scripts": {
...
"test": "cross-env NODE_ENV=test jest",
"test:watch": "cross-env NODE_ENV=test jest --silent --verbose --watchAll",
...
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
}
}
Before going further, I like to add another tool that helps us create random names, emails, addresses, and numbers to enrich our tests. I’ll use faker.js
npm install @faker-js/faker --save-dev
Let’s create different test files:
// test/unit/note.test.ts
import Note from '../../src/core/entity/Note'
import { faker } from '@faker-js/faker';
describe('Note Entity Test', () => {
it('must constrcut a note correctly', () => {
const id = faker.datatype.uuid()
const title = faker.lorem.word()
const description = faker.lorem.sentence()
const note = new Note(id, title, description)
expect(note).toBeTruthy()
expect(note.id).toBe(id)
expect(note.title).toBe(title)
expect(note.description).toBe(description)
expect(note.archive).toBeFalsy()
})
})
// test/unit/in-memory.test.ts
import NoteRepositoryInMemory from '../../src/infra/repository/NoteRepositoryInMemory'
import { faker } from '@faker-js/faker';
const repository = new NoteRepositoryInMemory()
describe('Note Collection Test', () => {
it('must initialize with 1 item', async () => {
const notes = await repository.findAll()
expect(notes).toBeTruthy()
expect(notes.length).toBe(1)
expect(notes[0].title).toBe('Hello World!')
expect(notes[0].description).toBe('This is a simple task')
});
it('must create a new item', async () => {
const title = faker.lorem.word()
const description = faker.lorem.sentence()
await repository.create(title, description)
const notes = await repository.findAll()
expect(notes).toBeTruthy()
expect(notes.length).toBe(2)
expect(notes[0].title).toBe('Hello World!')
expect(notes[0].description).toBe('This is a simple task')
expect(notes[1].title).toBe(title)
expect(notes[1].description).toBe(description)
});
it('must create update an existing item', async () => {
const notes = await repository.findAll()
const lastId = notes[notes.length - 1].id
const title = faker.lorem.word()
const description = faker.lorem.sentence()
await repository.update(lastId, {title, description})
const result = await repository.findAll()
expect(result).toBeTruthy()
expect(result.length).toBe(2)
expect(result[0].title).toBe('Hello World!')
expect(result[0].description).toBe('This is a simple task')
expect(result[1].title).toBe(title)
expect(result[1].description).toBe(description)
});
it('must delete an existing item', async () => {
const notes = await repository.findAll()
const lastId = notes[notes.length - 1].id
await repository.delete(lastId)
const result = await repository.findAll()
expect(result).toBeTruthy()
expect(result.length).toBe(1)
expect(result[0].title).toBe('Hello World!')
expect(result[0].description).toBe('This is a simple task')
});
})
I’ve added 2 unit tests but I want to make sure my users also do not face any problems, so let’s add some acceptance test
For this we need to add 2 packages and so some extra stuff to get there.
We need supertest to allow us to make HTTP requests against our web server (in this context expressjs), Also we need cross-env which allows us to pass the env variable in the command line when running a script.
For installing the packages please run the below command in your terminal:
npm install jest supertest cross-env
npm install - save-dev @types/supertest
Let’s do some changes to our entry point:
We changed 2 things:
- If the NODE_ENV equals to test then we always test via memory.
- We export the app to be used in our test as a package
Let’s add the last test file:
// test/acceptance/rest.test.ts
import app from '../../src/app';
import supertest from 'supertest'
import { faker } from '@faker-js/faker';
let id : string;
describe('Acceptance REST API Test', () => {
it('HTTP GET /', async () => {
const res = await supertest(app).get("/");
expect(res.statusCode).toBe(200);
expect(res.body).toBeTruthy()
expect(res.body.message).toBe('NoteApp is Up & Running')
expect(res.body.timestamp).toBeTruthy()
});
it('HTTP GET /v1/notes', async () => {
const res = await supertest(app).get("/v1/notes");
expect(res.statusCode).toBe(200);
expect(res.body).toBeTruthy()
expect(res.body).toMatchObject([{
"title": "Hello World!",
"description": "This is a simple task",
"archive": false,
}])
id = res.body[0].id
expect(id).not.toBeNull()
});
it('HTTP GET /v1/notes/{id}', async () => {
const res = await supertest(app).get(`/v1/notes/${id}`);
expect(res.statusCode).toBe(200);
expect(res.body).toBeTruthy()
expect(res.body).toMatchObject({
"id": id,
"title": "Hello World!",
"description": "This is a simple task",
"archive": false,
})
});
it('HTTP POST /v1/notes/{id}', async () => {
const title = faker.lorem.word()
const description = faker.lorem.sentence()
const res = await supertest(app).post(`/v1/notes`).send({title, description});
expect(res.statusCode).toBe(201);
expect(res.body).toBeTruthy()
expect(res.body).toMatchObject({
"title": title,
"description": description,
"archive": false,
})
id = res.body.id
expect(id).not.toBeNull()
});
it('HTTP PUT /v1/notes/{id}', async () => {
const title = faker.lorem.word()
const description = faker.lorem.sentence()
const res = await supertest(app).put(`/v1/notes/${id}`).send({title, description, archive: true});
expect(res.statusCode).toBe(200);
expect(res.body).toBeTruthy()
expect(res.body).toMatchObject({
"title": title,
"description": description,
"archive": true,
})
expect(res.body.id).not.toBeNull()
});
it('HTTP DELETE /v1/notes/{id}', async () => {
const res = await supertest(app).delete(`/v1/notes/${id}`);
expect(res.statusCode).toBe(200);
expect(res.body).toBeTruthy()
expect(res.body).toMatchObject({
"message": 'marked for deleation',
})
});
})
Now we can run the test
npm run test:watch
I’m in love with what we can see now….
Congratulation! You came this far and made me happy by reading this article. It motivates me so much for the next part. I’ll promise to write ASAP.
Kind regards