Test-Drive Development (TDD)

This is a phrase you’ve most definitely heard of. It’s a programming paradigm that enforces writing automated tests for your code before writing the code that is being tested. This way of writing code can provide you with a lot of benefits, such as:

  • reducing bugs in existing features,

  • making code less prone to bugs while adding new features,

  • making debugging easier,

  • forcing you to slow down and think before writing code,

  • making your code more stable.

This article will focus on teaching you the basics of writing unit tests rather than writing your code with a test-first approach. You can learn more about that here.

Unit Testing

There are different automated testing levels, such as unit testing, API or integration testing, GUI testing, end-to-end testing, etc. Here we will talk about unit testing. It involves writing isolated tests for all of your functions, modules, or components in such a way in which they would cover all of their possible outcomes (outputs). Among the benefits already stated, writing unit tests for functions will make it easier for other people to understand their purpose. Instead of figuring this out by reading the actual functions, they would read their tests in which their purpose is clearly shown. So unit tests also serve as complementary documentation.

Even if you have a clear idea of what you want to make, having a good unit test coverage will make you more confident when refactoring chunks of code or adding new functionality by ensuring that existing functionality hasn’t changed.

Testing Frameworks

Testing frameworks, also called test runners, make writing and running automated tests much easier than writing tests only with Vanilla JavaScript. They do this by providing you with a lot of out of the box functionality. They also make your test smaller, cleaner, and easier to understand.

There are a lot of testing frameworks out there. Some of the most popular ones are Jasmine, AVA, Tape, Jest, Mocha, to name a few. They all have their set of special features, but luckily they all have similar syntax, so choosing which one you’ll use doesn’t matter all that much. Here we will talk about Jest.

Jest

Jest comes with a wide API. In other frameworks, you would have to include additional libraries to use the functionality that Jest provides you with from the get-go. A good example of this is Mocha because it doesn’t provide us with an assertion library, which we use to define testing logic and conditions. When using Mocha, you would have to include something like Chai to have this functionality. Jest, on the other hand, has its own API for assertions.

Jest is a high-speed library due to its parallel testing. This doesn’t matter much for smaller projects, but this might be very important for larger projects. Facebook maintains Jest, so it is very well supported. With every new update, it improves considerably.

Setup

We can download Jest using package managers like Yarn or NPM:

1npm init
2npm install --save-dev jest

Notice that we save it as a development dependency.

Now let’s create a function that will find the average from an array of numbers and put it in its own file called getAverage.js :

1//getAverage.js
2const getAverage = array => {
3    const sum = array.reduce((acc, c) => acc + c, 0);
4    return sum / array.length;
5}
6
7module.exports = getAverage;

We have to use the modular way of importing and exporting files because Jest doesn’t support the ES6 import/export syntax. You can use Babel to make this work.

To test if our function returns expected results, we need to create a separate file called getAverage.unit.js. When we later run Jest from the command line, it will automatically look for every file that ends with .unit.js or .spec.js and run the tests inside those files.

1//getAverage.unit.js
2const getAverage = require('./getAverage');
3
4test('Calculating average', () => {
5    const input = [1, 2, 3, 4, 5];
6    expect(getAverage(input)).toBe(3)
7})

We first import getAverage from its file and then test its functionality with the test function. It takes a description string and a function from which it performs the test. We created an example input for which we expect to get a certain output. In this case, that output would be 3. As you can see, Jest has a pretty understandable syntax.

Notice that we didn’t have to import anything from Jest. That is because its functions are globally available. There is a big discussion among JavaScript programmers about polluting the global namespace, but that is not the focus of this article.

Now you can try running the test from the command line:

1npx jest

You should see something like this in your terminal:

Terminal output

This means that our function works since the test has passed.

It is common practice to use scripts to run our tests. We set them up in our package.json file:

1"scripts": {
2  "test": "jest"
3},

We can also make Jest automatically run all of our tests each time we save our files. We do this by using the --watch tag:

1"scripts": {
2  "test": "jest --watch"
3},

Now when you run npm test from the command line, Jest will start watching our code. There are shortcuts that you can control the watcher with. Note that this can only be used if you have git initialized in your folder.

Coverage

Good tests contain multiple test cases. When you are writing test cases, try to cover as many possible situations as you can. This is especially important when your functions have branching and thus, have multiple outcomes:

1//getAverage.unit.js
2const getAverage = require('./getAverage');
3
4test('Calculating average', () => {
5    const input1 = [1, 2, 3, 4, 5];
6    expect(getAverage(input2)).toBe(3)
7
8    const input2 = [6, 7, 8, 9, 10];
9    expect(getAverage(input2)).toBe(8)
10})

Jest provides us with tools that can help us test the coverage of our tests. You can use this tool using the --coverage tag when running your tests from the command line. This shows us the percentage of outcomes our tests cover, along with some other information. Currently, this will percentage will be 100% because getAverage doesn’t have different outcomes. Let’s change it so that it has two possible types of output:

1const getAverage = array => {
2    const sum = array.reduce((acc, c) => acc + c, 0);
3    const average = sum / array.length
4    return average < 5 ? "Average is smaller than 5" : average;
5}

The test suit will fail if you try to run it at this point because our first test expects 3 but will receive Average is smaller than 5. Let’s change it so that it expects the proper output:

1const getAverage = require('./getAverage');
2
3test('Calculating average', () => {
4    const input1 = [1, 2, 3, 4, 5];
5    expect(getAverage(input1)).toBe("Average is smaller than 5")
6
7    const input2 = [6, 7, 8, 9, 10];
8    expect(getAverage(input2)).toBe(8)
9})

Now when you run your test suit, all tests will pass, and you’ll get information about the coverage your tests provide:

Terminal output 2

If you comment out one of the expect statements, you would 50% code coverage:

Terminal output 3

Matchers

We use matchers from the expect API to check if certain values meet certain conditions. In our example, we used the toBe matcher. The way this matcher works is by using Object.is to test exact equality. It is used to compare primitive values or to check the referential identity of object instances.

Since it uses Object.is, we can’t use the toBe matcher to compare floating-point numbers. Instead we use toBeCloseTo:

1test('Adding floating point numbers', () => {
2    const value = 0.1 + 0.2;
3    //expect(value).toBe(0.3); This won't work because of rounding error
4    expect(value).toBeCloseTo(0.3);
5});

We also can’t use it to compare objects. For this, we use toEqual. This and toBe are equivalent for numbers.

For values like undefined, null, and false, we use helper matchers such as toBeNull, toBeUndefinded, and toBeDefined.

You can also use matchers like toBeTruthy and toBeFalsy. These match anything that if statements treat as true or false, respectively.

For strings, you can use toMatch, which takes a regular expression as an argument.

1test('There is a bar in baracuda', () => {
2    expect('baracuda').toMatch(/bar/);
3});

For arrays and other iterables, you can use toContain to check if they contain certain values:

1const input = [1, 2, 3, 4, 5];
2test('Input contains a 3', () => {
3    expect(input).toContain(3)
4})

We can use not to test the opposite:

1test('Calculating average', () => {
2    const input = [1, 2, 3, 4, 5];
3    expect(getAverage(input)).not.toBe(3)
4})

There are also matchers which are used to compare numbers:

1const input = 8;
2test('Comparing numbers', () => {
3    expect(value).toBeGreaterThan(5);
4    expect(value).toBeGreaterThanOrEqual(8);
5    expect(value).not.toBeLessThan(7); //same as toBeGreaterThan(7)
6    expect(value).toBeLessThanOrEqual(8.5);
7});

Conclusion

As you can see, testing is pretty simple and easy to understand. There are discussions online that talk about if testing is even necessary for your code. The main reason for this is that it can take a lot of your time hence decreasing productivity. This is true, but writing unit tests do payout in the long run. You will be able to refactor your code or add new functionality much quicker than you would without them. Debugging will also be easier, and your code, in general, will become easier for other people to understand.

Jest comes packed with much more functionality which you can utilize in your projects, such as mock functions, snapshots, or setup helper functions like beforeEach and afterEach. All of these are explained in the official documentation.

If you would like to see a more detailed explanation of test-driven development, I suggest checking out this playlist from Fun Fun Function.