Unit Testing and Functional Testing: Testing a Notes model

Let’s start our unit testing journey with the data models we wrote for the Notes application. Because this is unit testing, the models should be tested separately from the rest of the Notes application.

In the case of most of the Notes models, isolating their dependencies implies creating a mock database. Are you going to test the data model or the underlying database?

Mocking out a database means creating a fake database implementation, which does not look like a productive use of our time. You can argue that testing a data model is really about testing the interaction between your code and the database. Since mocking out the database means not testing that interaction, we should test our code against the database engine in order to validate that interaction.

With that line of reasoning in mind, we’ll skip mocking out the database, and instead run the tests against a database containing test data. To simplify launching the test database, we’ll use Docker to start and stop a version of the Notes application stack that’s set up for testing.

Let’s start by setting up the tools.

1. Mocha and Chai – the chosen test tools

If you haven’t already done so, duplicate the source tree so that you can use it in this chapter. For example, if you had a directory named chap12, create one named chap13 containing everything from chap12 to chap13.

In the notes directory, create a new directory named test.

Mocha (http://mochajs.org/) is one of many test frameworks available for Node.js. As you’ll see shortly, it helps us write test cases and test suites, and it provides a test results reporting mechanism. It was chosen over the alternatives because it supports Promises. It fits very well with the Chai assertion library mentioned earlier.

While in the notes/test directory, type the following to install Mocha and Chai:

$ npm init

… answer the questions to create package.json

$ npm install mocha@7.x chai@4.2.x cross-env@7.x npm-run-all@4.1.x

save-dev

 

This, of course, sets up a package.json file and installs the required packages.

Beyond Mocha and Chai, we’ve installed two additional tools. The first, cross-env, is one we’ve used before and it enables cross-platform support for setting environment variables on the command line. The second, npm-run-all, simplifies using package.json to drive build or test procedures.

With the tools set up, we can move on to creating tests.

2. Notes model test suite

Because we have several Notes models, the test suite should run against any model. We can write tests using the NotesStore API, and an environment variable should be used to declare the model to test. Therefore, the test script will load notes- store.mjs and call functions on the object it supplies. Other environment variables will be used for other configuration settings.

Because we’ve written the Notes application using ES6 modules, we have a small item to consider. Older Mocha releases only supported running tests in CommonJS modules, so this would require us to jump through a couple of hoops to test Notes modules. But the current release of Mocha does support them, meaning we can freely use ES6 modules.

We’ll start by writing a single test case and go through the steps of running that test and getting the results. After that, we’ll write several more test cases, and even find a couple of bugs. These bugs will give us a chance to debug the application and fix any problems. We’ll close out this section by discussing how to run tests that require us to set up background services, such as a database server.

2.1. Creating the initial Notes model test case

In the test directory, create a file named test-model.mjs containing the following. This will be the outer shell of the test suite:

import util from ‘util’;

import Chai from ‘chai’;

const assert = Chai.assert;

import { useModel as useNotesModel } from ‘../models/notes-store.mjs’;

var store;

describe(‘Initialize’, function() {

this.timeout(100000);

it(‘should successfully load the model’, async function() {

try {

// Initialize just as in app.mjs

// If these execute without exception the test succeeds

store = await useNotesModel(process.env.NOTES_MODEL);

} catch (e) {

console.error(e);

throw e;

}

});

});

This loads in the required modules and implements the first test case.

The Chai library supports three flavors of assertions. We’re using the assert style here, but it’s easy to use a different style if you prefer.

To load the model to be tested, we call the useModel function (renamed as useNotesModel). You’ll remember that this uses the import() function to dynamically select the actual NotesStore implementation to use. The NOTES_MODEL environment variable is used to select which to load.

Calling this.timeout adjusts the time allowed for completing the test. By default, Mocha allows 2,000 milliseconds (2 seconds) for a test case to be completed. This particular test case might take longer than that, so we’ve given it more time.

The test function is declared as async. Mocha can be used in a callback fashion, where Mocha passes in a callback to the test to invoke and indicate errors. However, it can also be used with async test functions, meaning that we can throw errors in the normal way and Mocha will automatically capture those errors to determine if the test fails.

Generally, Mocha looks to see if the function throws an exception or whether the test case takes too long to execute (a timeout situation). In either case, Mocha will indicate a test failure. That’s, of course, simple to determine for non-asynchronous code. But Node.js is all about asynchronous code, and Mocha has two models for testing asynchronous code. In the first (not seen here), Mocha passes in a callback function, and the test code is to call the callback function. In the second, as seen here, it looks for a Promise being returned by the test function and determines a pass/fail regarding whether the Promise is in the resolve or reject state.

We are keeping the NotesStore model in the global store variable so that it can be used by all tests. The test, in this case, is whether we can load a given NotesStore implementation. As the comment states, if this executes without throwing an exception, the test has succeeded. The other purpose of this test is to initialize the variable for use by other test cases.

It is useful to notice that this code carefully avoids loading app.mjs. Instead, it loads the test driver module, models/notes-store.mjs, and whatever module is loaded by useNotesModel. The NotesStore implementation is what’s being tested, and the spirit of unit testing says to isolate it as much as possible.

Before we proceed further, let’s talk about how Mocha structures tests.

With Mocha, a test suite is contained within a describe block. The first argument is a piece of descriptive text that you use to tailor the presentation of test results. The second argument is a function that contains the contents of the given test suite.

The it function is a test case. The intent is for us to read this as it should successfully load the module. Then, the code within the function is used to check that assertion.

Now that we have a test case written, let’s learn how to run tests.

2.2. Running the first test case

Now that we have a test case, let’s run the test. In the package.json file, add the following scripts section:

“scripts”: {

“test-all”: “npm-run-all test-notes-memory test-level test-notes-

fs test-notes-sqlite3 test-notes-sequelize-sqlite”,

“test-notes-memory”: “cross-env NOTES_MODEL=memory mocha test

-model”,

“test-level”: “cross-env NOTES_MODEL=level mocha test-model”,

“test-notes-fs”: “cross-env NOTES_MODEL=fs mocha test-model”,

“pretest-notes-sqlite3”: “rm -f chap13.sqlite3 && sqlite3

chap13.sqlite3 –init ../models/schema-sqlite3.sql </dev/null”,

“test-notes-sqlite3”: “cross-env NOTES_MODEL=sqlite3

SQLITE_FILE=chap13.sqlite3 mocha test-model”,

“test-notes-sequelize-sqlite”: “cross-env NOTES_MODEL=sequelize

SEQUELIZE_CONNECT=sequelize-sqlite.yaml mocha test-model”

}

What we’ve done here is create a test-all script that will run the test suite against the individual NotesStore implementations. We can run this script to run every test combination, or we can run a specific script to test just the one combination. For example, test-notes-sequelize-sqlite will run tests against SequelizeNotesStore using the SQLite3 database.

It uses npm-run-all to support running the tests in series. Normally, in a package.json script, we would write this:

“test-all”: “npm run test-notes-memory && npm run test-level && npm run test-notes-fs && …”

This runs a series of steps one after another, relying on a feature of the Bash shell. The npm-run-all tool serves the same purpose, namely running one package.json script after another in the series. The first advantage is that the code is simpler and more compact, making it easier to read, while the other advantage is that it is cross- platform. We’re using cross-env for the same purpose so that the test scripts can be executed on Windows as easily as they can be on Linux or macOS.

For the test-notes-sequelize-sqlite test, look closely. Here, you can see that we need a database configuration file named sequelize-sqlite.yaml. Create that file with the following code:

dbname: notestest

username:

password:

params:

dialect: sqlite

storage: notestest-sequelize.sqlite3

logging: false

This, as the test script name suggests, uses SQLite3 as the underlying database, storing it in the named file.

We are missing two combinations, test-notes-sequelize-mysql for SequelizeNotesStore using MySQL and test-notes-mongodb, which tests against MongoDBNotesStore. We’ll implement these combinations later.

Having automated the run of all test combinations, we can try it out:

$ npm run test-all

> notes-test@1.0.0 test-all /Users/David/Chapter13/notes/test

> npm-run-all test-notes-memory test-level test-notes-fs test-notes- sqlite3 test-notes-sequelize-sqlite

> notes-test@1.0.0 test-notes-memory /Users/David/Chapter13/notes/test

> cross-env NOTES_MODEL=memory mocha test-model 

Initialize

should successfully load the model 

1 passing (8ms)

 

If all has gone well, you’ll get this result for every test combination currently supported in the test-all script.

This completes the first test, which was to demonstrate how to create tests and execute them. All that remains is to write more tests.

2.3. Adding some tests

That was easy, but if we want to find what bugs we created, we need to test some functionality. Now, let’s create a test suite for testing NotesStore, which will contain several test suites for different aspects of NotesStore.

What does that mean? Remember that the describe function is the container for a test suite and that the it function is the container for a test case. By simply nesting describe functions, we can contain a test suite within a test suite. It will be clearer what that means after we implement this:

describe(‘Model Test’, function() {

describe(‘check keylist’, function() {

before(async function() {

await store.create(‘n1’, ‘Note 1’, ‘Note 1’);

await store.create(‘n2’, ‘Note 2’, ‘Note 2’);

await store.create(‘n3’, ‘Note 3’, ‘Note 3’);

});

after(async function() {

const keyz = await store.keylist();

for (let key of keyz) {

await store.destroy(key);

}

});

});

});

Here, we have a describe function that defines a test suite containing another describe function. That’s the structure of a nested test suite.

We do not have test cases in the it function defined at the moment, but we do have the before and after functions. These two functions do what they sound like; namely, the before function runs before all the test cases, while the after function runs after all the test cases have finished. The before function is meant to set up conditions that will be tested, while the after function is meant for teardown.

In this case, the before function adds entries to NotesStore, while the after function removes all entries. The idea is to have a clean slate after each nested test suite is executed.

The before and after functions are what Mocha calls a hook. The other hooks are beforeEach and afterEach. The difference is that the Each hooks are triggered before or after each test case’s execution.

These two hooks also serve as test cases since the create and destroy methods could fail, in which case the hook will fail.

Between the before and after hook functions, add the following test cases:

it(“should have three entries”, async function() {

const keyz = await store.keylist();

assert.exists(keyz);

assert.isArray(keyz);

assert.lengthOf(keyz, 3);

});

it(“should have keys n1 n2 n3”, async function() {

const keyz = await store.keylist();

assert.exists(keyz);

assert.isArray(keyz);

assert.lengthOf(keyz, 3);

for (let key of keyz) {

assert.match(key, /n[123]/, “correct key”);

}

});

it(“should have titles Node #”, async function() {

const keyz = await store.keylist();

assert.exists(keyz);

assert.isArray(keyz);

assert.lengthOf(keyz, 3);

var keyPromises = keyz.map(key => store.read(key));

const notez = await Promise.all(keyPromises);

for (let note of notez) {

assert.match(note.title, /Note [123]/, “correct title”);

}

});

As suggested by the description for this test suite, the functions all test the keylist method.

For each test case, we start by calling keylist, then using assert methods to check different aspects of the array that is returned. The idea is to call NotesStore API functions, then test the results to check whether they matched the expected results.

Now, we can run the tests and get the following:

$ npm run test-all

> notes-test@1.0.0 test-notes-fs /Users/David/Chapter13/notes/test

> NOTES_MODEL=fs mocha test-model

 

Initialize

     should successfully load the model (174ms)

Model Test check keylist

should have three entries

should have keys n1 n2 n3

should have titles Node # 

4 passing (226ms)

 

Compare the outputs with the descriptive strings in the describe and it functions. You’ll see that the structure of this output matches the structure of the test suites and test cases. In other words, we should structure them so that they have well-structured test output.

As they say, testing is never completed, only exhausted. So, let’s see how far we can go before exhausting ourselves.

2.4. More tests for the Notes model

That wasn’t enough to test much, so let’s go ahead and add some more tests:

describe(‘Model Test’, function() {

describe(‘read note’, function() {

before(async function() {

await store.create(‘n1’, ‘Note 1’, ‘Note 1’);

});

it(‘should have proper note’, async function() {

const note = await store.read(‘n1’);

assert.exists(note);

assert.deepEqual({

key: note.key, title: note.title, body: note.body

}, {

key: ‘n1’, title: ‘Note 1’,

});

});

body: ‘Note 1’

it(‘Unknown note should fail’, async function() {

try {

const note = await store.read(‘badkey12’);

assert.notExists(note);

throw new Error(‘should not get here’);

} catch(err) {

// An error is expected, so it is an error if

// the ‘should not get here’ error is thrown assert.notEqual(err.message, ‘should not get here’);

}

});

after(async function() {

const keyz = await store.keylist();

for (let key of keyz) {

await store.destroy(key);

}

});

});

});

These tests check the read method. In the first test case, we check whether it successfully reads a known Note, while in the second test case, we have a negative test of what happens if we read a non-existent Note.

Negative tests are very important to ensure that functions fail when they’re supposed to fail and that failures are indicated correctly.

The Chai Assertions API includes some very expressive assertions. In this case, we’ve used the deepEqual method, which does a deep comparison of two objects. You’ll see that for the first argument, we pass in an object and that for the second, we pass an object that’s used to check the first. To see why this is useful, let’s force it to indicate an error by inserting FAIL into one of the test strings.

After running the tests, we get the following output:

> notes-test@1.0.0 test-notes-memory /Users/David/Chapter13/notes/test

> NOTES_MODEL=memory mocha test-model 

Initialize

should successfully load the model 

Model Test 

check keylist

should have three entries

should have keys n1 n2 n3

should have titles Node #

read note

1 should have proper note  

Unknown note should fail 

5 passing (35ms)

1 failing 

1) Model Test

read note

should have proper note: 

AssertionError: expected { Object (key, title, …) } to deeply equal

{ Object (key, title, …) }

+ expected – actual

{

“body”: “Note 1”

“key”: “n1”

– “title”: “Note 1”

+ “title”: “Note 1 FAIL”

} 

at Context.<anonymous>

(file:///Users/David/Chapter13/notes/test/test-model.mjs:76:16) 

This is what a failed test looks like. Instead of the checkmark, there is a number, and the number corresponds to a report below it. In the failure report, the deepEqual function gave us clear information about how the object fields differed. In this case, it is the test we forced to fail because we wanted to see how the deepEqual function works.

Notice that for the negative tests – where the test passes if an error is thrown – we run it in a try/catch block. The throw new Error line in each case should not execute because the preceding code should throw an error. Therefore, we can check if the message in that thrown error is the message that arrives, and fail the test if that’s the case.

2.5. Diagnosing test failures

We can add more tests because, obviously, these tests are not sufficient to be able to ship Notes to the public. After doing so, and then running the tests against the different test combinations, we will find this result for the SQLite3 combination:

$ npm run test-notes-sqlite3 

> notes-test@1.0.0 test-notes-sqlite3

/Users/David/Chapter11/notes/test

> rm -f sqlite3 && sqlite3 chap11.sqlite3 –init

../models/schema-sqlite3.sql </dev/null && NOTES_MODEL=sqlite3

SQLITE_FILE=chap11.sqlite3 mocha test-model

 Initialize

should successfully load the model (89ms) 

Model Test

check keylist

should have three entries

should have keys n1 n2 n3

should have titles Node #

read note

should have proper note

1) Unknown note should fail

change note

after a successful model.update

destroy note

should remove note

2) should fail to remove unknown note 

7 passing (183ms)

2 failing

 

1) Model Test read note

Unknown note should fail:

Uncaught TypeError: Cannot read property ‘notekey’ of undefined at

Statement.<anonymous>

(file:///Users/David/Chapter11/notes/models/notes-sqlite3.mjs:79:43)

 

2) Model Test

destroy note

should fail to remove unknown note:

AssertionError: expected ‘should not get here’ to not equal ‘should not get here’

+ expected – actual

at Context.<anonymous> 

(file:///Users/David/Chapter11/notes/test/test- model.mjs:152:20) 

Our test suite found two errors, one of which is the error we mentioned in Chapter 7, Data Storage and Retrieval. Both failures came from the negative test cases. In one case, the test calls store.read(“badkey12”), while in the other, it calls store.delete(“badkey12”).

It is easy enough to insert console.log calls and learn what is going on.

For the read method, SQLite3 gave us undefined for row. The test suite successfully calls the read function multiple times with a notekey value that does exist.

Obviously, the failure is limited to the case of an invalid notekey value. In such cases, the query gives an empty result set and SQLite3 invokes the callback with undefined in both the error and the row values. Indeed, the equivalent SQL SELECT statement does not throw an error; it simply returns an empty result set. An empty result set isn’t an error, so we received no error and an undefined row.

However, we defined read to throw an error if no such Note exists. This means this function must be written to detect this condition and throw an error.

There is a difference between the read functions in models/notes-sqlite3.mjs and models/notes-sequelize.mjs. On the day we wrote SequelizeNotesStore, we must have thought through this function more carefully than we did on the day we wrote SQLITE3NotesStore. In SequelizeNotesStore.read, there is an error that’s thrown when we receive an empty result set, and it has a check that we can adapt. Let’s rewrite the read function in models/notes-sqlite.mjs so that it reads as follows:

async read(key) {

var db = await connectDB();

var note = await new Promise((resolve, reject) => {

db.get(“SELECT * FROM notes WHERE notekey = ?”,

[ key ], (err, row) => {

if (err) return reject(err);

if (!row) {

reject(new Error(‘No note found for ${key}’));

} else {

const note = new Note(row.notekey, row.title, row.body);

resolve(note);

}

});

});

return note;

}

If this receives an empty result, an error is thrown. While the database doesn’t see empty results set as an error, Notes does. Furthermore, Notes already knows how to deal with a thrown error in this case. Make this change and that particular test case will pass.

There is a second similar error in the destroy logic. In SQL, it obviously is not an SQL error if this SQL (from models/notes-sqlite3.mjs) does not delete anything:

db.run(“DELETE FROM notes WHERE notekey = ?;”, … );

Unfortunately, there isn’t a method in the SQL option to fail if it does not delete any records. Therefore, we must add a check to see if a record exists, namely the following:

async destroy(key) {

var db = await connectDB();

const note = await this.read(key);

return await new Promise((resolve, reject) => {

db.run(“DELETE FROM notes WHERE notekey = ?;”,

[ key ], err => {

if (err) return reject(err);

this.emitDestroyed(key);

resolve();

});

});

}

Therefore, we read the note and, as a byproduct, we verify the note exists. If the note doesn’t exist, read will throw an error, and the DELETE operation will not even run.

When we run test-notes-sequelize-sqlite, there is also a similar failure in its destroy method. In models/notes-sequelize.mjs, make the following change:

async destroy(key) {

await connectDB();

const note = await SQNote.findOne({ where: { notekey: key } });

if (!note) {

throw new Error(‘No note found for ${key}’);

} else {

await SQNote.destroy({ where: { notekey: key } });

}

this.emitDestroyed(key);

}

This is the same change; that is, to first read the Note corresponding to the given key, and if the Note does not exist, to throw an error.

Likewise, when running test-level, we get a similar failure, and the solution is to edit models/notes-level.mjs to make the following change:

async destroy(key) {

const db = await connectDB();

const note = Note.fromJSON(await db.get(key));

await db.del(key);

this.emitDestroyed(key);

}

As with the other NotesStore implementations, this reads the Note before trying to destroy it. If the read operation fails, then the test case sees the expected error.

These are the bugs we referred to in Chapter 7, Data Storage and Retrieval. We simply forgot to check for these conditions in this particular model. Thankfully, our diligent testing caught the problem. At least, that’s the story to tell the managers rather than telling them that we forgot to check for something we already knew could happen.

2.6. Testing against databases that require server setup – MySQL and MongoDB

That was good, but we obviously won’t run Notes in production with a database such as SQLite3 or Level. We can run Notes against the SQL databases supported by Sequelize (such as MySQL) and against MongoDB. Clearly, we’ve been remiss in not testing those two combinations.

Our test results matrix reads as follows:

  • notes-fs: PASS
  • notes-memory: PASS
  • notes-level: 1 failure, now fixed
  • notes-sqlite3: 2 failures, now fixed
  • notes-sequelize: With SQLite3: 1 failure, now fixed
  • notes-sequelize: With MySQL: untested
  • notes-mongodb: Untested

The two untested NotesStore implementations both require that we set up a database server. We avoided testing these combinations, but our manager won’t accept that excuse because the CEO needs to know we’ve completed the test cycles. Notes must be tested with a configuration similar to the production environments’.

In production, we’ll be using a regular database server, with MySQL or MongoDB being the primary choices. Therefore, we need a way to incur a low overhead to run tests against those databases. Testing against the production configuration must be so easy that we should feel no resistance in doing so, to ensure that tests are run often enough to make the desired impact.

In this section, we made a lot of progress and have a decent start on a test suite for the NotesStore database modules. We learned how to set up test suites and test cases in Mocha, as well as how to get useful test reporting. We learned how to use package.json to drive test suite execution. We also learned about negative test scenarios and how to diagnose errors that come up.

But we need to work on this issue of testing against a database server. Fortunately, we’ve already worked with a piece of technology that supports easily creating and destroying the deployment infrastructure. Hello, Docker!

In the next section, we’ll learn how to repurpose the Docker Compose deployment as a test infrastructure.

Source: Herron David (2020), Node.js Web Development: Server-side web development made easy with Node 14 using practical examples, Packt Publishing.

Leave a Reply

Your email address will not be published. Required fields are marked *