How TDD(Test-Driven Development) improve REST API code quality?

Shuwen
4 min readJan 19, 2023

--

If you ask me what I would do better in my next project, I would say I will start by writing first line of test code before jumping into development. In reflection on my past project experience, I find that TDD(Test-Driven Development) can make a big difference in code quality, shorten the development time, and instill greater confidence in the outcome.

For example, to ensure that code is of high quality, it is essential to conduct thorough testing and address any vulnerabilities that are identified. In implementing automated testing, it may be discovered that certain tests cannot be performed due to untestable code. To overcome this issue, it is recommended to design software using principles established in the SOLID software design framework before applying the TDD.

Software design following principles

Functional Layers

Take REST api using NodeJS and Express as an example. Create the following layers when designing the application.

  • Route: it maps the application’s endpoints(API) to internal HTTP function.
  • Controller: it handles the API request and sends back the response.
  • Service: it takes the responsibility of the business logic and manipulates data.
  • Model: it defines the data structure.

Functional code

After desiging the application by different layers, the code should be testable from the top level. In service layer, more functions should be created to handle the business requirement, such as getAccount, getProduct, addProduct, deleteProduct, etc. The following tests will be implemented on these functions.

Implement automated test

Basically, there are two types tests: unit test and integration test.

  • unit test: test one independant function code in one module, such as isValidatedEmail utility function.
  • integration test: test will cover more than one module, such as testing endpoint (/products).

Unit test

Create real test cases and test the function. For example (Jest):

import { isPositiveNumeric } from "../../src/utils/is-numeric";

test("is positive number", () => {
expect(isPositiveNumeric("abc")).toBe(false);
expect(isPositiveNumeric("a12")).toBe(false);
expect(isPositiveNumeric("12a")).toBe(false);
expect(isPositiveNumeric("-10")).toBe(false);
expect(isPositiveNumeric("0")).toBe(false);
expect(isPositiveNumeric("12")).toBe(true);
});

Integration test

There are two senarios: manipulating data from database, and manipulating data from third-party APIs. In each senario, mock data should be used to do the test instead of testing the real database or third-party API data.

Basically, there are two types test cases: happy path, and exception path.

Happy path

The mock file should be the happy path by default. For example (Jest):

in mock test file:

export const mockGetProducts = jest.fn(() => {
return Promise.resolve({
data: {
products: [
{
title: "title",
},
],
},
});
});

const mock = jest.fn().mockImplementation(() => {
return {
getProducts: mockGetProducts,
};
});
export default mock;

in test file, add jes.mock before the test cases:

jest.mock("../../src/services/product-service");

describe("GET /products", () => {
it("get all products", async () => {
const res = await request(app).get("/v1/products");
expect(res.status).toBe(200);
expect(res.body.products).toBeDefined();
});
});

Exception path

To test exception path, one method is to add the mock test data in each test case instead of passing parameters to mock test in mock file. The reason is that jest.mock are hoisted to the top of the file, Jest prevents access to out-of-scope variables.

For example:

describe("GET /products", () => {
it("get all products", async () => {
const res = await request(app).get("/v1/products");
expect(res.status).toBe(200);
expect(res.body.products).toBeDefined();
});

it("500 error", async () => {
const mock = jest.spyOn(productService(), "getProducts");
mock.mockImplementationOnce(() => {
throw new Error("500 error test");
});
const res = await request(app).get("/v1/products");
expect(res.status).toBe(500);
});
});

you can create customized error by extending Error class in NodeJS:

export class FirebaseError extends Error {
errorInfo?: {
code?: string;
};
}

let firebaseErrorNotFound = new FirebaseError("No firebase user found");
firebaseErrorNotFound.errorInfo = { code: "auth/user-not-found" };
export firebaseErrorNotFound;

in test case:

it("No firebase user found", async () => {
const mock = jest.spyOn(firebaseAdminAccountService(), "getAccountByEmail");
mock.mockImplementationOnce(() => {
throw firebaseErrorNotFound;
});

const res = await request(app).put("/v1/customers").send(userCreated);
expect(res.status).toBe(404);
});

How to mock Firebase data?

Firstly, create one mock document snap data:

const test = require("firebase-functions-test")();
export const snap = test.firestore.exampleDocumentSnapshot();

in test file, using snap mock data:

it("no firebase user info found", async () => {
const snapUndefined = {
data: function () {
return undefined;
},
exists: false,
id: snap.id,
ref: snap.ref,
readTime: snap.readTime,
get: snap.get,
isEqual: snap.isEqual,
};

const mockGetAccount = jest.spyOn(
firebaseAdminAccountService(),
"getAccount"
);
mockGetAccount.mockImplementation(() => {
return Promise.resolve(snapUndefined);
});
const res = await request(app).get(
`/v1/customer/verify-email?email=${email}&token=${token}`
);
expect(res.status).toBe(400);
});

You can find more information from the offical Firebase website: https://firebase.google.com/docs/rules/unit-tests

Analyze test report

There are four coverages: Statement, Branch, Functions, and Lines. Read more from this reference: https://krishankantsinghal.medium.com/how-to-read-test-coverage-report-generated-using-jest-c2d1cb70da8b

We should have all business logic 100% covered, as the following example (Util folder is for Unit Test, and Controller folder is for Integration Test)

TDD is more than quality

TDD goes beyond just ensuring code quality, as it can also lead to increased productivity, shorten time and higher confidence in the development process. By writing tests before writing the actual code, we can catch and fix potential problems early on, and ensure that our code works as expected. This means we can avoid spending time on debugging and fixing issues later on, and focus on developing new features instead. Plus, having a good set of tests can make it easier for us and our team to maintain the codebase over time.

Photo by matthew Feeney on Unsplash

--

--