Looking for Senior AWS Serverless Architects & Engineers?
Let's TalkWe will use Mocha and Chai to do unit tests and talk about the importance of testing.
In this article, we are going to dive into unit testing and learn about how to use Mocha and Chai to write our tests. We will also take a look at how to do granular testing, an important concept when you have hundreds or thousands of unit tests. We will also talk about why testing is important.
Overview
- What is unit testing?
- Mocha — what, how, run, result
- Chai — what, how, run, result
- Testing a real NodeJS function — code, test, run, result
- Granular testing with the Mocha CLI — test a single file, test a group of tests or a single test
- Testing Opinion — granular testing, testing & code quality
- Testing Asynchronous functions
- Review
What is unit testing?
“UNIT TESTING is a level of software testing where individual units/ components of a software are tested. The purpose is to validate that each unit of the software performs as designed. A unit is the smallest testable part of any software. It usually has one or a few inputs and usually a single output. In procedural programming, a unit may be an individual program, function, procedure, etc.” — Software Fundamentals, Unit Testing
If you want to learn more about unit testing at a high level. Definitely, take a look at the link above.
Mocha (a testing framework for NodeJS)
What is Mocha?
Mocha is a testing framework for NodeJS which allows developers to easily test their code. Mocha has a CLI that we talk about below and is full of helpful features. Mocha is one of the most popular NodeJS testing frameworks and as a result, there are lots of tutorials and documentation.
How to install Mocha?
# global install
npm install mocha -g
# project install
npm install mocha --save-dev
How to use Mocha?
- create a directory called test at the root of your project
- create a test file under tests called example.test.js
- paste in the following snippet
const assert = require('assert');
describe('Simple Math Test', () => {
it('should return 2', () => {
assert.equal(1 + 1, 2);
});
it('should return 9', () => {
assert.equal(3 * 3, 9);
});
});
Breakdown of the code snippet
- describe — a logical grouping of tests, “Simple Math Test”
- it — a single test, “it.. should return x”
- assert — how you validate your test works or fails, “assert.equal(1+ 1, 2)”
Update package.json
- open package.json
- change the “scripts” block to the following code snippet
"scripts": {
"test": "mocha"
}
Run test
npm run test
Result
Break tests on purpose
Now let’s adjust test/example.test.js to fail. Update your test to the following code snippet.
const assert = require('assert');
describe('Simple Math Test', () => {
it('should return 2', () => {
assert.equal(1 + 1, 2);
});
it('should return 9', () => {
assert.equal(3 * 2, 9);
});
});
Rerun the test
npm run test
Result
Chai (testing in English)
What is Chai?
Similar to Mocha assert.equal() we can use Chai to write tests like English sentences.
How to install Chai?
npm install --save-dev chai
How to use Chai?
const expect = require('chai');
...
expect(1 + 1).to.equal(2);
expect(isTrue).to.be.true;
You can also use should all though we won’t get into it in this article.
const should = require('chai').should();
...
isTrue.should.equal(true);
Update our test file
Now we can update our test/example.test.js file with the following code snippet.
const expect = require('chai').expect;
describe('Simple Math Test', () => {
it('should return 2', () => {
expect(1 + 1).to.equal(2);
});
it('should return 9', () => {
expect(3 * 3).to.equal(9);
});
});
Rerun the test
npm run test
Result
Alright, now let’s do this with a real NodeJS function.
Testing a real NodeJS function
Create a NodeJS function
We are going to make this really simple and not have a nested folder structure. Let’s do the following.
- Create a file called math.js at the root of your project
- Copy in the following code snippet
const math = {};
math.add = (num1, num2) => num1 + num2;
math.multiply = (num1, num2) => num1 * num2;
module.exports = math;
Create a test file
Now we need a new test file to handle validating that our math.js code is working properly.
- Create a file called math.test.js under the testdirectory
- Copy the following code snippet
const expect = require('chai').expect;
// import math file
const math = require('../math');
describe('math.js tests', () => {
describe('math.add() Test', () => {
it('should equal 2', () => {
const result = math.add(1, 1);
expect(result).to.equal(2);
});
it('should equal 4', () => {
const result = math.add(2, 2);
expect(result).to.equal(4);
});
});
describe('math.multiply() Test', () => {
it('should equal 3', () => {
const result = math.multiply(3, 1);
expect(result).to.equal(3);
});
it('should equal 10', () => {
const result = math.multiply(5, 2);
expect(result).to.equal(10);
});
});
});
Notice how we put an outer describe() at the very top. This will affect how the test result looks in the terminal and make it easier to parse.
Rerun the tests
npm run test
Result
If you would like to see the tests fail, try passing a different number!
Granular testing with the Mocha CLI
Test a single file independently
As I’m sure you noticed above, we are seeing both the tests from example.test.js and math.test.js when we run npm run test. What if we want to only test math.test.js?
npm run test /path/to/test
Rerun the test
npm run test test/math.test.js
Result
Test a single set of tests inside a file independently
Now that we know how to test everything and test a single file. What if we want to test a single set of functions inside our desired test file.
Well for this we need to use the Mocha --grepoption. This will allow us to search our tests for a specific string matching the set of tests we want to run. This is made even easier using describe() because it naturally isolates a set of tests.
npm run test -- --grep "searchable string"
Let’s target the math.add() tests only. Since we named our describe() function as describe('math.add() ..') this will be easy.
Rerun the test
The initial -- allows us to reuse our npm run test command that we set up in our package.json and then we use --grep to isolate the tests.
npm run test -- --grep "math.add()"
Result
There we go now we are targeting a specific describe() inside our tests.
Test a single, it, test
Similar to the above we can just copy the --grep command and use a string which equals the single it() test.
Let’s target the first test inside describe('math.add() ..') . This would be the it('should equal 2') . However, note that if we had multiple tests with the same string e.g. should equal 2 we would have some cross-over.
Rerun the test
npm run test -- --grep "should equal 2"
Result
Testing Opinion
Why am I harping on how to test at a granular level?
It will help combat the common objections to testing. From personal experience when a testing suite grows, it becomes a nightmare to understand.
Well, when it comes to testing, most developers don’t care for it as much as writing the code. Some developers don’t care about it at all.
On the surface, testing can slow down development. However, if you think from a more macro-level you can easily see how having a full test suite alongside your code has a massive long term benefit.
For instance, when new developers join your team. When projects run for 6+ months with new code constantly being added.
What does testing encourage?
Testing complex spaghetti code is a nightmare. Having a mindset where tests are a requirement, not a “nice-to-have” will make you as a developer write more modular and reusable code. As you will avoid creating nightmares for yourself by default.
Testing and code quality
Once you have a test suite of any size you can immediately start using the tests to reduce the number of small errors being pushed into production. The tests will act as a baseline for your entire team to work from.
Meaning that prior to every pull request, deployment, or code review. You have the baseline established. My code is passing all tests. This saves time, money, and gives confidence that new code (on the surface) didn’t break old code.
Testing Asynchronous functions
Well, first off let’s talk about the history of promises in JavaScript.
History of Asynchronous JavaScript
I would highly recommend reading this article to get insight into the progression of Asynchronous JavaScript. It does a wonderful job showing code snippets and helping set the stage for why Async/Await is so powerful.
- Callbacks — functions get passed a “callback” function and when the called function finishes executing it uses the “callback” to return
- Promises — wrapper on functions with a simplified syntax, resolve/reject in the called function to handle success/failure and .then()/.catch() in the outer function to handle the success/failure of the called functions. Easy to chain functions together using multiple .then().then().then().
- Async/Await — released with ES8, a simplified syntax that removes some of the overhead with promises. The new syntax async will mark the function as asynchronous. Then you can simply say await function() instead of doing function().then() .
How to test asynchronous code?
Let’s use the math.add() function to test and let’s replace the math.js file with the following code snippet.
const math = {};
// math.add = (num1, num2) => num1 + num2;
math.add = async (num1, num2) => await num1 + num2;
// math.multiply = (num1, num2) => num1 * num2;
math.multiply = (num1, num2) => new Promise((resolve, reject) => {
resolve(num1 * num2);
});
module.exports = math;
As you can see we kept the previous non-async version and added in the async version below. What we can see is two variants, the math.add() is using traditional promises with resolve/reject and the math.multiply() is using async/await syntax.
Both math.add() and math.multiply() are effectively the same to our test suite. As you see below we can choose to use async/await or .then() syntax inside our tests. My preference is async/await, even if the function we are testing was written using resolve/reject.
Now let’s update our math.test.js file with the following for math.add().
it('should equal 2', async () => {
const result = await math.add(1, 1);
expect(result).to.equal(2);
});
You can see we are now using async/await to stop test execution until we get a result from math.add().
Rerun the test
npm run test -- --grep "should equal 2"
Result
Let’s take a look at the same function, but using .then().
it('should equal 2', () => {
math.add(1, 1).then(result => expect(result).to.equal(2));
});
If we were using callbacks instead of promises we would use the Mocha done() syntax, however, I’m not going to get into that here as that is not how we should be writing code nowadays (IMO).
Rerun the test
npm run test -- --grep "should equal 2"
Result
This is great, we’ve been able to see the similarities between async/await and resolve/reject, but what about when it comes to testing error handling?
How to test asynchronous error handling?
Above, we showed what it looks like when our functions return a successful response. However, we need to also have tests which confirm that our error handling is working properly, e.g. catching errors.
Let’s modify the math.add() function inside themath.js file to throw an error if an argument isn’t passed to the function. We will do this using a try/catch block and async/await.
math.add = async (num1, num2) => {
try {
if(num1 && num2) {
const result = await num1 + num2;
return result;
} else {
throw 'missing arg';
}
} catch (err) {
throw err;
}
};
Now let’s create a new test which only passes a single argument instead of two numbers. This will play into the new if/else statement which checks to see if both arguments were passed, added above.
it('should throw an error', async () => {
try {
await math.add(1);
} catch (error) {
expect(error).to.equal('missing ar');
}
});
As you can see we’ve added a try/catch block that allows us to handle the error and we are checking for the error message. However, we’ve put a typo on purpose.
Rerun the test
npm run test -- --grep "should throw an error"
Result
As you can see from the output above our test was looking for the error to be “missing arg”, but we were checking for “missing ar”. Let’s update this.
it('should throw an error', async () => {
try {
await math.add(1);
} catch (error) {
expect(error).to.equal('missing arg');
}
});
Now let’s rerun the test.
Rerun the test
npm run test -- --grep "should throw an error"
Result
Alright, that’s all for now. In future (planned) articles we will dive into integration testing and then move into how to write unit tests and integration tests for serverless.
Review
In this article we covered a lot of topics, here is the full list.
- What is unit testing?
- Mocha — what, how, run, result
- Chai — what, how, run, result
- Testing a real NodeJS function — code, test, run, result
- Granular testing with the Mocha CLI — test a single file, test a group of tests or a single test
- Testing Opinion — granular testing, testing & code quality
- Testing Asynchronous functions — plus, error handling!
- Review
I hope that you found this useful and I hope this helps to de-mistify some of the gaps I had when first learning how to unit test.