Writing unit tests takes time and effort. Nonetheless, many teams insist on writing them anyway—that’s because of the benefits they bring to a project.
Those benefits are mainly the following:
Let’s see those benefits in more detail:
Computers excel at well-defined, repetitive tasks. When you write unit tests, you write code that will verify whether your program does what it was meant to do. With automated verification in place, you can check your code very quickly. In my work, I have 3200+ that run in just under 14 seconds. With such performance, you can retest your whole codebase whenever you save your code. After I got more experience with writing unit tests, I started to feel that the time I saved with the quicker feedback loop returned the time I invested in writing tests.
As an example, let’s see updated unit tests for the translate
method introduced in my last article:
describe("translate", () => {
it("should translate to supported languages", () => {
expect(translate("hello", "en")).toEqual("Hello!");
expect(translate("hello", "pl")).toEqual("Cześć!");
});
it("should default to english if language missing", () => {
expect(translate("hello", "fr")).toEqual("Hello!");
});
it("should return the key if translation is missing", () => {
expect(translate("farewell", "en")).toEqual("farewell");
expect(translate("farewell", "pl")).toEqual("farewell");
expect(translate("farewell", "fr")).toEqual("farewell");
});
});
Running those tests happens in the blink of an eye:
npm run test
> [email protected] test
> jasmine
Randomized with seed 31262
Started
...
Ran 3 of 21 specs
3 specs, 0 failures
Finished in 0.003 seconds
Incomplete: fit() or fdescribe() was found
Randomized with seed 31262 (jasmine --random=true --seed=31262
Another big advantage of unit tests is stating the expectations explicitly in the code. For example, the data formatting function shortDate
could do one of the following things when provided with an argument that is not a date:
throw an error,
return undefined, or
return an empty string.
Each of those choices could be a good idea in some places, so it could happen that at some point the exact behavior will be changed. I like adding special cases like this to the test, so the future developer will be reminded that some code can depend on specific behavior when they start changing the API.
shortDate
tests, updated to cover invalid inputs:
describe("shortDate", () => {
it("should correctly format date", () => {
const date = new Date("2023-11-02");
expect(shortDate(date)).toEqual("2023-11-02");
});
it("should fail gracefully for no-dates", () => {
expect(shortDate("")).toEqual("");
expect(shortDate({})).toEqual("");
expect(shortDate(1)).toEqual("");
expect(shortDate()).toEqual("");
});
});
When I write the implementation for a method, I think about the happy path—everything going as expected.
When I write unit tests, I think about everything that can go wrong:
Covering those edge cases makes the tests helpful. A few examples from the demo repository:
greet
tests:
describe("greet", () => {
it("should greet by name and surname", () => {
expect(greet("Lorem", "Ipsum")).toEqual("Hello Lorem Ipsum!");
});
it("should fail gracefully for missing arguments", () => {
expect(greet("Lorem")).toEqual("Hello Lorem!");
expect(greet(undefined, "Ipsum")).toEqual("Hello Ipsum!");
expect(greet()).toEqual("Hello!");
});
});
applyDiscount
tests:
describe("applyDiscount", () => {
it("should lower the price accordingly", () => {
expect(applyDiscount(120, 25)).toEqual(90);
expect(applyDiscount(8, 50)).toEqual(4);
});
it("should manage rounding error", () => {
expect(applyDiscount(0.1, 40)).toEqual(0.06);
});
it("should round results to 0.01", () => {
expect(applyDiscount(1.11, 25)).toEqual(0.83);
});
it("should return NaN for corrupt inputs", () => {
expect(applyDiscount("", 40)).toBeNaN();
expect(applyDiscount(40)).toBeNaN();
expect(applyDiscount()).toBeNaN();
});
it("should throw errors on discount percentage outside 0-100 range", () => {
expect(() => applyDiscount(120, 125)).toThrowError();
expect(() => applyDiscount(120, -25)).toThrowError();
});
});
calculatePrice
tests:
describe("calculatePrice", () => {
it("should find a price of many products", () => {
expect(calculatePrice(4, 3)).toEqual(12);
expect(calculatePrice(9, 0.5)).toEqual(4.5);
});
it("should manage rounding error", () => {
expect(calculatePrice(0.1, 0.4)).toEqual(0.04);
});
it("should round results to 0.01", () => {
expect(calculatePrice(1.11, 0.5)).toEqual(0.56);
});
it("should return NaN for corrupt inputs", () => {
expect(calculatePrice("", 40)).toBeNaN();
expect(calculatePrice(40)).toBeNaN();
expect(calculatePrice()).toBeNaN();
});
});
Once I have all expectations for my code defined, it’s effortless to refactor it. We can safely reorganize the code, improving the quality while maintaining the behavior. Removing friction from code improvements is where we see plenty of long-term benefits from unit testing. When your team is enabled to improve code without the fear that they will break something, they are more likely to try improving things.
On the flip side, code that nobody can change without causing some unexpected changes is code that is very difficult and risky to improve. Unit tests are often a barrier that prevents code from entering into a spiral of growing complexity and maintainability.
ellipsis
tests are a good example of checking all the behaviors we could care about:
describe("ellipsis", () => {
it("should shorten long text at 50 chars", () => {
expect(
ellipsis(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque a faucibus massa."
)
).toEqual("Lorem ipsum dolor sit amet, consectetur adipiscing…");
});
it("should leave short text unchanged", () => {
expect(ellipsis("Lorem ipsum sin dolor")).toEqual(
"Lorem ipsum sin dolor"
);
});
it("should shorten to custom length", () => {
expect(ellipsis("Lorem ipsum sin dolor", 10)).toEqual("Lorem ipsu…");
});
it("should return unchanged non-string argument", () => {
expect(ellipsis(11)).toEqual(11);
expect(ellipsis({ lorem: "ipsum" })).toEqual({ lorem: "ipsum" });
});
it("should ignore second argument if not number", () => {
expect(
ellipsis(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque a faucibus massa.",
{}
)
).toEqual("Lorem ipsum dolor sit amet, consectetur adipiscing…");
});
});
The cost/benefit balance will depend a lot on the type of project and the team that works on it. For benefits to show, you need a certain quality of tests—and this can be difficult if nobody on your team has experience with building unit tests. The communication benefits are greater when you have a bigger team—so different people work on the code, or when the projects live a long time—so people need a reminder about their past decisions. Long-term benefits of enabled refactoring will appear only if the project exists long enough such that the need for refactoring has a chance to arise.
Also published here.