DEV Community

Sir Muel I πŸ‡³πŸ‡¬
Sir Muel I πŸ‡³πŸ‡¬

Posted on

Unit testing JavaScript Applications - Part 1

Read the full post at: https://www.xkoji.dev/blog/unit-testing-javascript-applications-part-1/

As developers, a lot of the time we find writing unit tests for our application to be a daunting task, which we would prefer not to do. Considering that writing tests doesn't really have any business value either, it becomes even easier to postpone writing the tests for your application till much later. However we know that writing tests have a number of benefits that become more apparent as the project grows in complexity and the team working on the product increases in size, or goes through an organizational restructuring. In this series, we would be looking at writing unit tests for JavaScript applications.

What is Unit Testing?

Unit testing refers to writing tests that verify that a piece (unit) of code behaves as the author intended, giving a set of inputs in all conceivable conditions. Unit tests always tests the piece of code in isolation, without any external factors or dependencies influencing the code. When thinking about unit tests, it helps to think about the unit of code in terms of a function.

In its simplest form, a function accepts a set of inputs, performs some computation based on the inputs, and returns some output. Let's create a simple function called sum() which takes two numbers and returns their sum as an output.

const sum = (a, b) => a + b;

const output = sum(2, 3);
// expected output -> 5

From my perspective as a user of this function, I don't really care how the sum() function decides to compute the output. All I care about is that: if I provide 2 and 3 as the inputs, sum() should return 5 always, no matter how many times I run it. The sum function could have been written a different way to compute the output, but as long as the output is the same, then the function still does what it was intended to do.

// a random contrived implementation of sum
const sum = (a, b) => {
  let output = a;
  const isAdd = b > 0;
  b = Math.abs(b);

  while(b > 0) {
    if (b >= 1) {
      if (isAdd) {
        output++;
      } else {
        output--;
      }
      b--;
    } else {
      if (isAdd) {
        output += b;
      } else {
        output -= b;
      }
      b = 0;
    }
  }
  return output;
}

const output = sum(2, 3);
// expected output -> 5

Unit testing comes with a number of benefits as well:

  • you can change (refactor) your code confidently when you have solid unit tests that cover all the requirements in place.
  • you end up writing better code as a result of writing unit tests. Some approaches to coding are very problematic when you need to write unit tests. In order to write tests, you need to write modular code with clear explicit dependencies (more on this later).
  • your code is more reliable as it has tests covering the requirements.
  • the test cases provide some form of living documentation about your code. Test cases describe the various requirements your code handles, and gives examples of how to use your code in form of the tests.

Pure Functions

When a function always returns the same output everytime for a given set of inputs, and it doesn't change anything else outside the scope of the input and output, then that function is called a pure function. However if the function changes anything else (like mutating a referenced variable, performing I/O operations e.g. saving file to disk, making a network request, modifying the DOM), the function is no longer pure. The reason why this concept is important is because it is easy to predict the behavior of a pure function when using it than an impure function. Also it is very easy to test pure functions without much complexity or knowledge of the internal workings of the function. Let's write a sum() function that is not pure to compare with our previous versions.

// Already loaded https://cdn.jsdelivr.net/npm/add@latest/index.min.js on the page
const sum = (a, b) => {
  return window.add([ a, b ]);
};

const output = sum(2, 3);
// expected output -> 5

This function still returns the correct output, but it relies on an external function (window.add()) existing for it to function. The function no longer depends on only the input parameters, but it also depends on the existence of an external factor, and that the external factor actually returns the right output.

If we want to unit test this function, remember we need to test the function in isolation. When running a test for the sum() function, we don't want to be executing code in the window.add() function. We only want to execute and test the code in the unit of code we are testing, which is the sum() function in this case. For us to write the unit test, we would need to mock the window.add() dependency.

Mocking Dependencies

Mocking simply refers to replacing the actual implementation of a piece of code with a fake one that resembles the original one, and behaves like the original one. This is usually done to avoid executing the actual piece of code while trying to test another piece of code. In the example above, we want to test the sum() function without executing code in the window.add() function. This adds a major complexity to the unit testing process because you would need to rely on specialized tools and testing frameworks to be able to mock dependencies easily. This can be one of the most painful parts about writing unit tests, if the code structure for your piece of code depends on several external factors, especially if those external factors aren't passed into the function as input parameters, but instead are accessed directly from a global scope from inside the piece of code.

In the impure sum() function example, the function access add() directly from the global window object. Like we mentioned above, this increases the complexity of the tests. A way to make it easier to mock the dependency would be by passing the dependencies as input parameters as well before using them.

// Already loaded https://cdn.jsdelivr.net/npm/add@latest/index.min.js on the page
const sum = (windowObj, a, b) => {
  return windowObj.add([ a, b ]);
};

const output = sum(window, 2, 3);
// expected output -> 5

Here, we are passing the window object as one of the parameters of the sum() function. Now it is easier to mock the window object, by just creating a fake object that behaves the same way as the window object.

const sum = (windowObj, a, b) => {
  return windowObj.add([ a, b ]);
};

const w = {
  add(arr) {
    return arr[0] + arr[1];
  }
};
const output = sum(w, 2, 3);
// expected output -> 5

As you can see, we created a fake object that also has an add function. Now we have easily mocked the window object. Also we were able to make the sum() function pure again, since it only depends on its inputs and doesn't modify anything outside of itself. Of course if the add() function used is an impure function, that would also make the sum() function impure, since purity of a function is transitive.

Whitebox vs Blackbox Testing

Generally speaking, there are two categories of testing: whitebox and blackbox testing. Whitebox testing refers to tests that depend on the tester having knowledge about the way the piece of code being tested works internally. Blackbox testing is the opposite. The person writing the tests can be unaware about the way the piece of software functions internally.

Unit testing is an example of whitebox testing and is usually written by the author of the code. As the author, you are usually aware of all the conditions, and logical flows of execution within the code, and so you are able to write tests with all possible combinations of inputs to test all the execution flows. Remember the definition of unit testing: writing tests that verify that a piece (unit) of code behaves as the author intended, giving a set of inputs in all conceivable conditions. You want to make sure all the execution flows are tested to avoid any unexpected behaviors when the piece of code is being executed in a production environment. Also note that I said "all conceivable conditions". This is because there can be an infinite combination of inputs that your piece of code would be executed with, and your test cases might not cover all these combinations. There could be some edge cases that aren't obvious when writing the code and which aren't covered by the tests but would show up when the code is in use. Usually when that happens, you should add a new test case to cover such a case.

When writing unit tests, I like to think about the piece of code from both the blackbox (how the users would see it) and the whitebox perspectives:

  • I think from a blackbox perspective when determining the inputs and outputs of the piece of code to test, and I use that to create the test cases.
  • I think from a whitebox perspective when I need to mock dependencies, since we need knowledge about the internals of the piece of code in order to be able to mock the dependencies.
  • I think from a whitebox perspective when I want to cover all the logical execution flows within the piece of code, and I use this to add extra test cases, making the test suite robust.

What should I know about TDD?

Test Driven Development (TDD) is an approach to development that involves defining test cases, and using those test cases to define what code needs to be written. According to Wikipedia,

Test-driven development (TDD) is a software development process that relies on the repetition of a very short development cycle: requirements are turned into very specific test cases, then the code is improved so that the tests pass. This is opposed to software development that allows code to be added that is not proven to meet requirements.

This means you end up writing your tests while writing the actual code, and you no longer have writing tests as a chore that you need to do at a later time. It also makes writing tests much easier, since you write your code with the tests in mind.

One of the benefits of the test driven approach is that it helps you write much better code with much cleaner code structure.

Following the TDD approach involves three laws that you need to follow:

  1. You must write a failing test before you write any production code.
  2. You must not write more of a test than is sufficient to fail, or fail to compile.
  3. You must not write more production code than is sufficient to make the currently failing test pass.

...

Continue reading at xkoji.dev: https://www.xkoji.dev/blog/unit-testing-javascript-applications-part-1/

Top comments (0)