#LearnByDIY - How to create a JavaScript unit testing framework from scratch

Alcides Queiroz
Apr 9, 2018 · 11 min read
Image for post
Image for post
This is how the output of our testing framework will look like

I promise, this is gonna be fun. =)

Probably, automated tests are part of your daily routine (if not, please stop reading this article and start from the beginning, by learning from the father of TDD himself). You’ve been using testing frameworks such as Node-tap (or Tape), Jasmine, Mocha or QUnit for quite a while, just accepting that they do some magic stuff and not asking too many questions about them. Or, if you’re like me, maybe you’re always curious about how things work, including testing frameworks, of course.

This article will guide you through the process of creating a JavaScript testing framework from scratch, with a pretty decent DSL and a nicely detailed output. This is the first article in my #LearnByDIY series. The idea is to demystify certain kinds of software that we’re used to, by creating simpler versions of them.

Disclaimers

  • The goal of this article is not to create a production-ready tool. Please, don’t use the framework that we’ll be creating to test production code. Its purpose is purely educational. =)
  • Naturally, our little framework won’t be full-featured. Things such as async tests, parallel executions, a richer set of matchers, a CLI (with options like --watch), pluggable reporters and DSLs, etc., won’t be present in our final version. However, I strongly recommend that you keep toying with this project and maybe try to implement some of these missing parts. Perhaps you can transform it into a serious open source project. I’d love to know that this toy project became an “actual” testing framework.

⚔️ Tyrion - A tiny testing framework

Image for post
Image for post
Tyrion is small, but brave.

We’ll be using Node.js in this project, with good and old CommonJS modules. The minimum Node version you’ll need is v8.6.0. If you have an older version, please update it.

Oh, I almost forgot… I’m using Yarn throughout this article, for things like yarn init, yarn link and so on, but you can use “vanilla” NPM in a similar manner (npm init, npm link, …).

Creating the project folder structure

tyrion/
|
|______ proj/
| |
| |______ src/
|
|______ playground/
|
|______ src/
|______ tests/

In other “words”:

$ mkdir -p tyrion/proj/src tyrion/playground/src tyrion/playground/tests

We need two folders, each one to a separate project.

  • The proj folder will contain the Tyrion framework package.
  • The playground folder will contain a disposable Node project for playing with our framework. It will serve as a lab during our development process.

Initializing the Node projects

{
"name": "playground",
"version": "1.0.0",
"scripts": {
"test": "node tests"
},

"license": "MIT"
}

After creating this file, let’s do the same for the other project, the Tyrion package itself. In the proj folder, run yarn init. It will prompt you for some information to properly create the package.json file. Enter the following values (in bold):

question name (proj): tyrion <enter>
question version (1.0.0): <enter>
question description: <enter>
question entry point (index.js): src/index.js <enter>
question repository url: <enter>
question author: <enter>
question license (MIT): <enter>
question private: <enter>

Now, we need to install Tyrion as a development dependency in our playground project. If it was a published package, we’d just need to install it directly, through npm i --dev or yarn add --dev. As we only have Tyrion locally, this is not possible. Luckily, both Yarn and NPM have a feature to help developers during this package “inception” phase, allowing us to simulate a link between two packages (one as a dependency of the other).

To create this dependency link, go to the proj folder and run:

$ yarn link

Then, in the playground folder, run:

$ yarn link tyrion 

That’s all. Now Tyrion is a dependency of the playground project.

Creating some modules to be our “guinea pigs”

playground/src/number-utils.js
playground/src/string-utils.js

Writing some tests

Let’s write a few tests to see it more clearly. Of course, nothing will work, since we didn’t implement anything in our framework.

playground/tests/number-utils.test.js
playground/tests/string-utils.test.js

And a tests/index.js file, to import our tests in only one place.

playground/tests/index.js

Tyrion will borrow one of Node-tap’s principles:

Test files should be “normal” programs that can be run directly.

That means that it can’t require a special runner that puts magic functions into a global space. node test.js is a perfectly ok way to run a test, and it ought to function exactly the same as when it’s run by the fancy runner with reporting and such. JavaScript tests should be JavaScript programs; not english-language poems with weird punctuation.

https://www.node-tap.org/#tutti-i-gusti-sono-gusti.

As you might remember, in our playground’s package.json file, we have a test script which simply runs node tests. So, to execute it, just type npm test and hit enter. Yeap, do it. Let’s see it crashing:

Image for post
Image for post

This error is clear. We don’t have anything in our framework. No module is being exported at all. To fix it, in the proj folder, create a src/index.js file exporting an empty object, as you can see below:

module.exports = {};

Now, we’ll run npm test again:

Image for post
Image for post

Node is complaining because our guarantee function doesn’t exist. This is simple to fix, too:

const guarantee = () => {};module.exports = { guarantee };

Run the test script again:

Image for post
Image for post

Voilà! No errors, but nothing happens, either. =(

The guarantee function

Let’s implement it:

And to test if it works, let’s append another assertion to the end of our number-utils.test.js file:

guarantee(123 === 321); // This should fail 

Now run it once more:

Image for post
Image for post

A-ha! It works! It’s ugly, but it’s functional.

The check function

In Tyrion, our test unit function will be called check. Its signature should be check(testDescription, callback). We also want it to give us a friendlier output, describing the passing and failing tests.

This is what it will look like:

Now, we can rewrite our tests to use the new check function:

And re-run our test suite:

Image for post
Image for post

Cool. But… what about some colors?? Wouldn’t it be a lot easier to distinguish between passing and failing tests?

Add the colors module as a dependency:

yarn add colors

So, import it at the top of the proj/src/index.js file:

const colors = require('colors');

And let’s put some colors in our output:

const check = (title, cb) => {
try{
cb();
console.log(`${' OK '.bgGreen.black} ${title.green}`);
} catch(e) {
console.log(`${' FAIL '.bgRed.black} ${title.red}`);
console.log(e.stack.red);
}
};
Image for post
Image for post

That’s better. =)

The xcheck function

const xcheck = (title, cb) => {
console.log(`${' DISABLED '.bgWhite.black} ${title.gray}`);
};
module.exports = { guarantee, check, xcheck };

So, import the xcheck function in the number-utils.test.js file and disable one of our tests:

const { guarantee, check, xcheck } = require('tyrion');
const numberUtils = require('../src/number-utils');
// method: isPrime
xcheck('returns true for prime numbers', () => {
guarantee(numberUtils.isPrime(2));
guarantee(numberUtils.isPrime(3));
guarantee(numberUtils.isPrime(5));
guarantee(numberUtils.isPrime(7));
guarantee(numberUtils.isPrime(23));
});

And here’s how it behaves:

Image for post
Image for post

Test summary and exit code

Another desirable feature is a test summary. It would be nice to know how many tests passed, failed, or skipped (the disabled ones). For this, we could increment some counters in both check and xcheck functions.

We will create the end function, which prints the test summary and finishes with the appropriate exit code:

And don’t forget to call it in the playground/tests/index.js file:

const { end } = require('tyrion');require('./string-utils.test');
require('./number-utils.test');
end();

Or maybe:

const tyrion = require('tyrion');require('./string-utils.test');
require('./number-utils.test');
tyrion.end();

Now, let’s re-run npm test:

Image for post
Image for post

Great, it works.

The group function

And update our tests to use this new function:

Here’s the new output:

Image for post
Image for post

Well, the good news is that it works. The bad news is that it’s getting hard to understand. We need a way to indent this output in order to make it more readable:

Run it again:

Image for post
Image for post

That’s way better!

So, how does it work?

  • The repeat function repeats a string n times.
  • The indent function repeats an indent (of four spaces) n times by using the repeat function.
  • The indentLines function indents a string with multiple lines by adding n indents to the beginning of each line. We’re using it to indent error stacks.
  • The indentLevel variable is incremented at the beginning of each group execution and decremented at its end. This way, nested groups can be correctly indented.

More matchers

First, create the matchers folder:

$ mkdir proj/src/matchers

Now, we’ll create each matcher in a separate file:

The same matcher uses the strict equality operator (===) to test if two arguments are exactly the same object (for reference types) or equal (for primitive types). It behaves similarly to the toBe matcher in Jasmine and t.equal in node-tap.

Note: Node-tap also has a matcher called t.same, but it works differently (it won’t verify if two objects are exactly the same, but if both are deeply equivalent).

The identical matcher verifies that two arguments are equivalent. It uses the == operator for comparing values.

The deeplyIdentical matcher does a deep comparison of two objects. This kind of comparison can be considerably complex, or at least too complex for this article’s purpose. So, let’s install an existing module to handle deep equality and use it in our matcher:

$ yarn add deep-equal

Then:

This is how an error will look:

Image for post
Image for post

The falsy matcher will fail if the supplied value is truthy.

The truthy matcher works in a similar manner to our guarantee function. It passes when the supplied value is truthy and breaks if it’s falsy.

The throws matcher will pass if a function throws an error. It’s possible to specify the wanted error message, but this is not mandatory.

An index.js file to re-export all matchers:

And finally let’s glue them all together:

You can use our new matchers this way:

const { guarantee, check } = require('tyrion');check('playing with our new matchers', () => {
// The original guarantee function still works
guarantee(123 === 123);
guarantee.truthy('abc');
guarantee.falsy(null);
const a = { whatever: 777 };
const b = a;
guarantee.same(a, b);
guarantee.identical(undefined, null);
const c = { whatever: { foo: { bar: 'baz' } } };
const d = Object.assign({}, c);
guarantee.deeplyIdentical(c, d);
function boom() { throw new Error('Some error...'); }
guarantee.throws(boom);
guarantee.throws(boom, 'Some error...');
});

The beforeEach function

How does it work?

  • Every time a group is declared, we’re pushing a new array to the beforeEachStack variable. This array will accumulate all beforeEach callbacks declared in that scope.
  • After a group execution is completed, we remove the array at the top of our callbacks stack.
  • The beforeEach function receives a callback and appends it to the array at the top of our callbacks stack.
  • At the beginning of each check function, we’re calling every beforeEach callback in all levels of our stack.

The beforeAll function

Otherwise, if we wanted to ensure that the beforeAll function works correctly even in the middle or at the end of a group, we should dramatically change our existing logic. Well, we’re not going to do that, since it isn’t a rational usage of this function.

Our version of beforeAll will just receive a callback and immediately execute it.

const beforeAll = cb => cb();module.exports = { 
group, check, xcheck, guarantee, beforeAll, end
};

An example of usage:

const { guarantee, check, group, beforeAll } = require('tyrion');let a;
beforeAll(() => {
a = { something: 'example' };
});
group('playing with the beforeAll function', () => {
let b;
beforeAll(() => {
b = { something: 'example' };
});
check('some test', () => {
guarantee.deeplyIdentical(a, b);
});
check('another test', () => {
guarantee.identical(11, 11);
});
});

The final version of Tyrion

I added a SILENT option which disables logging. It’s being used to make it easier to test Tyrion (yep, testing frameworks need to be tested too).

The complete project is available here.

Possible improvements

  • Support for async tests
  • Parallel execution of tests
  • afterEach and afterAll functions
  • A xgroup function, which disables an entire group
  • A function similar to Jasmine’s fit
  • Spies
  • Decoupling DSL from reporting logic.
  • Pluggable reporters
  • A terminal CLI (with a --watch option)
  • Yet more matchers
  • Friendlier error stacks

I encourage you to keep playing with this project. Feel free to use and expand it. Please let me know your thoughts, suggestions, and experiments by leaving a comment below. =)

freeCodeCamp.org

This is no longer updated.

Alcides Queiroz

Written by

JavaScript hacker, front-end engineer and F/OSS lover. ☞ github.com/alcidesqueiroz ☜ ☞ alcidesqueiroz.com ☜ ☞ twitter.com/alcidesqueiroz ☜

freeCodeCamp.org

This is no longer updated. Go to https://freecodecamp.org/news instead

Alcides Queiroz

Written by

JavaScript hacker, front-end engineer and F/OSS lover. ☞ github.com/alcidesqueiroz ☜ ☞ alcidesqueiroz.com ☜ ☞ twitter.com/alcidesqueiroz ☜

freeCodeCamp.org

This is no longer updated. Go to https://freecodecamp.org/news instead

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch

Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore

Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store