read24 - Server: Automated Testing In-Memory Database

August 13, 2020

Roger Ngo

Today is a big day. This is the day where the read24 project becomes more “legitimate” 😂 Why? Because, TESTS!

To be honest, although the pages, and API seem to be working so far when using the in-memory database, I am personally not entirely confident about certain things working as intended.

One of my doubts is selecting elements from the database through complex filters. I had a hard time implementing this, and I need to make sure that there are no bugs. Another is deletion. I need to make sure that deleting elements actually remove the entry entirely from the database. Assurance that all of these things are working will prove for a better development experience with less hair being pulled later on!

So, what do I need to get started?

Well, the server project definitely needs a testing framework! Let’s install a popular one… jest.

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

But I am not done just yet, in order to be able to write the tests in TypeScript, ts-jest preset will also need to be installed, and configured.

npm install --save-dev ts-jest

In package.json, two new commands can be introduced:

"test": "jest --detectOpenHandles --runInBand --coverage --reporters=\"default\"",
"build:test": "npm run build && npm run test"

npm test will run jest and any test files written.‌

Here are the meaning of the switches, and why I choose to use them:‌

https://jestjs.io/docs/en/cli

Switch Information Usage
detectOpenHandles Print open handles which are preventing Jest from exiting cleanly This is good to have for when I may have open TCP connections which may need to be cleanly disconnected.
runInBand Run all tests serially in the current process, rather than create a worker pool of child processes that run tests Very important for my needs. The in-memory database must persist through all tests, and must be consistent across them. If I don’t specify runInBand, then each test will run in a new process which means running a new instance of the in-memory db. Thus running into situations where data is missing, or is inconsistent.
coverage Specifies to college coverage information Collect coverage
reporters Run tests with specified reporters Use the default reporter for coverage and output the HTML files to coverage

In package.json, configure jest with the following:

"jest": {
  "preset": "ts-jest",
  "setupFiles": ["./src/setupTests.ts"],
  "testPathIgnorePatterns": ["./node_modules/", "./dist/"]
}

I am choosing to configure jest to use the ts-jest preset where TypeScript files will be built to JS. Additionally, setupFiles contains all files which I want to be run before the tests. This will contain all code to configure the database, and seed data for it. testPathignorePatterns contains all the folders which jest should ignore.

More refactoring, and setup is now needed on the project before I start writing any test. The db_connector.ts interface needs to be updated to include more code.

Currently only MemoryDb has the dump, and initialize methods. It is useful for the MemoryDb to have, but for MySqlDb, it is not implemented. But in order to avoid any unnecessary casting when using DatabaseConnector, I will specify the dump, and initialize methods as abstract methods which must be implemented:

public abstract dump(): void;
public abstract initialize(db: any): void;

Then in MySqlDb, the implementation are stubs:

public dump() {
    return;
}

public initialize(db: any) {
    return;
}

To make configuration management easier, I’ll move away all the logic to set the runtime variables to its own file, config.ts.

The configuration will be stored in global.RuntimeConfiguration which will only be initialized once when the server process initializes. In the case of tests, this will be in the setupTests file.

import * as dotenv from 'dotenv';

const config = dotenv.config();
console.log('Loaded env', config.parsed);

if (!(global as any).RuntimeConfiguration) {
    console.log('Configuration not initialized, initializing now');
    
    (global as any).RuntimeConfiguration = {
        port: process.env.PORT || 5000,
        environment: 'development',
        data_source: 'mysql'
    };    
}

export default (global as any).RuntimeConfiguration;

connector.ts needs to be updated to now initialize either MemoryDb, or MySqlDb into global.DatabaseConnector. Then the current instance of the connector can be accessed through global.DatabaseConnector. I assign this global variable to DatabaseConnector, which can then be imported anywhere within the codebase.

import * as RuntimeConfig from '../config';
const Config = RuntimeConfig.default;

import { MemoryDb } from './memory';
import { MySqlDb } from './sql';
import { DbConnector } from './db_connector';

if(!(global as any).DatabaseConnector) {
    if(Config.data_source === 'memory') {
        (global as any).DatabaseConnector = new MemoryDb();
    } else {
        (global as any).DatabaseConnector = new MySqlDb();
    }
} 
export const DatabaseConnector = (global as any).DatabaseConnector as DbConnector;

Next, let’s create setupTests to seed some data for the tests. One thing I need to call out is that since index.ts isn’t run for testing, setupTests is where configuration variables should be assigned.

The following code will just add 2 classrooms to the database.

import * as RuntimeConfig from './config';
const Config = RuntimeConfig.default;

Config.data_source = 'memory';
Config.environment = 'test';

import { Classroom } from './models/classroom';

export default async function setup() {
    // Add a sample classroom
    await new Classroom({
        name: 'Room 10',
        slug: 'room10'
    }).insert();
    
    await new Classroom({
        name: 'Room 5',
        slug: 'room5'
    }).insert();
}


setup();

Whew! I made it this far, and now it is time for the main event! Writing some unit tests!

The goal is to get 100% coverage in the memory.ts class.

Create a test in a new folder src/tests. This file is memory.test.ts. The first test can just be:

describe('memory database tests', () => {
    it('should find a classroom by id', async () => {
        const classroom = await DatabaseConnector.find('classrooms', 1);

        expect(classroom.name).toBe('Room 10');
        expect(classroom.slug).toBe('room10');
    });
});

The above test will test the find method. Other test cases can be:

Just by writing these tests, I was able to find, and fix several bugs in memory.ts!‌

  1. update had a bug that would overwrite current columns with undefined if the data payload did not have a specified key. Logically, if no key is present in data, then the original data should be untouched.
  2. select had logic for checking opts.includeDeleted was flipped. if includeDeleted is false, or not specified, then only select items where dateDeleted === 0.
  3. select had a bug where filtering did not work because the condition variable wasn’t being returned in the filter callback.
  4. delete had the same issue as above!

Here is an example detailing a more complex test:

it('should still select logically deleted items', async () => {
    const newClassroom = await new Classroom({
        name: 'Room 4',
        slug: 'room4'
    }).insert();

    const count = (await DatabaseConnector.select('classrooms', {
        aggregate: {
            type: AggregateType.Count,
            alias: 'count',
            column: 'id'
        }
    }))[0]['count'];

    expect(count).toBe(2);

    await newClassroom.delete();

    const newCount = (await DatabaseConnector.select('classrooms', {
        aggregate: {
            type: AggregateType.Count,
            alias: 'count',
            column: 'id'
        },
        includeDeleted: true
    }))[0]['count'];

    expect(newCount).toBe(2);
});

Looks like running the tests work!

Here’s the coverage report!

All this is great, but what if I want to know exactly what logic should be tested? Well looking into coverage folder for the coverage report, I can find the exact statements which need to be covered.

Knowing all this information, it becomes easy to pinpoint what conditions need to be covered so that I can write my tests to exercise those portions of code.

Eventually, most of the file will be covered!