Writing tests for your software is an established practice in most development environments nowadays. It also makes a lot of sense. Code that is subject to changes and changes will need some sort of plan for knowing it still does what is it actually expected to do. Tests do that for you.

Writing tests for your software is also a vessel for excessive nit-picking and countless discussions about the “one true way”. Testing snobism is a real thing. The multitude of approaches, even philosophies and the abundance of tooling makes the concept inaccessible for beginners, occasional programmers and self-taught makers.

Fact is, many people - including myself - will still start their programming career driven by the simple requirement that they need something that is not there yet. So they build it, they iterate on it. They shouldn’t have to setup a fully automated test suite for everything when they just started.

I think the fundamentals of testing and writing testable code can be taught without relying on test runners, frameworks, assertions libraries and whatever advanced concepts are out there. Those can be introduced on large codebases that will be worked on by a team. But when just starting, keep it simple.


What is testing?

The basic idea behind testing is writing an additional piece of software that will interact with your actual software. It defines a set of behaviors that you expect from your software, and when run checks if these still hold true. This way you can check if changes to your code will break things in places you didn’t expect it to break.

What is a test?

The most basic concept every test will contain is an “assertion”. If the assertion holds true your test passes. If your assertion proves false, your test fails. You can write a simple assertion function in a few lines:

function assert(condition, errorMessage) {
  if (!condition) {
    throw new Error(errorMessage);
  }
}

This way you can check if the tests you are performing on your code pass by checking if running your code throws an error:

function numberEnhancer(number) {
  if (number === 7) {
    return 'Lucky number ' + number;
  }
  return '' + number;
}

// now let's run a few tests
var result1 = numberEnhancer(1);
assert(result1 === '1', 'Expected numberEnhancer(1) to return 1');

var result7 = numberEnhancer(7);
assert(result7 === 'Lucky number 7', 'Expected numberEnhancer(7) to return Lucky number 7');

var result8 = numberEnhancer(8);
assert(result1 === '8', 'Expected numberEnhancer(8) to return 8');

console.log('All tests passed!');

Running the above code will not throw an error, so it passes and prints the success message. If you now decide to change your code and make a mistake while doing so (for example forgetting that numberEnhancer is expected to always return a string), your tests will throw an error and you know that something is broken:

function numberEnhancer(number) {
  switch (number) {
  case 7:
    return 'Lucky number ' + number;
  default:
    return number;
  }
}

var result1 = numberEnhancer(1);
assert(result1 === '1', 'Expected numberEnhancer(1) to return 1');  // this will now throw an error

console.log('All tests passed!'); // this will not be logged

Now you can go back and change the code until it will pass the tests again. Or - if the requirements have changed - you can change the tests to reflect your updated requirements.

In these examples we defined a custom assert function so we know what is going on under the hood. Luckily, all newer browsers do contain such a function already, so when working, we do not need to define it ourselves. It’s called console.assert and you can use it like this:

var a = 7;
var b = 7;
console.assert(a === b, 'Expected a to equal b');

The nice thing about it is also that unlike our custom assert function it will not stop the execution of the code, so you can still see if the tests defined after the first failing one will pass or fail.

Functions

The easiest unit of code that can be tested is a function. Ideally, a function acts like a vending machine: you give it some input and - if working correctly - it will always return the same value on the same input. Assume we have a coded vending machine that will sell pieces of bubblegum, 5 cents each:

function bubblegum(centsGiven) {
  var piecesGiven = 0;
  var changeReturned = centsGiven;
  
  while (changeReturned >= 5) {
    piecesGiven = piecesGiven + 1;
    changeReturned = changeReturned - 5;
  }
  
  var output = {
    pieces: piecesGiven,
    change: changeReturned
  };

  return output;
}

This function will always do the same depending only on the number of cents given. This behavior is also called “pure”. The function has no connection to the outside world, it is free of “side effects”. Functions like this make for easy testing as you can just compare the output with what you expect for the given input.

var result1 = bubblegum(12);
console.assert(result1.pieces === 2, 'Expected 2 pieces of bubblegum');
console.assert(result1.change === 2, 'Expected 2 cents of change');

var result2 = bubblegum(3);
console.assert(result1.pieces === 0, 'Expected 0 pieces of bubblegum');
console.assert(result1.change === 3, 'Expected 3 cents of change');

You could now even start defining a list of input / output pairs and have the computer do all the work:

var tests = [
  { input: 0, pieces: 0, change: 0},
  { input: 40, pieces: 8, change: 0},
  { input: 2, pieces: 0, change: 2},
  { input: 13, pieces: 2, change: 3}
];

for (var i = 0; i < tests.length; i++) {
  var result = bubblegum(tests[i].input);
  console.assert(result.pieces === tests[i].pieces, 'Expected ' + tests[i].pieces + ' pieces of bubblegum');
  console.assert(result.change === tests[i].change, 'Expected ' + tests[i].change +  ' cents of change');  
}

You probably noticed that code like this is very easy to test. It’s almost boring. That’s actually a good sign. If your code is free of side effects it will be easy to predict and easy to understand when you need to revisit the portion at a later point in time.

Side effects

Code that runs in the browser often has more to it than just transforming one value into something else using a defined set of rules. This is what is called “side effects”. When being called, your unit of code will have changed something in its surroundings. Some graphic changed, some counter being displayed changed. Most interactive things will have side effects. We can test these as well, although it gets more complicated.

Let’s say we have a simple click counter on a website that will increment on each click of a certain button. We could be writing code like this:

var counterElement = document.querySelector('.counter');
var clickButton = document.querySelector('.button');

clickButton.addEventListener('click', function(event) {
  var currentCount = counterElement.innerText;
  var next = parseInt(currentCount, 10) + 1;
  counterElement.innerText = next;
});

We could now write a test like this:

document.querySelector('.button').click();
var content = document.querySelector('.counter').innerText;
console.assert(content === '1');

document.querySelector('.button').click();
content = document.querySelector('.counter').innerText;
console.assert(content === '2');

This works, but has one problem: whenever some other test wants to access for example the .counter element, it is not really sure what state this test has left the element in, and we also do not know what state the counter was in when we entered the test. In case the code this test was testing was broken, the counter might contain “0” or “NaN”, or something completely different. This is why for stateful tests, you will usually create “setup” and “teardown” functions that know how to establish a testable state, and restore the original state after your test has run.

var originalContent;
function setup() {
  originalContent = document.querySelector('.counter').innerText;
  document.querySelector('.counter').innerText = '10';
}

function teardown() {
  document.querySelector('.counter').innerText = originalContent;
}

setup(); // store the original state

document.querySelector('.button').click();
var content = document.querySelector('.counter').innerText;
console.assert(content === '11');

document.querySelector('.button').click();
content = document.querySelector('.counter').innerText;
console.assert(content === '12');

teardown(); // restore the original state

Now we can be sure that all code that runs after our test will see the document in an umodified state.

Faking things

Testing code that interacts with code you have no control over is sometimes pretty difficult. For example you might define a function that tries to save a value in the browser’s local storage:

function toggleUserPreference(event) {
  var previous = window.localStorage.getItem('preference');
  var next;
  if (previous === 'on') {
    next = 'off';
  } else {
    next = 'on';
  }
  window.localStorage.setItem('preference', next);
}

var toggleElement = document.querySelector('.toggle');
toggleElement.addEventListener('click', toggleUserPreference);

How would we write a test for toggleUserPreference? There is no return value to check and it depends on the values previously stored in your browser’s localStorage. One could start by preparing the environment and then read the values from the localStorage again, but this is just as complex as the actual code, which makes the test prone to contain possible bugs itself.

To work around this, there is the concept of a “fake”. Fakes might also be sometimes called mocks (or even stubs), and the actual definitions are kind of disputed, but this does not really matter here.

What we could do is now in order to make testing our handler easier is to create our own fake window.localStorage that we have full control over and use it when writing our tests. If we would write our handler code like this:

function togglePreferenceWithStorage(storage) {
  // instead of directly defining the event handler
  // we return an event handler that depends on the
  // "storage" that is passed
  return function(event) {  
    var previous = storage.getItem('preference');
    var next;
    if (previous === 'on') {
      next = 'off';
    } else {
      next = 'on';
    }
    storage.setItem('preference', next);
  }
}

var toggleElement = document.querySelector('.toggle');
toggleElement.addEventListener('click', togglePreferenceWithStorage(window.localStorage));

When testing this handler, we could now create our own storage that will always do what we want when calling getItem and setItem.

function FakeStorage(value) {
  this.receivedValue = null;
  
  this.getItem = function(key) {
    return value;
  };
  
  this.setItem = function(key, value) {
    this.receivedValue = value;
  };
}

If we create such a FakeStorage it has the exact methods of window.localStorage that we call, so we can now write a test for our handler using the fake storage:

var onStorage = new FakeStorage('on');
var testHandler1 = togglePreferenceWithStorage(onStorage);
testHandler1();
console.assert(onStorage.receivedValue === 'off');
testHandler1();
console.assert(onStorage.receivedValue === 'on');

var offStorage = new FakeStorage('off');
var testHandler2 = togglePreferenceWithStorage(offStorage);
testHandler2();
console.assert(offStorage.receivedValue === 'on');

var notSetStorage = new FakeStorage();
var testHandler3 = togglePreferenceWithStorage(notSetStorage);
testHandler3();
console.assert(notSetStorage.receivedValue === 'on');

There are quite a few approaches for doing things like this - for example “stubbing” where you would temporarily overwrite window.localStorage with a fake - but you will get the idea behind it.

Testable code

As we’ve seen above it’s way easier to test single functions with a single purpose than a complex set of interactions. So if you want to optimize your code for testability, the following is probably the easiest rule of thumb: can any portion of this code be moved into a coherent block that serves one purpose? In case yes, move it into a pure function and call it from your main routine. Can any of the browser functionality that the code accesses be passed in as an argument so that it can be replaced with a fake version when being tested?

If your main routine is then mostly just calling functions that can be tested on their own, it’s probably fine not test the main routine for now.

Even if you do not end up writing tests for your code this is an interesting principle to follow: would it be easy to write tests for this part of the code? In case no, maybe think about how it could be structured differently so that adding a test for it would seem like stating the obvious.

Coverage

When testing code, there is a concept called coverage. It describes the percentage of lines of code that have actually run when you ran your tests against the code. Some people like to obsess about this metric, but in this context it’s not really important. What is much more important than this number though is to understand the following: ideally your tests cover all of the possible execution paths of your code. If you forget to think about this, you might end up with a lot of tests, but none of them are actually testing what your code does. Consider the following function:

function estimate(number) {
  if (number < 10) {
    return 'few';
  }
  if (number < 50) {
    return 'some';
  }
  if (number < 100) {
    return 'quite a few';
  }
  return 'a lot';
}

If your tests now only test for all numbers lower than 10, only the first if statement will ever run, making the test. Instead you should test for all possible return values:

console.assert(estimate(3) === 'few');
console.assert(estimate(34) === 'some');
console.assert(estimate(50) === 'quite a few');
console.assert(estimate(77) === 'quite a few');
console.assert(estimate(100) === 'a lot');
console.assert(estimate(231) === 'a lot');

These tests - while not complete - will cover all of the possible scenarios the estimate function may encounter. This ensure the function could be changed in all places and we’d still know if we keep the original behavior intact just by running the tests.

Test Driven Development

Test Driven Development, often abbreviated TDD, is an approach to writing code that has gained a lot of traction and created an almost cult like following that will evangelize it as the only way to write software. Ever. While it’s still possible and valid to write code without TDD, it’s definitely worth looking into the idea and maybe picking up a few of its ideas, even for beginners.

The main idea behind TDD is to write your tests before you write the code to be tested. This allows you to define your code’s behavior first by writing a failing set of tests, then you add the actual code until the tests will actually pass, leaving you with a portion of code that exactly does what you have thought of before.

For example, you could start off by defining the tests for a function that adds all the numbers it will be called with, while skipping everything that is not a number:

function addNumbers() {}

console.assert(addNumbers() === 0);
console.assert(addNumbers(0, 2, -3) === -1);
console.assert(addNumbers("string", "string", 1) === 1);
console.assert(addNumbers(null, 1, 2, 3, ["yes", "no"]) === 6);

When running this piece of code, all tests will fail. You could now incrementally make the test cases pass by implementing the function in these steps:

function addNumbers() {
  var sum = 0;
  return sum;
}

This leaves us with 3 failing tests.

function addNumbers() {
  var sum = 0;
  for (var i = 0; i < arguments.length; i++) {
    sum = sum + arguments[i];
  }
  return sum;
}

Which leaves us with 2 failing tests.

function addNumbers() {
  var sum = 0;
  for (var i = 0; i < arguments.length; i++) {
    if (typeof arguments[i] === 'number') {
      sum = sum + arguments[i];
    }
  }
  return sum;
}

This will now pass all of the tests and we are ready to use the function. Implementing it was pretty easy, as we knew about the requirements and all the possible edge cases beforehand. This also shows the fundamental “flaw” of TDD: you need to know exactly what your code is supposed to do beforehand. In case you actually do know, it’s a very nice and robust approach to writing your code, if you don’t it’s probably ok to write the code first. Just like with coverage and tests themselves I think the most important part about TDD is knowing about the concept and remembering it when writing code. You don’t necessarily need to do TDD in order to write your code in a step by step way, covering the requirements one by one.

Where to look next

While all of this might have given you an idea of what testing code actually is, why it matters and what to look for, the approach used is not very scalable. You might write a test here and there while you work on your project, but it won’t take you any further than that. Which is why there is already a plethora of tools and libraries for testing out there, some of them very basic, some of them with a lots of bells and whistles.

If you want to look into beginner friendly options for setting up tests for your project I would recommend looking into one of the following frameworks, all of them can run code in the browser, have a large and helpful community and are welcoming towards newcomers:

Happy coding, making and testing!