Advantages of unit testing—with examples in Jasmine

Advantages of unit testing—with examples in Jasmine

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:

  • fast feedback—unit tests speed up each iteration of tweaking the code, and those gains can offset at least some time spent on writing tests.
  • Explicit expectations—clear communication of what is expected from the code.
  • Attention to edge cases—good unit tests will provide examples for every edge case that developers can think of.
  • Easy refactoring—an infrastructure to support code evolution in the long term.

Let’s see those benefits in more details:

Fast feedback

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 just under14 seconds. With such performance, you can retest your whole codebase every time when you save your code. After I got more experience with writing unit tests, it started to feel that the time I saved with the quicker feedback loop returns the time I invested in writing tests.

As an example, let’s see update 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

> testing-example@1.0.0 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

Explicit expectation

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 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("");
    });
  });

Attention to edge cases

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:

  • some arguments missing,
  • wrong data type, or
  • invalid combinations or arguments—such as dividing 0 by 0.

Covering those edge cases makes the tests really helpful. A few examples form 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();
    });
  });

Easy refactoring

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 unmaintainability.

ellipsis tests are a good example of checking all the behavior we could possibly 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…");
    });
  });

Summary

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.