• Social

Introduction to unit testing, test driven development

Put simply, test driven development is writing tests for your production code, before you’ve actually written your production code. For example, if we were writing a function to convert inches to centimetres, we would write a test that calls our function first and then we write the function to past those tests. Thinking in this order helps clarify our intentions and helps write more robust, maintainable, bug free code. There are many other advantages and also a few disadvantages.

Advantages of TDD

  • Ensures quality software
  • Forces us to clarify our thoughts
  • Improves communication between developers
  • Improves the structure of our code. It helps promotes more loosely (coupled aka modular aka functional) code.
  • Allows developers to make changes without worrying – the test with flag breaks you make.

Disadvantages of TDD

  • Takes longer. This isn’t really a valid disadvantages as it will save time in the long run.
  • Management aren’t always happy with taking longer to right code.
  • It’s possible to write bad tests, which can create a false sense of security.

Clearly the advantages outweigh the disadvantages and that we should be using TDD, lets summarise the criteria of a good test.

Criteria of a good test.

  • Readable – Make it clear what your code is supposed to do.
  • Isolated – Ensure our tests are isolated.
  • Thorough – Test for edge case inputs as well.
  • Explicit – All the information is made readily available to test.

The tests

There are three levels of test driven development.

  • Unit tests – the most common tests. Low level, specific tests for individual bits of functionality
  • Integration tests – ensures these individual pieces work together correctly. e.g. make sure app can talk to API.
  • End to end tests – ensures the app works from the perspective of the user, e.g. you are testing the user experience.

This article focuses solely on unit tests but it’s worth knowing that their are testing patterns for the more complex functions of our application. There are a number of JavaScript libraries that can help with TDD and they cover one or more of the following areas.

  1. A testing environment / test runner
  2. A testing framework
  3. An assertion library

Mocha JS covers the first two on the list, Chai covers the last. Jasmine and Jest on the other hand cover all three. This article will use the popular Chai as our assertion library.

Process of writing a test

In order to write a test we should follow a three step process and continue this process all the way un till we have a full piece of software.

  1. Write a failing test – forces us to define the functionality we want to add and avoid false positives.
  2. Write production code to make the test pass – write just enough code to make the test we just wrote pass.
  3. Refactor the code – Once we have our production code passing, we can refactor.

Writing a test

Before starting make sure to pull down the code from my GitHub repo via the link below. Once you have pulled the code open the folder up in a terminal and run npm intall. This will install node_modules for you to run the code.

Codebase

For the sake of this lesson we are writing a function that counts the amount of each letters in a string called getLetterCount().  If we pass the function the string ‘cat’, we would expect a returned object of {c:1, a:1 , t:1} and if we passed it the string ‘better’ we would expect {b:1, e:2, t:2, r: 1}.  Lets start by writing our first test.

import { expect } from 'chai'
import { getLetterCount } from './letter-count.js'

// describe the test test is for.
describe('getLetterCount - basic functionality', () => {
    // test for empty strings
    it('returns an empty object when passed an empty string', () => {
        const expected = {}
        const actual = getLetterCount('')
        // we use .deep because it's an object which 
        expect(actual).to.deep.equal(expected)
    })
})

As you can we have described our initial test, using a chai function, describe(), where the first parameter is a description of the test and the second is a function which is where we add the list of tests we need it to pass.

The first test is to check it returns an empty object, when passed an empty string. We start by writing our expected and actual results, and then use chai to compare these two. It is written in plain English as to make it more legible. We can see we are expecting our array, to equal our given expected value. Note we have to use deep because we’re passing in an object and not a string.

We then want to go ahead and create the actual function in our letter-count.js file where it takes a string, splits it into an array and then we reduce it too a new object with the letter count. Notice that in the below code we are simply returning false, in order to give us a failing test the first time around.

export const getLetterCount = string => {
 return false;
}

 

From there, we can run npm run test in our console to test to see that our test fails. This helps us avoid false positives. Now lets refactor the function to make it do what we want to see if we can get it to return a passed test.

export const getLetterCount = string => {
    // use split in our function to 
    const letters = string.split('')

    // reduce our letters array to a new object
    const letterCount = letters.reduce((newObject, letter) => {
        newObject[letter] = (newObject[letter] + 1) || 1
        return newObject
    }, {})

    return letterCount
}

When running it we will see that is does indeed return an empty object when we pass it an empty string and our test has passed.

Lets add a couple more of these test whereby we want to check the robustness of it in the case of a more complex string is added. Let’s check if the string cat, returns {c:1, a:1 , t:1} and better returns {b:1, e:2, t:2, r: 1}.

import { expect } from 'chai'
import { getLetterCount } from './letter-count.js'

// describe the test test is for.
describe('getLetterCount - basic functionality', () => {
    // test for empty strings
    it('returns an empty object when passed an empty string', () => {
        const expected = {}
        const actual = getLetterCount('')
        // we use .deep because it's an object which 
        expect(actual).to.deep.equal(expected)
    })

    // test for a simple string
    it('return the correct letter count for a word with only one of each letter', () => {
        const expected = { c: 1, a: 1, t: 1 }
        const actual = getLetterCount('cat')
        expect(actual).to.deep.equal(expected)
    })

    // test for a more complex string
    it('return the correct letter count for a word with multiple of each letter', () => {
        const expected = { b: 1, e: 2, t: 2, r: 1 }
        const actual = getLetterCount('better')
        expect(actual).to.deep.equal(expected)
    })
})

Both times are test has passed, which means our function is robust and we can be confident that our new function will be suitable for production ready code.

Conclusion

Unit testing is a great way to ensure you think about the code you are writing and clarify exactly what you need to achieve. It means you can be confident that bugs are ironed out sooner rather than later, saving a lot of time and money in the long run. Generally speaking any successful software company will have these principles at its core and so if you are looking to become a senior developer you will need to know these well. Good luck testing!

Scroll Up