Testing a Node RESTful API part I

DevelopWithAP
8 min readJul 31, 2022

Though not a professional developer yet, over the past few months I’ve had the good fortune of striking off connections with people in the business who were willing enough to answer all of my stupid questions. Inevitably, testing came up. The course I was doing at the time had gone to great lengths to instil the importance of writing tests, making it a requirement for ticking off the project. And I must admit, writing test stumped me for a long time. Longer than I care to admit. When I put the question to my connections the response I got was pretty much along the lines of: I know I should be shipping it with tests, but developing is very time consuming and I can get away with not writing them. So, I just don’t.

While a sample of 2 is definitely not representative of the professional developer population, I’ll admit the answer shocked me. I’m aware that most companies have got dedicated teams making sure their product behaves according to requirements. I assume that these wizards have got an aptitude for making things break, which is something I honestly admire and which makes them an indispensable part of the team. But the question stayed with me: Should I test every single functionality of the feature I develop?

Since my instinctive answer to this question is yes, this article will discuss how to write tests for a RESTful API. Before we begin I’ll caveat the rest with a warning:

This is not indicative of how you’d perform testing in a production environment! If you have to write tests and don’t know how to, speak to more senior devs!

The application featured in this article is a RESTful API that supports all CRUD operations. It uses TypeScript for type safety as well as JWT for secure transfer of information. Source code can be found at https://www.github.com/DevelopWithAP/storefront-api.

Set-up

The project uses a Postgres database and the dotenv package from npm for managing environment variables that hold sensitive connection information while an express.js server is handling all our requests. The testing framework of choice is Jasmine which is suited to both BDD and TDD. In the package.json file we find the script that will perform all of the tests. Let’s have a look:

...
"scripts": {
...
"test": "ENV=test db-migrate --env test up && jasmine-ts && db-migrate db:drop test",
...
},
...

In short running npm run test will switch to the test environment, perform all the necessary database migrations, actually test the code with Jasmine , and destroy the newly created test database using the db-migrate command db:drop .

Models

This article focuses on the User model which represents the users who can use the app. The user.ts file could look something like this:

import client from '../database';
import bcrypt from 'bcrypt';

const saltRounds: string = process.env["SALT_ROUNDS"] as string;
const bcryptPassword: string = process.env["BCRYPT_PASSWORD"] as string;

export type User = {
id?: number;
first_name: string;
last_name: string;
password: string;
}

export class UserStore {
async index(): Promise<User[]> {
try {
const conn = await client.connect();
const sql = 'SELECT * FROM users';
const result = await conn.query(sql);
conn.release();
return result.rows;
} catch (err) {
throw new Error(`Could not get users. Error: ${err}`);
}
}

async show(id: string): Promise<User> {
try {
const conn = await client.connect();
const sql = 'SELECT * FROM users WHERE id=($1)';
const result = await conn.query(sql, [id]);
conn.release();
return result.rows[0];
} catch (err) {
throw new Error(`Could not find user ${id}. Error: ${err}`);
}
}

async create(user: User): Promise<User> {
try {
const conn = await client.connect();
const sql = 'INSERT INTO users (first_name, last_name, password) VALUES($1, $2, $3) RETURNING *';
const hash = bcrypt.hashSync(user.password + bcryptPassword, parseInt(saltRounds));
const result = await conn.query(sql, [user.first_name, user.last_name, hash]);
conn.release();
return result.rows[0];
} catch (error) {
throw new Error(`Unable to create new user ${user.first_name}. Error: ${error}`);
}
}

async update(id: string, user: User): Promise<User> {
try {
const conn = await client.connect();
const sql = 'UPDATE users SET first_name = ($1), last_name = ($2), password = ($3) WHERE id=($4) RETURNING *';
const result = await conn.query(sql, [user.first_name, user.last_name, user.password, id]);
conn.release();
return result.rows[0];
} catch (error) {
throw new Error(`Unable to update user ${user.id}. Error: ${error}`)
}
}

async remove(id: string): Promise<User> {
try {
const conn = await client.connect();
const sql = 'DELETE FROM users WHERE id=($1) RETURNING *';
const result = await conn.query(sql, [id]);
conn.release();
return result.rows[0];
} catch (error) {
throw new Error(`Could not delete user ${id}. Error: ${error}`);
}
}

async authenticate(first_name: string, last_name: string, password: string): Promise<User | null> {
try {
const conn = await client.connect();
const sql =
"SELECT * FROM users WHERE first_name=($1) AND last_name=($2)";
const result = await conn.query(sql, [first_name, last_name]);

if (result.rows.length) {
const user = result.rows[0];

if (bcrypt.compareSync(password + bcryptPassword, user.password)) {
return user;
}
}

return null;
} catch (err) {
throw new Error(`Cannot authenticate user ${last_name}, ${first_name}. Error: ${err}.`);
}
}
}

The first import statement takes care of the database connection settings, while the second one deals with password encryption (more on that later).
saltRounds and bcryptPassword environment variables are required by npm ‘s bcrypt module.

The export statement takes care of the User model definition. The UserStore class is where the action happens. All methods follow the same pattern: After establishing a connection with our database, we execute an SQL query to interact with our database and store the result of the interaction in our result variable. Finally, the catch statement takes care of error handling. The authenticate method of our class follows the same pattern with the addition of a synchronous password comparison between the password provided by the user with the hashed password stored in the database. The process is pretty standard and rather straightforward. Now, on to the actual testing.

Tests

Let’s navigate to the tests directory of our project and use the almighty tree Unix command to understand the structure.

tree .
.
├── handlers
│ ├── orderSpecs.ts
│ ├── productSpecs.ts
│ └── userSpecs.ts
├── helpers
│ └── reporter.ts
└── models
├── orderSpecs.ts
├── productSpecs.ts
└── userSpecs.ts
3 directories, 7 files

Firstly, the reporter.ts comes shipped with Jasmine and in simple cases you won’t have to touch it. By convention, a file with ...Specs.ts is a test file. Therefore, there are tests for our models and handlers alike. We’ll start with the models.

We find these lines at the top of the file:

import { UserStore, User } from '../../models/user';const store = new UserStore();const testPassword: string = process.env.POSTGRES_PASSWORD_TEST as string;

First, we import all the necessary information about the class we’ll be testing, then we instantiate a new user class and finally we pull our test password variable from our .env file.

describe('User Model', () => {  const createUser = async (user:User) => {    return store.create(user);  };  const deleteUser = async (id:string) => {    return store.remove(id);
};
it('should have an index method', () => { expect(store.index).toBeTruthy(); });
it('should have a show method', () => { expect(store.show).toBeTruthy(); });
it('should have a create method', () => { expect(store.create).toBeTruthy(); }); it('should have a remove method', () => { expect(store.remove).toBeTruthy(); }); it('should have an update method', () => { expect(store.update).toBeTruthy(); }); it('should have an authenticate method', () => {
expect(store.authenticate).toBeTruthy();
});
...}

In the describe function we group all related tests. In this case we infer we we’ll be writing specs a.k.a tests for our User model. The two functions at the top are wrapper functions to be used later when we’re interacting with our DB and for now you can ignore them. They help prevent potential problems stemming from the fact that we’re writing a lot of asynchronous code. The it functions are the actual tests to be performed. Specifically, we go through all of the methods of our User class one by one. For now, all we want to know is that all of the methods exist. But how? Let’s look at what the .toBeTruthy() method actually does. Here’s the definition:

function toBeTruthy() {
return {
compare: function(actual) {
return {
pass: !!actual
};
}
};
}

According to the MDN Web Docs Glossary:

a value is truthy if when examined in a Boolean context can be considered true

In other words, if the value in question got type-casted to boolean and evaluated to true it would be truthy . The operation !! tests for truthiness by coercing the value passed to expect to a boolean.

Phew, that was a lot. Time to run our tests. From the root folder of our project we can run npm run test or yarn test . Let’s check out terminal output:

We can breathe a sigh of relief since our model passes all the tests…for now.

Remember the two wrapper functions we defined at the very beginning? Let’s use them to interact with our database.

We need to add a mock user right underneath the describe statement:

userSpecs.ts

...
const user: User = {
first_name: 'Store0',
last_name: 'Dev0',
password: testPassword,
};
...

Simply put the user object here will mimic the information sent to our database.

Also, I have to admit I won’t be up for any creativity awards for the user name…

The rest of our file looks like this:

...it('create method should add a new user', async () => {  const response = await createUser(user);  expect(response.first_name).toBe(user.first_name);  expect(response.last_name).toBe(user.last_name);

await deleteUser(String(response.id));
});it('index method should return a list of users', async () => {
const createdUser: User = await createUser(user);
const response = await store.index(); expect(response.length).toBeGreaterThan(0); await deleteUser(String(createdUser.id));});it('show method should retrieve the correct user', async () => {
const newUser: User = await createUser(user);
const response = await store.show(String(newUser.id)); expect(response.first_name).toBe(newUser.first_name); expect(response.last_name).toBe(newUser.last_name); await deleteUser(String(newUser.id));});it('update method should update user information', async () => { const userToUpdate: User = await createUser(user); const updateDetails : User = { first_name: 'Store1', last_name: 'Dev1', password: testPassword}; const response = await store.update(String(userToUpdate.id), updateDetails); expect(response.first_name).toEqual(updateDetails.first_name); expect(response.last_name).toEqual(updateDetails.last_name); await deleteUser(String(userToUpdate.id));});it('remove method should remove the user', async () => { const newUser: User = await createUser(user); const response = await store.remove(String(newUser.id)); expect(response.id).toBe(newUser.id);});it('authenticate method should authenticate the user', async () => { const userToAuthenticate: User = await createUser(user); const response = await store.authenticate('Store0', 'Dev0', testPassword); expect(response?.first_name).toEqual('Store0'); expect(response?.last_name).toEqual('Dev0');});

Let’s have a look at the the test that checks for the creation of a new user:

it('create method should add a new user', async () => {const response = await createUser(user); // add user to the DBexpect(response.first_name).toBe(user.first_name); // check 'first_name' fieldexpect(response.last_name).toBe(user.last_name); // check 'last_name' field

await deleteUser(String(response.id)); // delete newly created user from DB as it is no longer needed.
});

The rest of the tests perform similar actions with the variable being the modification — if any, for example we do not modify the state of our DB if we’re simply reading from it — to the DB. To make sure we’ve not lost it so far:

Another sigh of relief is in order here. Our code can successfully Create, Read, Update and Delete from our database as well as use our authentication middleware.

That is all for now. The next article will discuss endpoint testing.

See you soon and keep on coding!

--

--

DevelopWithAP

Transitioning from continuous to discrete mathematics, thinking algorithmically and programming (very poorly, for now). Interested in low-level programming.