This article is the second in a series of deep dives into JavaScript. You van view the first article here.
This series does not comprehensively cover every JavaScript language feature. Instead, features are covered as they crop up in solutions to problems. Also, every post is based on tutorials and open source libraries produced by other developers, so like you, I too am also learning new things with each article.
Let the testing begin
To test or not to test, that is the question. At the end of the last post we pondered our next move after writing the first piece of code for our framework. We surmised that we could either write more code until we have a working framework or begin adding tests. Each option has tradeoffs. Writing more code means quickly creating a working prototype. Alternatively, writing tests means more robust code early on but at the price of slower development. There is no right or wrong answer. Sometimes you need to rapidly prototype and test different solutions whilst other times you add tests so you write better code.
We will take the testing route because in addition to making our code more robust, it gives us another chance to explore JavaScript from a different angle. Below is a reminder of what our createElement
function currently looks like:
function createElement (type, opts) {
if (type == null || typeof type !== 'string') {
throw Error('The element type must be a string');
}
if (arguments[1] !== undefined && Object.prototype.toString.call(opts) !== '[object Object]') {
throw Error('The options argument must be an object');
}
const { attrs = {}, children = [] } = opts || {};
return {
type,
attrs,
children
}
}
And below are the tests we will cover in this article.
group('createElement function', () => {
check('it creates a virtual dom object', () => {
const target = createElement('div', {});
const copy = { type: 'div', attrs: {}, children: [] };
assert.isDeeplyEqual(target, copy);
});
check('it throws errors when a string is not specified as the first argument', () => {
const err = () => createElement(1, null);
assert.throws(err, 'The element type must be a string');
});
check('it throws errors when the options argument is not an object', () => {
const err = () => createElement('h1', null);
assert.throws(err, 'The options argument must be an object');
});
xcheck('it creates DOM elements', () => {
// do some testing
});
xcheck('it mounts DOM elements', () => {
// do some testing
});
});
When the tests run, we will see this:
The work in the tests is being done by group
, check
, xcheck
and assert
. We will begin by examining group
and check
. Note: some code has been omitted for brevity
function group (title, fn) {
console.log(title);
fn();
}
function check(title, fn) {
console.log(title);
try {
fn();
} catch (e) {
console.log(title);
console.log((e.message);
console.log(e.stack);
}
};
The behaviour of both methods is simple but they introduce two new things we have not covered before: try/catch
and arrow functions
.
In the last post we had our first foray into error handling via the throw
keyword. Try/catch
is another error handling feature. It is used to test a block of code for errors and then handle any exceptions. In the check
function, the try
block will execute the function that has been passed in as the second argument. Any execptions are sent to the catch block, which is passed an error object. This object contains a message describing the error and a stack trace indicating which line of code caused it. Note: the variable e
passed to the catch
block is block scoped, so this code would result in an error:
function errorWaitingToHappen() {
try {
throw Error('I am an error');
} catch (errorWithLongVariableName) {
console.log(errorWithLongVariableName)
}
console.log(errorWithLongVariableName) // this is undefined
}
errorWaitingToHappen(); // Uncaught ReferenceError: errorWithLongVariableName is not defined
Arrow functions were introduced in ES6 and generally speaking, they behave like normal functions except in these cases. We are using them because none of the issues outlined in the links are applicable and they make our code terser.
Our next lesson comes not from the testing library we are building but from the code being tested. createElement
should throw exceptions when called with certain arguments. Testing this behaviour requires us to pass it to assert.throws
with those arguments specified. We could do it like this:
assert.throws(createElement, ['h1', null], 'The options argument must be an object');
And then implement assert.throws
:
throws(fn, args, errMsg = '') {
try {
fn(...args);
} catch (e) {
// do something
}
}
By passing in the error inducing arguments in an array, we can spread them into createElement
to successfully trigger the exception. However, this is not an optimal solution because it pollutes our assert.throws
implementation. That function should not care about the arguments needed to throw an exception. Its sole purpose is to execute the function and check for errors. We can achieve that goal by introducing another new concept: lazy evaluation:
const err = () => createElement('h1', null);
assert.throws(err, 'The options argument must be an object');
Even though we are calling createElement
in the function assigned to err
, we need not worry because the actual execution happens within the context of assert.throws
. It is only evaluated when needed, hence the term. Had we written this:
assert.throws(createElement('h1', null), 'The options argument must be an object');
createElement
will be called in the context of check
. And because check
has its own try/catch
block, it would handle the exception, meaning that assert.throws
would never run and our test would fail when it should have passed. With that sorted, we can fill out the rest of assert.throws
.
throws(fn, errMsg = '') {
const didNotThrowErr = new Error('The supplied function didn\'t throw an error');
try {
fn();
throw didNotThrowErr;
} catch (e) {
if (e === didNotThrowErr) throw didNotThrowErr;
if (!errMsg || e.message === errMsg) return true;
throw new Error(`\n\nFound: ${e.message}\nWanted: ${errMsg}\n\n`);
}
}
Checking for object equality
The implementation of assert.isDeeplyEqual
raises some interesting questions. As we can see below, its purpose is to check if any two given objects are equal.
check('it creates a virtual dom object', () => {
const target = createElement('div', {});
const copy = { type: 'div', attrs: {}, children: [] };
assert.isDeeplyEqual(target, copy);
});
What kind of equality are we checking for here? Compound values (objects, arrays etc) in JavaScript are assigned by reference, so using the in-built equality operators will not help us. We want to ensure both objects contain the same properties and that those properties also contain the same values. Here are some questions our solution needs to answer:
- Are both values objects?
- Do they contain the same number of properties?
- Do all those properties hold the same value? If they do:
- Are they primitive values we can easily check using equality operators?
- If they are compound values, what do we do?
- Are we going to handle every type of compound value?
We will call the function which does the checks deepEqual
and the first thing we need to do is check if we are dealing with objects. This check will be repeated again later so we can abstract it away with the following utility function:
function getLengthAndType(obj) {
if (Object.prototype.toString.call(obj) === '[object Array]') {
return { type: "array", length: obj.length }
}
if (Object.prototype.toString.call(obj) === '[object Object]') {
return { type: "object", length: Object.keys(obj).length }
}
return null;
}
We return an object with useful information we will use later and it also helps us avoid repeating the Object.prototype.toString.call
call. Here is the utility in action in the first part.
function deepEqual(obj, comparisonObj) {
const objInfo = getLengthAndType(obj);
const comparisonObjInfo = getLengthAndType(comparisonObj);
// only go forward with arrays or objects
if ( !objInfo || !comparisonObjInfo) {
return false
}
if (objInfo.length !== comparisonObjInfo.length || objInfo.type !== comparisonObjInfo.type) {
return false
}
getLengthAndType
returns null
for non-arrays and non-objects, so we can quickly establish the type of values we are comparing since null
is a falsy value. After that, we check the length and ensure both objects are the same type. The next thing to consider is how we are going to iterate over our array or object, and check each value.
if (objInfo.type === 'array') {
for (var i = 0; i < objInfo.length; i++) {
if (compare(obj[i], comparisonObj[i]) === false) return false;
}
} else {
for (let [key] of Object.entries(obj)) {
if (compare(obj[key], comparisonObj[key]) === false) return false;
}
}
Using a for
loop allow us to iterate over the array and check the values easily. However, this solution does have one drawback in that it assumes the values we want to check share same index position in both arrays. This is fine for our use case because we are checking that the objects defined in the children
array of our virtual dom object are in the same position. Object.entries
returns an array of an object's [key, value]
pairs. Coupled with for..of
, which creates a loop over iterable objects, we can also iterate over an object. An alternative approach would have been to use for..in
but this would require an additional hasOwnProperty
check because for..in
also iterates over inherited properties, something Object.entries
does not do. This alternative approach would look like this:
if (objInfo.type === 'array') {
for (var i = 0; i < objInfo.length; i++) {
if (compare(obj[i], comparisonObj[i]) === false) return false;
}
} else {
for (var prop in obj ) {
if (obj.hasOwnProperty(prop)) {
if (compare(obj[prop], comparisonObj[prop]) === false) return false;
}
}
}
The most interesting thing about deepEqual
is the concept it introduces in its internal compare
function. As you can see below, we easily compare functions and primitive values with the toString()
method and equality operators, respectively. However, comparing arrays or objects is more complicated.
const compare = (val, comparisonVal) => {
const isArrayOrObject = getLengthAndType(val);
const isFunction = Object.prototype.toString.call(val) === '[object Function]';
if (isArrayOrObject) {
if (!deepEqual(val, comparisonVal)) return false;
}
else {
if (isFunction) {
if (val.toString() !== comparisonVal.toString()) return false;
} else {
if (val !== comparisonVal) return false;
}
}
};
Up until now, we have been declaring our functions in one place and then calling them in another. With deepEqual
, we are calling it within itself. This process is called recursion and makes deepEqual
a recursive function. Given this call isDeeplyEqual( {arr: [1, 2, 3]}, {arr: [1, 2, 3]} )
, when the program reaches the compare
function, this happens:
// The initial recursive call
const compare = (val, comparisonVal) => {
// val is {arr: [1, 2, 3]}
// comparisonVal is {arr: [1, 2, 3]}
const isArrayOrObject = getLengthAndType(val); // { type: "object", length: 2 }
if (isArrayOrObject) { // true
if (!deepEqual(val, comparisonVal)) return false; // recursion!
}
//...
}
// We reach compare again and make another recursive call
const compare = (val, comparisonVal) => {
// val is [1, 2, 3]
// comparisonVal is [1, 2, 3]
const isArrayOrObject = getLengthAndType(val); // { type: "array", length: 3 }
if (isArrayOrObject) { // true
if (!deepEqual(val, comparisonVal)) return false; // more recursion!
}
//...
}
// No more recursive calls
// We are now comparing every element in the array [1, 2, 3]
const compare = (val, comparisonVal) => {
// val is 1
// comparisonVal is 1
const isArrayOrObject = getLengthAndType(val); // false
if (isArrayOrObject) { // false
if (!deepEqual(val, comparisonVal)) return false; // no recursion :-(
}
//...
}
Recursion is a good way to navigate and perform operations on nested data structures. In our scenario it abstracts away the complexity of writing conditional checks to handle what will be an unknown number of nested objects and arrays. Each time we call a function, a new execution context is created and added to the call stack, so we can offload that work to the JavaScript engine and wait for whatever is returned. Put together, deepEqual
looks like this:
function getLengthAndType(obj) {
if (Object.prototype.toString.call(obj) === '[object Array]') {
return { type: "array", length: obj.length }
}
if (Object.prototype.toString.call(obj) === '[object Object]') {
return { type: "object", length: Object.keys(obj).length }
}
return null;
}
function deepEqual(obj, comparisonObj) {
const objInfo = getLengthAndType(obj);
const comparisonObjInfo = getLengthAndType(comparisonObj);
// only go forward with arrays or objects
if ( !objInfo || !comparisonObjInfo) {
return false
}
if (objInfo.length !== comparisonObjInfo.length || objInfo.type !== comparisonObjInfo.type) {
return false
}
const compare = (val, comparisonVal) => {
const isArrayOrObject = getLengthAndType(val);
const isFunction = Object.prototype.toString.call(val) === '[object Function]';
if (isArrayOrObject) {
if (!deepEqual(val, comparisonVal)) return false;
}
else {
if (isFunction) {
if (val.toString() !== comparisonVal.toString()) return false;
} else {
if (val !== comparisonVal) return false; // we are comparing primitive values
}
}
};
if (objInfo.type === 'array') {
for (var i = 0; i < objInfo.length; i++) {
if (compare(obj[i], comparisonObj[i]) === false) return false;
}
} else {
for (let [key] of Object.entries(obj)) {
if (compare(obj[key], comparisonObj[key]) === false) return false;
}
}
return true; // nothing failed
}
So far we have only looked at the code responsible for the actual testing but how do we show the test results and other information to the user? Unlike our framework which will be used to create user interfaces in the browser, our testing framework only works on the command line. Let us begin with the final implementations of check
and group
. xcheck
is also included but it is not doing much.
const colors = require('colors');
const assert = require('./assertions');
const repeat = (str, n) => Array(n).join(str);
const indent = n => repeat(' ', n);
const indentLines = (str, n) => indent(n) + str.replace(/\n/g, `\n${indent(n)}`);
const log = str => console.log(str);
const summary = { success: 0, fail: 0, disabled: 0 };
let indentLevel = 0;
let examinar;
function group(title, fn) {
indentLevel++;
log(`\n${indent(indentLevel)}⇨ ${title}`.yellow);
fn();
indentLevel--;
}
function check(title, fn) {
try {
fn();
log(`${indent(indentLevel + 1)}${' OK '.bgGreen.black} ${title.green}`);
summary.success++;
} catch (e) {
log(`${indent(indentLevel + 1)}${' FAIL '.bgRed.black} ${title.red}`);
log(indentLines(e.message.red, indentLevel + 1));
log(indentLines(e.stack.red, indentLevel + 1));
summary.fail++;
}
}
function xcheck(title) {
log(`${indent(indentLevel + 1)}${' DISABLED '.bgWhite.black} ${title.gray}`);
summary.disabled++;
}
function end() {
log(`\n${repeat('.', 60)}\n`);
log('Test summary:\n');
log(` Success: ${summary.success}`.green);
log(` Fail: ${summary.fail}`.red);
log(` Disabled: ${summary.disabled}\n\n`.gray);
if (summary.fail > 0 ) process.exit(1);
process.exit(0);
}
module.exports = { assert, check, end, group, xcheck };
Displaying the results and relevant information is achieved through a combination of indentation, adding colour and summarisation. Each time we group some tests, we log the title using template literals and the color module. You will notice that this is the first time in our project we are using a third party library. This is because as much as we are trying to write our own code, that is not always possible and there are instances where, because of time or complexity, it makes more sense to leverage existing solutions. However, when using third-party code it does help to understand a high level what exactly is being abstracted away. The color module works by extending String.prototype
and defining getters which return the specified colour. You can test this yourself by importing the module and writing console.log(''.__proto__)
. Template literals can evaluate expressions, so combined with the colour module, they create nice visuals for our tests.
The end
function terminates our suite of tests after logging a summary. This is achieved by using the globally available process.exit()
method which instructs Node.js to terminate the current process synchronously with an exit code: 1
for failure and 0
for success.
There is one more thing we can learn from the code at end of our function: exporting modules. We will visit this topic in greater depth in our next post when set up our development environment but for now, we can briefly cover it. Modules are units of code, much like lego pieces, which can be put together to create various things. Node treats each file as a separate module. You import files using the require
keyword and export with module.exports
or exports
. Both module.exports
or exports
reference the same object so:
module.exports = { assert, check, end, group, xcheck };
is equivalent to:
exports.check = check();
exports.end = end();
exports.assert = assert;
exports.group = group();
exports.xcheck = xcheck()
Summary
In this post we made no progress with the actual framework code but we began laying a foundation for future development. In creating a testing framework we learnt about arrow functions, try/catch, lazy evaluation, recursion, template strings and exporting modules. All these lessons were brief but powerful nonetheless because we did them in the context of a very hands on project. This approach makes the concepts more concrete.
Top comments (0)