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;
}
}
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.
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;
}
}
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 two aspects that are not made explicit:
- 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. - 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);
});
});
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];
}
}
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 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"];
}
}
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.
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,
};
}
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 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.
Top comments (2)
Hi @marcinwosinek ! I really enjoyed reading you article ! šš
Congrats on making your code so much readable that I can understand exactly what is going on, even though I'm not used to Javascript (I deal with C++ on a daily basis). šš
I am wondering what you think about the S.O.L.I.D principle in order to make the code more easily testable and maintainable. This is not a precise metrics, but I feel that it can be a principle that can help to get an approximation of testability, so I wanted to get your opinion on it. š
Thank you!
Good point: SOLID, and the approach I presented here points to the same directions. So:
Other parts of SOLID don't have such a direct impact on testing