Unit testing frameworks

Today we get an introduction to unit testing frameworks, and learn how to write and run tests in a popular framework with an assertion library.

Table of Contents

What is a Unit Testing Framework?

A testing framework is… well, it could be a lot of things, depending on the type of testing you're doing, the types of tools or SPA frameworks you're using, and how much testing you're doing overall.

Today we're just looking at the most common type of test that developers need to write - unit tests. We've looked at them before, talking about when, why and how we write them. We'll look at how other types of testing integrate into our workflow next week.

When I was thinking about which framework we should try out today, I started by looking at the usage statistics from the most recent State of JS survey Opens in a new window.

Now, obviously we're not going to do end-to-end testing, as that's done when you have the requirements for a whole application, and it's typically done as integration testing (meaning it's done on the remote branch, not on your local machine). A bit hefty and abstract for an introduction. So that lets us rule out Cypress Opens in a new window, and Playwright Opens in a new window.

We don't need browser automation (we don't have any user stories!), so no need for webdriver.io Opens in a new window or Puppeteer Opens in a new window.

We're not trying to co-ordinate a bunch of different testing libraries at once, so that's testing library Opens in a new window taken care of.

We're not trying to manage a bunch of framework-generated components, so we'll pass on Storybook Opens in a new window.

And honestly, Ava Opens in a new window has never caught on widely, probably because its syntax isn't super-approachable.

That leaves us with three unit-testing frameworks that have all been in the top 4 testing frameworks for the past 5 years: Mocha, Jest, and Jasmine.

Today we'll look at Mocha. It's lightweight, flexible, and has been one of the top-two frameworks for the last half decade.

Jest is great, but is very much tied to React (they're both developed by the Facebook team).

Jasmine is powerful, but is more oriented to full-time QA work, and has more utilities than most developers need in their daily work.

That being said, I'm not terribly worried about teaching you the "wrong" framework out of these three. Want to know why?

Syntactical similarities between major unit testing libraries

const myBoolean = true;
Jasmine syntax
describe("Example test", function() {
  it("should be true", function() {
    expect(myBoolean).toBe(true);
  });
});
Jest syntax
describe("Example test", function() {
  test("should be true", function() {
    expect(myBoolean).toBe(true);
  });
});
Mocha syntax
describe("Example test", function() {
  it("should be true", function() {
    expect(myBoolean).to.be(true);
  });
});

Yeah, so, I'm pretty sure regardless of which framework we learn today, you'll be able to adapt to your team's preferred option.

Intro to Mocha

Let's jump into Mocha! Mocha is an open-source project that was started ten years ago by a developer from Victoria, British Columbia (who is so prolific that there are conspiracy theories about him - seriously, Google "TJ Holowaychuk").

Mocha is fairly straightforward, so let's jump into a project.

Commands to set up a node project with Mocha

# make a new folder
mkdir mocha-demo
# change directories into the folder
cd mocha-demo
# initialize a node project
npm init

At this point, npm will prompt you for the info about your project that goes into your package.json file.

After you work through the npm init prompts...

# install mocha
npm install --save-dev mocha
# make a folder for our tests
mkdir test
# make a file to write our first test in
touch test/my-test.js

Assuming you accepted the defaults when you ran npm init, your package.json file probably looks something like this now:

{
  "name": "mocha-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "mocha": "^9.0.0"
  }
}

Let's add the line that says we're using JavaScript modules, and set our test script to run the command mocha

{
  "name": "mocha-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "mocha"
  },
  "type": "module",
  "author": "",
  "license": "ISC",
  "dependencies": {
    "mocha": "^9.0.0"
  }
}

Writing our first test

Let's write and run our first test!

Paste the following code into my-test.js:

import { equal } from 'assert';
 
const myArray = [1, 2, 3];
 
describe('myArray', () => {
    it('should not contain the number 4', () => {
      equal(myArray.indexOf(4), -1);
  });
});

Now in the console, run the command:

npm test

…and you get something like this in return:

The output of our first test - with a green check mark

The test directory

The /test/ directory is special, as Mocha assumes that that's where you're keeping your tests. (You could use a different directory, but you'd have to pass that in as an argument when you run the mocha command, which at this point is an unnecessary complication.)

Files in the tests directory are regular JavaScript files (that happen to contain unit tests). By default, Mocha will run any JS files it finds in the /test/ directory.

Now let's talk about the code in /my-test.js

Mocha doesn't have an assertion library

What the heck is an assertion library? Don't worry, we'll talk about that in a moment. But do you remember console.assert()?

An assertion library is like that - something that provides functions into which we can pass an argument that evaluates to true or false, and then return some information based on that. Pretty simple idea - true? yay! false? boo!

Just like the browser console, Node comes with an assertion library called 'assert'. That's what we're importing in the first line of /my-test.js.

The equal method from Node's assert library is a function that returns true if the first and second values are equal to one another, roughly equivalent to:

function equal(a, b) {
  if (a === b) {
    return true;
  } else {
    return false;
  }
}

After we import the equal method from Node's 'assert' library, we declare an array. This isn't typical - usually we'll want to keep our application code, i.e. the stuff we'll actually deliver to the user in their browser, in a separate folder. We'd import the code we'd want to test into our test scripts. Since we're just starting out, though, let's keep things easy to read by having everything in one file.

Suites with describe()

The next thing we do is use the describe() function. This creates what's known as a 'test suite'. This sounds fancy, like a honeymoon suite. In fact, at least for our purposes, this is just an organizational tool for grouping our tests together and making our output readable. Functionally, a suite is a category we define for our test(s).

The first argument is the suite name, and the second argument is a function in which we run our test case.

describe('mySuiteName', () => {
    // test case goes here
});

You can have a bunch of suites in your test file, and you can nest them!

describe('Arrays', () => {
    describe('What arrays should contain', () => {
        describe('myArray', () => {
            it('should not contain the number 4', () => {
                equal(myArray.indexOf(4), -1);
            });
        });
        describe('A different array', () => {
            it('should not contain the number 3', () => {
                equal(aDifferentArray.indexOf(3), -1);
            });
        });
    });
    describe('How long arrays should be', () => {
        describe('A different array', () => {
            it('should have a length of 2', () => {
                equal(aDifferentArray.length, 2);
            });
        });
    });
});
The output of nested suites
Nested suites - not just a Hotel for Birds!

Test cases

The it() function is the bones of our testing - our test case.

A test case takes two arguments - a description of the test, and a function which contains our assertion.

(Technically, you could have multiple assertions in a test case, but that's not a good idea unless you've got a good reason for it. Stick to one test:one case until you're really comfortable with this stuff - it'll make your reporting easier to read and understand.)

Checkmark

Test cases are what give you those sweet, sweet green check marks in your reporting.

What is an Assertion Library?

As we mentioned before, Mocha doesn't come bundled with an assertion library. Well, what the heck is an assertion library?

An assertion library is some code that lets us use different syntax in our assertions.

Basically, they let us write our assertions the way we want (which usually means making them really easy to read).

Our assertion library should match our assertion style.

Assertion Styles

Your assertion style is simply the syntax with which you code your tests. Do you prefer this:

expect(foo).to.be.a('string');

… or this …

foo.should.be.a('string');
..?

That being said, styles usually categorized as belonging to one "testing approach". Testing approaches are descriptions of practices in testing, which are just fancy acronyms for some good ideas.

Test-Driven Development

Test-driven development (TDD) is something people like to hear when they're interviewing you for a job. It's a simple idea:

  1. write the test,
  2. fail the test,
  3. write your code,
  4. pass the test,
  5. stop writing code,
  6. repeat.

The import part here is #5 - as soon as you've written code that passes the test, you stop writing code. If your code doesn't matter to your test, don't write it.

Implicit in this is that you're only writing tests for your project requirements, and if you're writing code that doesn't matter to the project requirements, then why are you writing that code?

Behaviour-Driven Development

Behaviour-driven development (BDD) is test-driven development where the test condition is a user behaviour. It assumes that you're collaborating with people outside the development team (i.e. managers, UX/UI, QA) to develop and test project requirements based on how users use things.

As such, assertion styles that are categorized as "BDD" have syntax that is much closer to how the requirements would be written out in plain language.

Various Assertion Libraries

Mocha works with just about any assertion library written in JavaScript.

Here is an incomplete list of assertion libraries you could use with Mocha:

By far the most popular, however, is Chai Opens in a new window. That's what we'll look at today.

Intro to Chai

Chai is an assertion library that actually provides us several different assertion styles - assert Opens in a new window, expect Opens in a new window, and should Opens in a new window.

We'll be looking at expect today, seeing as…

Installing Chai

Install chai in our /mocha-demo/ the same way we install any node package that is a development dependency:

npm install --save-dev chai

Yup, that's it.

Using Chai

Let's create a new file in which to write a test using chai. (You don't have to use the command line for this, but it's fast, easy and looks cool, so why not!)

touch test/my-chai-test.js

Inside /my-chai-test.js, we'll import expect from chai, create a test suite (just like before), a test case (again, same as before), and an assertion using chai's expect assertion style.

import { expect } from 'chai';
 
describe('myChaiArray', () => {
    it('should be an array', () => {
        expect(myChaiArray).to.be.an('array');
    });
});

What happens when we run our npm test command?

Chai test failing, but others succeeding.
Don't worry, I've seen much worse.

Two things to address here:

  1. We're seeing the output of all the tests contained in all the test files in our /test folder.
Can I just run one test file?

Yah, sure, just specify the file after `npm test` like this:

npm test test/my-chai-test.js

Typically, though, we run all our unit tests all the time, because they each take a few milliseconds, and if you broke something, you want to know ASAP.

  1. We're failing our chai test because of an error:
    ReferenceError: myChaiArray is not defined

Well that seems pretty easy to figure out - we're trying to test something that doesn't exist! If you're doing TDD (or, by extension, BDD), this is where all your tests will start - testing something that doesn't exist yet. The next step is, naturally, to make it exist.

If we add myChaiArray to our my-chai-test.js file, we'll pass our test!

// Add this before your test
const myChaiArray = [1, 2, 3];

Chai's BDD syntax

We're not going to go through everything in the Chai expect/should API Opens in a new window today because:

  1. it's a lot,
  2. most things are pretty easy to understand just from the name, and
  3. you'll get a chance to explore them yourself in the lab work.

That being said, there's a few things I want to talk about.

Syntactic sugar

Sometimes computer languages add words or symbols that have no impact on how the code is executed, but make code easier to read and write (for humans).

Since one of the principles of BDD is readability, Chai's BDD syntaxes (expect and should) contain a lot of syntactic sugar.

This:

expect(myArray).to.be.an('array');

…gets executed the same as this:

expect(myArray).an('array');

expect has a bunch of these "chains" - words that are meant to make our expressions more easily understood by humans.

BDD chains (syntactic sugar words)
  • to
  • be
  • been
  • is
  • that
  • which
  • and
  • has
  • have
  • with
  • at
  • of
  • same
  • but
  • does
  • still
  • also

Things to test with Chai

One of the benefits of unit testing is the disciplined coding it encourages - keeping your functions small, simple and predictable. This encouragement happens by having you articulate the outcome of your code in your tests. So what can those outcomes be?

Testing functions

In Chai (and Mocha)'s documentation, you'll mostly see simple statements tested, like this:

expect(2).to.equal(2);

While it's perfectly fine to test arrays and objects if you want to ensure data integrity, usually what you want to test in unit testing is the outcome of functions.

const myFunction = (input) => input + 2;
 
describe('myFunction', () => {
    it('should be a number', () => {
        expect(myFunction(2)).to.be.a('number');
    });
});

Keep this in mind when you go to write your tests, and continue reading through the documentation.

Testing for booleans

Here's an easy one for you - true and false.

const whyIsThisEvenAFunction = () => true;
 
describe('Why is this even a function?', () => {
    it('should be true', () => {
        expect(whyIsThisEvenAFunction()).to.be.true;
    })
});
Testing for content

There are several different conditions we can test for in our content: equal, least, most, within, include, above, below, etc.

equal is the most common - very useful for both numbers and strings.

const myStringFunction = (name) => `Hi! My name is ${name}`;
 
describe('myStringFunction', () => {
    it('should equal "Hi! My name is Simon"', () => {
        expect(myStringFunction('Simon')).to.equal("Hi! My name is Simon");
    });
});

include is another handy one, especially for testing arrays:

const myArrayFunction = (newItem) => {
    const punkMovies = [
        'Ladies and Gentlemen, The Fabulous Stains', 
        'Out of the Blue', 
        'Hard Core Logo', 
        'Green Room', 
        'Tank Girl'
    ];
    punkMovies.push(newItem);
    return punkMovies;
}
 
describe('myArrayFunction', () => {
    it('should add a new movie', () => {
        expect(myArrayFunction('Repo Man')).to.be.an('array').and.to.contain('Repo Man');
    });
});
Testing for types

We've already seen an example of testing the type of value returned. You can test for all sorts of things! Strings, numbers, objects, arrays, promises, etc.

It's considered a good practice to test the value type before testing for the content by chaining these conditions together:

describe('myFunction', () => {
    it('should be a number that equals 4', () => {
        expect(myFunction(2)).to.be.a('number').and.to.equal(4);
    });
});
Testing negatives

For basically any test, you can add .not to your chain and test for the opposite.

const whyIsThisEvenAFunction = () => false;
 
describe('Why is this even a function?', () => {
    it('should be not true', () => {
        expect(whyIsThisEvenAFunction()).to.not.be.true;
    })
});

This is usually a bad idea - test for what things are, not for what things aren't whenever possible. Sometimes it's appropriate, but this is one of those things that you should only do when you've got a good justification for it.

Custom error messages

expect() can take a second argument after the value you're testing - a custom error message.

const whyIsThisEvenAFunction = () => false;
 
describe('Why is this even a function?', () => {
    it('should be true', () => {
        expect(whyIsThisEvenAFunction(), "This is supposed to be true!!!").to.be.true;
    })
});

This can be handy when reading the output of your errors:

Output with custom error message
So dramatic

Okay, that should be enough to get you started! Unit testing and test-driven development are things that make life easier once you get into the habit, so we're going to write a whole bunch for our lab.