Test-Driven Development + Continuous Integration using NodeJS and GitHub Hooks
Why test-driven development or unit testing?
- Test-driven development helps you reduce bugs as we predefine the test cases
- 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
- Additional to above point these test cases can act as documentation and help the new developer understand the code better
- 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
- The first name should be mandatory
- An email has to be valid
- Password length has to a minimum of 6 characters
- Email Id and Password fields should be mandatory
- Password and Confirm password has to be same
- Duplicate emails should not be allowed
- Should allow to create a user with proper first name, last name email, password and confirm password
- 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
- beforeAll => Connecting to Mongo memory server
- beforeEach => Get and delete all collections
- 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
Rename the file to tests.yaml and delete everything inside the editor
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.
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 !!!