How to keep your units testable in JavaScript

Marcin Wosinek - Feb 1 '23 - - Dev Community

Unit tests are a challenging topic, with many interconnected aspects that make it difficult for beginners. If your impression is that they

  • are time-consuming to write,
  • provide only meaningless validation, or
  • require a lot of additional effort in case of code refactoring,

then chances are that you haven’t seen a well-executed unit tests approach so far. This article provides a simple example that shows that none of those issues has to affect your code.

What is testability

Testability is an informal measure of how easy it is to write tests for code. There is no precise measure that would allow us to compare code. For me, a good approximation of testability is:

  • how easy it is for me to plan and write unit tests and
  • how much test code I need to write to get close-to-perfect coverage of my application logic.

As you see, it’s a bit subjective. In this sense, it’s similar to readability—some patterns are clearly better or worse, whereas in some other cases it’s mostly a matter of a personal preference. Same as in the case of readability: after you spend enough time looking at the code through this lens, you will develop an intuition about how testable different approaches are. Until then, it’s a good idea to follow recommendations of others while occasionally checking whether the code is easy to test.

In short, if you struggle to find a way to write tests for your code, it’s likely suffering from the low testability.

What are units

Units are a small piece of code that you can think about in isolation from the rest of the application. They can be classes, functions, or components. A good unit can be defined as one that

  • has a name that matches its purpose,
  • considers inputs, outputs and possible states, and
  • fits well with other units in your application.

Some common issues that make the unit of your code bad are as follows:

  • tight coupling between different units—instead of following well-defined methods of accessing one another, the units depend on the internal details (like data structure) of other units
  • units that put some values on the global scope—for other code to either override accidentally or use directly
  • unclear purpose—for example, a class called utils keeps code that rounds numbers, generates unique ID, and can keep anything and everything else

Example: singleton with global configuration

Singleton is a software design pattern that allows only one instance of the object to exist in the application. For the rest of the article, we will use an example of a global configuration that we want to be the same across the entire application. Our example is a perfect use case for singleton—we centralize settings in one place, but without putting data directly on the global scope.

The focus is mostly focused on reading the values—the initialization part will always be done only once in the application, and in the first iteration it can be just hard-coded.

Testable class with tests

To start, let’s create a simple class:

export class Configuration {
  settings = [
    { name: "language", value: "en" },
    { name: "debug", value: false },
  ];

  getSetting(name) {
    const setting = this.settings.find(
      (value) => value.name === name
    );

    return setting.value;
  }
}
Enter fullscreen mode Exit fullscreen mode

For adding tests, I follow the example from my older article. The test file is:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return hard-coded settings", () => {
    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

You can find the code at the initial-implementation branch.

If the application life ended here, the effort put into setting up testing infrastructure and writing the test was mostly pointless: we don’t need any safety measure to make sure hard-coded values are returned as expected. Unit tests become useful when we evolve our code and when we want to make sure some parts of the logic are changed while others stay the same.

First refactoring: setting data from outside

Firstly, let's make the class more dynamic. We’ll introduce a method to initialize the configuration. The idea is that some other part of the application will get the correct values, and the responsibility of the Configuration class will be to keep and provide their values to the rest of the application.

Updated code:

export class Configuration {
  settings = [];

  init(settings) {
    this.settings = settings;
  }

  getSetting(name) {
    const setting = this.settings.find(
      (value) => value.name === name
    );

    return setting.value;
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the change in the code is pretty small, but the class is much more versatile—instead of hard-coding setting values, it will support whatever is set with the init call.

Updated tests:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init([
      { name: "language", value: "en" },
      { name: "debug", value: false },
    ]);

    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);
  });
});
Enter fullscreen mode Exit fullscreen mode

More flexible logic requires more code in tests. In this test implementation, our tests are running all the code we have, but there two aspects that are not made explicit:

  1. Do we support rerunning the init method? The code, as it is right now, would work just fine, but one could imagine a case where we would want our logic to ignore reruns or maybe throw an error.
  2. We don’t check whether the settings are read from the values that were provided in the init call. It could be that we have some hardcoded values that happen to match what we have in our test.

To make our tests more complete, let’s reinitiate the config with different values:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init([
      { name: "language", value: "en" },
      { name: "debug", value: false },
    ]);

    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);

    // reinitiate with other values
    configuration.init([
      { name: "language", value: "es" },
      { name: "debug", value: true },
    ]);

    expect(configuration.getSetting("language")).toEqual("es");
    expect(configuration.getSetting("debug")).toEqual(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Now our tests are checking all the important aspects of the code. You can find this version of code at initable-configuration branch.

Second refactoring: changing data structure

If you wondered why we keep the settings as an array, you have a good point: it doesn’t fit the purpose well. We will refactor the data structure now into something that makes much more sense: an object.

Update code:

export class Configuration {
  settings = {};

  init(settings) {
    this.settings = settings;
  }

  getSetting(name) {
    return this.settings[name];
  }
}
Enter fullscreen mode Exit fullscreen mode

The data structure change made our code simpler and more resilient—it will not throw an error when you try to read a nonexistent setting. Both things are strong indicators that this refactoring was a good idea. The code change requires us to update the init calls in the unit tests:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init({
      language: "en",
      debug: false,
    });

    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);

    // reinitiate with other values
    configuration.init({
      language: "es",
      debug: true,
    });

    expect(configuration.getSetting("language")).toEqual("es");
    expect(configuration.getSetting("debug")).toEqual(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

You can find the code at object-based-approach branch.

Third refactoring: more testable class

On its own, this class is pretty testable. Unfortunately, if you used it in other classes, it wouldn't be easy to mock. We can address it by making the interface of the class even more explicit:

export class Configuration {
  settings = {};

  init(settings) {
    this.settings = settings;
  }

  getLanguage() {
    return this.settings["language"];
  }

  getDebug() {
    return this.settings["debug"];
  }
}
Enter fullscreen mode Exit fullscreen mode

Right now, we have two different methods to read each of the settings. Thanks to this change, mocking the configuration object will be very easy and clear to read:


spyOn(configuration, getLanguage).and.returnValue(en);

Enter fullscreen mode Exit fullscreen mode

The class’s own tests gets a bit more explicit as well:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init({
      language: "en",
      debug: false,
    });

    expect(configuration.getLanguage()).toEqual("en");
    expect(configuration.getDebug()).toEqual(false);

    // reinitiate with other values
    configuration.init({
      language: "es",
      debug: true,
    });

    expect(configuration.getLanguage()).toEqual("es");
    expect(configuration.getDebug()).toEqual(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

You can find this code at the separate-methods branch.

Untestable counter-example

Two wrap up, let’s take a look on what would be an untestable approach to the same class, using our final data model: an object:

export class Configuration {
  settings = {
      language: "es",
      debug: true,
    };
}
Enter fullscreen mode Exit fullscreen mode

Those values can be read with

configuration.settings.language
Enter fullscreen mode Exit fullscreen mode

If you are not used to writing unit tests, this solution will likely look more natural to you—after all, we solve the same issue with less code.

On the other hand, if we try the same approach with our original data model—the array—the code is still simple:

export class Configuration {
  settings = [
    { name: "language", value: "en" },
    { name: "debug", value: false },
  ];
}
Enter fullscreen mode Exit fullscreen mode

but reading values gets a bit complicated:

configuration.settings.find(
  value => value.name === language
).value
Enter fullscreen mode Exit fullscreen mode

If you had a complete application using the configuration in this way, going from array to object would be a massive refactoring—requiring changes in every single piece of code that accesses some values from configuration. And if you had everything covered by unit tests, those tests probably wouldn’t be very meaningful, and there would be even more code to update.

Conclusion

As you can see, by looking at the code from the point of view of testability, we went from a straightforward approach to something much more elaborate and rigid. This is an example of how unit tests impact your design. If we can see such a big impact on one simple class, imagine how different the code will be if you repeat it over years of development.

Whether this is a design approach that you want for your project is for you to evaluate. As I explained in an article about becoming a “fast” developer, my main concern is building the right thing in the right way instead of delivering out many features quickly. I would recommend this approach if you'd like

  • your project to have long and healthy life—counting in years or decades,
  • to make it easy for new developers to start quickly making changes in your code without fear of breaking things, or
  • to ensure smooth transition of ownership when you eventually leave the project behind.

I’m not arguing that this is the way of writing any code—depending on your goals, this approach may be a bad or a good fit for you. Good counter-examples would be any code that is meant to be discarded soon:

  • prototypes, experiments and proof-of-concepts
  • applications you write for fun

Similarly, it’s possible that your incentives are not aligned with the long-term health of the project. Unfortunately, it’s something you can see both

  • on an individual level—you write an application, but some others fix bugs and do the maintenance—and
  • as a company or a freelancer hired to work on a fixed-scope project, but the client didn't budget for taking care of the long term.

Are you interested in learning more?

One interesting extension of this class would be loading data from a server—something that you would likely want to do in a real-world application. Doing it in a fully testable way would require introducing dependency injection—something that would require a separate article. Let me know in the comments if you are interested in an article like this.

Meanwhile, if you would like to learn more about testing, you can sign up here to get updates when I publish testing-related content.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .