DEV Community

loading...
This is Angular

Unit Testing - Introduction

Jesús Rodríguez
Angular developer and author
Originally published at angular-tips.com Updated on ・11 min read

Let's talk about unit testing our applications.

What is unit testing and why should I care?

Unit tests are a bunch of Typescript files that we create to make sure that every part of our application works as it is expected to work. That means that we need to write hundred of lines of code to assert that our code does what is supposed to do.

  • Isn't that a waste of time? The boss is always telling us that we need to be faster and hundred of lines doesn't sound like fast. Au contraire, that bunch of code will save us HOURS. Don't believe me? I have proofs. <!--more-->
  • Extra code: How many times did you end with code that is not used? Maybe we added some extra loops that are not needed or some function to do something and then realize that we are not using it. When we code our modules before any test, we don't actually know what we are going to need or if our algorithm is going to support any kind of input (that could lead to those extra loops). More code means more stuff to maintain which also means, more money.

  • Bad API design: Maybe we need to create a new service to do something, and then we start writing functions to do the work and we put some of them public to define the service's API. Good, that is the idea isn't it? Some time after we get complaints about our really poor API that well, it is not as intuitive as we expected. In this category also goes those API functions that are not really needed (which is also extra code).

  • Refactor: What happens when we want to refactor our code? We are in big trouble. Even when we decide to not break the API, maybe that internal change is not working properly on some edge cases where it worked in the past. That will break the application for some people and they won't be happy at all (and those kind of bugs are normally a pain to debug).

  • Will it work: That is the end goal and probably the biggest time waster of anything you have to do in your applicaton. Something as simple as a calendar, involves some maths and some magic numbers to make it work. We really need to be sure it works. How? We open a certain date, we manually check with our OS calendar to see if it matches. We repeat that for some random dates (old ones, future ones). Then we change something in our service and well, we need to check the dates again to assert that nothing is broken. Repeat that 20 times for a normal service development.

How does the unit test help?

Ok, you convinced me that maybe I was wrong about not doing unit testing. But how can it help with those problems? What if we see a really simple example? (General example, not Angular related and it will be in a really slow peace to make the point).

Let's say I want an object which will be able to do some basic maths (Addition and Division). Your first thought is to start writing a class with some methods to do some math. We will end doing something like that, but what we are going to do is to test it first. Test it first? Why? Bear with me.

(If you want to follow this, I have a codesandbox for you to work.)

This codesandbox (and the Angular app that we will test in the next sections) uses Jest. Jest is a testing framework that can be used for any Javascript / Typescript project.

Our object should be able to sum 5 and 3and get 8. Let's test that.

File: src/calculator.spec.ts

describe('Calculator', () => {
  it('should be able to sum 5 and 3 to return 8', () => {
    // Arrange
    const calc = new Calculator();

    // Act
    const result = calc.sum(5, 3);

    // Assert
    expect(result).toBe(8);
  });
});
Enter fullscreen mode Exit fullscreen mode

Before we even look at the Tests tab at codesandbox, let's talk about this piece of code.

First we see that this looks like something between English and Typescript. Testing is meant to be something that is easy to read and easy to understand and just by reading the code, we get an idea of what it does:

"Describe a calculator. It should be able to run 5 and 3 to return 8. Create a calculator object, call a method and expect the result to be 8.".

Now back to technical details, tests are wrapped into describe functions. They are used to group our tests. The actual tests are functions called it where we actually code our tests.

Inside those it functions, we follow a pattern called AAA (Arrange, Act, Assert). With those 3 steps, we successfully write a test.

In this example, we are Arranging by creating a Calculator object, then Acting by calling it's sum method and Asserting by checking its result with our expected result.

Alright, but what is the result of this test?

1 failing test

Kind of expected, isn't it? We wrote our test before we even created our Calculator class.

Something interesting to notice here is how are we designing our API before we even coded it. We say that we want a sum method before we created the class.

Let's fix this, Shall we?

File: src/calculator.ts

export class Calculator {
  sum(num1: number, num2: number): number {
    return 8;
  }
}
Enter fullscreen mode Exit fullscreen mode

And also let's import it to our spec file:

File: src/Calculator.spec.ts:

import { Calculator } from './calculator';

describe('Calculator', () => {
  ...
});
Enter fullscreen mode Exit fullscreen mode

What does our test says now?

1 passing test

But... That is definitely not right, isn't it? We hardcoded the result 8 into the method. That way our tests surely pass.

We have to code the minimum possible code to make our tests pass. I understand that this is a contrived example and we already know that this implementation is not enough, but in a real world scenario (as we will see in the next sections) you may not know when an implementation is enough or not, so our job is to make a test pass as simple as possible, as we did in here.

Since we may not be sure that this implementation is enough, we have to write more tests:

File: src/calculator.spec.ts

it('should be able to sum a number with 0', () => {
  const calc = new Calculator();

  const result = calc.sum(7, 0);

  expect(result).toBe(7);
});
Enter fullscreen mode Exit fullscreen mode

If we see the test tab we see:

failing second test

1 test failed, 1 test passed. And we can see where it failed and why. We expected the result of 7 but we got 8. That means that something is wrong with our code.

This solves our Will it work? dilemma. We can immediately see that our code doesn't really work, so we need to fix it so all our test passes.

Let's fix it:

File: src/calculator.ts

export class Calculator {
  sum(num1: number, num2: number): number {
    return num1 + num2;
  }
}
Enter fullscreen mode Exit fullscreen mode

Now our tests says:

2 passing tests

Before we move on, let's take a peek to our current spec file:

import { Calculator } from './calculator';

describe('Calculator', () => {
  it('should be able to sum 5 and 3 to return 8', () => {
    // Arrange
    const calc = new Calculator();

    // Act
    const result = calc.sum(5, 3);

    // Assert
    expect(result).toBe(8);
  });

  it('should be able to sum a number with 0', () => {
    const calc = new Calculator();

    const result = calc.sum(7, 0);

    expect(result).toBe(7);
  });
});
Enter fullscreen mode Exit fullscreen mode

First, notice here that every it in our spec file is completely independent of the others. They run independently and you should never ever rely on the way they are ordered to "start something in one one them" and "assert in the other". In fact, Jest may run the it in a random order to avoid dependency between them.

Also, look at the code. There is some repetition in it. The DRY (don't repeat yourself) principle doesn't apply as strongly as it does in our application code. We are allowed to repeat some code for the sake of testing, but that doesn't mean that we should repeat all our code.

In this case we are repeating our Arrange part in those two tests, and if we have 20 of them, we are going to repeat it 20 times. We can do better.

There is a method called beforeEach that runs before each it function. There we can setup whatever we need for each test. Let's Arrange our code there so we have access to calc in each test.

Let's look at the new code:

File: src/calculator.spec.ts:

import { Calculator } from './calculator';

describe('Calculator', () => {
  let calc: Calculator;

  beforeEach(() => {
    // Arrange
    calc = new Calculator();
  });

  it('should be able to sum 5 and 3 to return 8', () => {
    // Act
    const result = calc.sum(5, 3);

    // Assert
    expect(result).toBe(8);
  });

  it('should be able to sum a number with 0', () => {
    const result = calc.sum(7, 0);

    expect(result).toBe(7);
  });
});
Enter fullscreen mode Exit fullscreen mode

This is a test refactor. We should only do them when all our tests are green, to be sure that it doesn't break anything.

So far so good, let's throw more different scenarios to see it behaves correctly:

it('should be able to sum a negative number for a positive result', () => {
  const result = calc.sum(7, -3);

  expect(result).toBe(4);
});

it('should be able to rum a negatrive number for a negative result', () => {
  expect(calc.sum(-20, 7)).toBe(-13);
});
Enter fullscreen mode Exit fullscreen mode

Notice how I wrote two lines in one in the last example. It is still readable so it is good in my book.

4 passing tests

Seems like our code handles this two use cases correctly.

Now, let's move to division, but before we do that, we could group or sum test in their own describe like this:

File: src/calculator.spec.ts:

import { Calculator } from './calculator';

describe('Calculator', () => {
  let calc: Calculator;

  beforeEach(() => {
    // Arrange
    calc = new Calculator();
  });

  describe('#sum', () => {
    it('should be able to sum 5 and 3 to return 8', () => {
      // Act
      const result = calc.sum(5, 3);

      // Assert
      expect(result).toBe(8);
    });

    it('should be able to sum a number with 0', () => {
      const result = calc.sum(7, 0);

      expect(result).toBe(7);
    });

    it('should be able to sum a negative number for a positive result', () => {
      const result = calc.sum(7, -3);

      expect(result).toBe(4);
    });

    it('should be able to rum a negatrive number for a negative result', () => {
      expect(calc.sum(-20, 7)).toBe(-13);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

We can anidate as many describe as we need. Also notice the # at #sum. It is a convention that says that we are testing a method.

Now let's create a new describe for a division with a simple test:

File: src/calculator.spec.ts:

    it('should be able to rum a negatrive number for a negative result', () => {
      expect(calc.sum(-20, 7)).toBe(-13);
    });
  });

describe('#division', () => {
  it('should be able to do an exact division', () => {
    const result = calc.division(20, 2);

    expect(result).toBe(10);
  });
});
Enter fullscreen mode Exit fullscreen mode

It fails:

division method failing

What a surprise. Let's fix it real quick:

File: src/calculator.ts:

export class Calculator {
  sum(num1: number, num2: number): number {
    return num1 + num2;
  }

  division(num1: number, num2: number): number {
    return num1 / num2;
  }
}
Enter fullscreen mode Exit fullscreen mode

5 passing tests

This time with the application requisites a bit clearer, we wrote a better division method.

We don't want or Calculator to deal with decimals, because who likes decimal anyway?

File: src/calculator.spec.ts:

it('returns a rounded result for a non exact division', () => {
  expect(calc.division(20, 3)).toBe(7)
});
Enter fullscreen mode Exit fullscreen mode

6th function failing

Apparently Typescript does like them.

Let's fix that.

File: src/calculator.spec.ts:

export class Calculator {
  sum(num1: number, num2: number): number {
    return num1 + num2;
  }

  division(num1: number, num2: number): number {
    return Math.round(num1 / num2);
  }
}
Enter fullscreen mode Exit fullscreen mode

6 passing tests

Yay, not only in rounds numbers now, but our other test still works as expected.

Now we want to throw an exception if we divide something by 0.

File: src/calculator.spec.ts:

it('throws an exception if we divide by 0', () => {
  expect(() => 
    calc.division(5, 0)
  ).toThrow('Division by 0 not allowed.');
});
Enter fullscreen mode Exit fullscreen mode

This test looks different. Instead of passing a variable to expect, we are passing a function. The idea is something like "We expect that when running this function, an exception will be thrown". Since division won't be able to return anything if it throws an exception, we cannot test the result as we previously did.

This test obviously fails:

toThrow is failing

Let's see our code before we change it:

File: spec/calculator.ts:

export class Calculator {
  sum(num1: number, num2: number): number {
    return num1 + num2;
  }

  division(num1: number, num2: number): number {
    return Math.round(num1 / num2);
  }
}
Enter fullscreen mode Exit fullscreen mode

Division by 0 happened when divisor is 0 but... which is which in our code? Let's refactor our code, but before we do that, we need our tests to pass and we have one that is failing. What we can do is "skip" the test until we refactor:

File: src/calculator.spec.ts:

xit('throws an exception if we divide by 0', () => {
  expect(() => 
    calc.division(5, 0)
  ).toThrow('Division by 0 not allowed.');
});
Enter fullscreen mode Exit fullscreen mode

Notice the xit. We use this as a way to "ignore" a test. We can always comment out the code, but that way we may forget that we had a test to fix. With xit we can see that it exist but that it was skipped.

NOTE: codesandbox doesn't manage this xit very well, but at least it says that there are no failing tests

Now we our broken test ignored, let's refactor our code:

export class Calculator {
  sum(num1: number, num2: number): number {
    return num1 + num2;
  }

  division(dividend: number, divisor: number): number {
    return Math.round(dividend / divisor);
  }
}
Enter fullscreen mode Exit fullscreen mode

Much better and tests still pass:

6 passing tests

NOTE: As mentioned, codesandbox doesn't manage this well and you may see a red X saying failed but all correct in the summary, that is fine.

That is a code refactor without the fear of breaking any feature.

Now swap the xit for it again:

File: src/calculator.spec.ts:

it('throws an exception if we divide by 0', () => {
  expect(() => 
    calc.division(5, 0)
  ).toThrow('Division by 0 not allowed.');
});
Enter fullscreen mode Exit fullscreen mode

And let's fix the code:

export class Calculator {
  sum(num1: number, num2: number): number {
    return num1 + num2;
  }

  division(dividend: number, divisor: number): number {
    if (divisor === 0) {
      throw new Error('Division by 0 not allowed.');
    }
    return Math.round(dividend / divisor);
  }
}

Enter fullscreen mode Exit fullscreen mode

All tests pass

And that is it! Congratulations, you just wrote your first test suite.

Conclusions of this example

Even when it is really really simple example. We already saw how we can address those problems I described earlier:

Our calculator doesn't have any extra code because we coded just what we needed to make our calculator work. Its API design is good enough, that is because we used it as we would like to use it on the real world. Will it work? Sure, I have a bunch of tests that proves that. What about refactor? Go ahead, if the tests still pass, then you're doing good.

Maybe you won't notice it with this example, but with proper tests, you will save a lot of hours maintaining extra code, dealing with API design with hopefully won't end on breaking changes, refactoring code without fear and of course being sure that your code will work.

Testing is your friend, and with little effort on it, will save us real pain.

See you in the next section where we will dive into mock and spies to then test an Angular component from scratch.

Discussion (0)