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.
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:
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 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 the 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 low testability.
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:
Some common issues that make the unit of your code bad are as follows:
utils
keeps code that rounds numbers, generates unique ID, and can keep anything and everything elseSingleton 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.
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;
}
}
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);
});
});
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.
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;
}
}
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);
});
});
More flexible logic requires more code in tests. In this test implementation, our tests are running all the code we have, but there are two aspects that are not made explicit:
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.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);
});
});
Now our tests are checking all the important aspects of the code. You can find this version of the code in initable-configuration branch.
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];
}
}
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);
});
});
You can find the code in object-based-approach branch.
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"];
}
}
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’);
…
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);
});
});
You can find this code at the separate-methods branch.
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,
};
}
Those values can be read with
configuration.settings.language
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 },
];
}
but reading values gets a bit complicated:
configuration.settings.find(
value => value.name === ‘language’
).value
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 the 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.
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 many features quickly. I would recommend this approach if you'd like
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:
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
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.
Also Published Here