Test-Driven Development + Continuous Integration using NodeJS and GitHub Hooks

suhas murthy
Level Up Coding
Published in
8 min readJul 5, 2020

--

Why test-driven development or unit testing?

  1. Test-driven development helps you reduce bugs as we predefine the test cases
  2. It helps you in code maintainability. Say a new developer comes and changes the code without having the product knowledge. Our test cases will fail which would help the new developer know where he went wrong
  3. Additional to above point these test cases can act as documentation and help the new developer understand the code better
  4. Test-driven development is adopted by agile software developers as it helps you with quick releases by using Continuers integration & Continues deployment (CI/CD)

In this article, let’s build a simple sign up service with Node using TTD. I will be using the same initial set up which I had earlier used in one of my articles to set up NodeJS using Kubernetes. If you are interested, please check out the article here. GitHub link for initial set up with express and typescript can be found here. (Please ignore Docker and Kubernetes set up if you are not familiar with it as we won't be using it here).

Let's say we have a new user story where our product/customer wants to have a simple sign up page.

Typically as developers, we get requirements from our product owners and we start writing code but here we need to convert this user story to test cases. For this story, let's say these will be my test cases and all platforms should follow the same. By doing this we also ensure that its uniform across all platforms

  1. The first name should be mandatory
  2. An email has to be valid
  3. Password length has to a minimum of 6 characters
  4. Email Id and Password fields should be mandatory
  5. Password and Confirm password has to be same
  6. Duplicate emails should not be allowed
  7. Should allow to create a user with proper first name, last name email, password and confirm password
  8. Should allow creating a user without last name.

Now that we have our requirements ready let's build our application.

Install all the dependencies required and set up a test environment

In this application, we will use supertest for testing HTTP

npm install supertest @types/supertest --save-dev

Jest for JavaScript testing

npm install jest @types/jest --save-dev

mongodb-memory-server for mocking MongoDB for testing

npm i mongodb-memory-server --save-dev

And lastly, ts-jest to allow jest test project built on TypeScript

After installing these dependencies we need to change the test script as follows in package.json

"scripts": {"start": "ts-node-dev src/index.ts","test": "jest --watchAll --no-cache"},"jest": {"preset": "ts-jest","testEnvironment": "node","setupFilesAfterEnv": ["./src/test/setup.ts"]},

Above we are basically telling jest to use ts-jest as we are building this app using TypeScript and providing a path to run the code to configure or set up the testing framework before each test file in the suite is executed.

Let's create setup file by creating a folder with name test under src directory and create a file setup.ts

In Setup, we will set 3 helper functions

  1. beforeAll => Connecting to Mongo memory server
  2. beforeEach => Get and delete all collections
  3. afterAll => After all the testing stop Mongo server and close the connection

This concludes the set up required to write our test cases for a simple signup route

Like I mentioned earlier we would require our code to pass 8 test cases to meet our product owner/Customer requirement

Create a __test__ folder under routes. This naming convention will inform jest to pick all the files inside this folder when we run the test script.

Inside this folder create a file by name signup.test.ts and define the 8 test cases required.

it("Should not allow to create user without first name", () => {});it("Should not allow to create user with invalid email", () => {});it("Should not allow to create user with invalid password. Has to be minimum of 6", () => {});it("Should not allow to create user without email id and password", () => {});it("Should not allow to create user without same password and confirm password", () => {});it("Should not allow to create user with same email", () => {});it("Should allow to create user with proper first name, last name  email, password and confirm password", () => {});it("Should allow to create user without last name", () => {});

Let's fill these test cases by importing super test and app.ts.

So for the first test case if the body doesn't have a valid first name we should not allow the user to sign up but instead throw error 400. So our test cases would be like this.

it("Should not allow to create user without first name", async () => {return request(app).post("/api/users/signup").send({lastName: "Abc",email: "abc@example.com",password: "12345678",confirmPassword: "12345678",}).expect(400);});

Similarly, let's fill all our test cases which is pretty straight forward.

Now when we run the test script all test cases would fail and we see something similar to this on our console.

Now let's change our code to pass each of these test cases

Pass the test cases

Let's change our code to pass these test cases

Test case number one says first name cant be invalid. To check this condition we will be using express-validator

npm install express-validator

Now let's include the express-validator body as middleware to our route and check if the first name exists and check for errors. If errors are not empty throw a 400 status error code

import express, { Request, Response } from "express";import { User } from "../models/user";import express, { Request, Response } from "express";import { User } from "../models/user";import { body, validationResult } from "express-validator";const router = express.Router();router.post("/api/users/signup",[body("firstName").trim().not().isEmpty().withMessage("First Name is required")],async (req: Request, res: Response) => {const errors = validationResult(req);if (!errors.isEmpty()) {return res.status(400).send(errors.array());}res.send({})});export { router as SignupRoute };

Now if we run the test script our first test case will pass

If you observe four test cases are pretty similar and have to do with validations. To keep the article short I summarize all the validation into one code. By doing this our code would end up like this

The next three test cases have to do with MongoDB. To pass these test cases lets create a user model under a model folder

import mongoose from "mongoose";const userSchema = new mongoose.Schema({firstName: { type: String, required: true },lastName: { type: String, required: false },email: { type: String, required: true },password: { type: String, required: true },});const User = mongoose.model("User", userSchema);export { User };

So the next test case is to make sure there is no duplicate email id entered. To pass this let's add validation to our route

const { firstName, lastName, email, password } = req.body;const existingUser = await User.findOne({ email });if (existingUser) {return res.status(400).send({ error: "Email already exists" });}

If we run our test cases 6 of our test cases will pass. The next two test cases are happy test cases where we allow the creation of users. By adding this all our test cases will pass and our code would end up like this

The entire code section for this can be found here. This concludes test-driven development

GitHub Action hooks and CI test scripts

What's the importance of the CI test script?

Usually, we maintain our production code in the master branch and create a separate branch for development. So when we want to merge the changes to the master branch for a production release we need to have a validation layer that should run test scripts in our project and tell us if all our test cases pass and we do not end up adding bugs to our application. To do this we can use CI test scripts in Github action hook

Prerequisite: To create GitHub action push your code to GitHub and create another branch say dev.

Let's go to GitHub and create action

Click on set up this workforce

Rename the file to tests.yaml and delete everything inside the editor

Clear everything inside Edit new file

Add this code inside the editor

In this Yaml file, we define when we want to run the test script ie during a pull request and also passing build instructions and click on start commit to commit a file

Before making a pull request we need to make a small change in package.json and add because the existing test script will not come out of the job we are adding a new script explicitly for GitHub

"test:ci": "jest"

Now let's create a pull request from dev (In my case its add-test-signup-route)to master branch and test if this works.

Click on Pull request and click on New pull request
Select from the branch you would like to merge to master and hit create pull request

As soon as we click on Pull request we can see our script running in Github

And if you click on show all check and click on details we can find the details

Now we can confidently click on a Merge pull request to merge the code to our master branch.

This concludes CI using GitHub hooks.

Hope you enjoyed this article on test-driven development and CI using GitHub.

Happy Coding!!! Cheers !!!

--

--