Let's dive into the topic of unit testing our applications.
What is unit testing and why should I care?
Unit tests are TypeScript files we put together to verify that each component of our application behaves as expected. In practice, this means crafting hundreds of lines of code to confirm our logic works correctly.
- Isn't that a waste of time? Management constantly pushes for speed, and hundreds of lines hardly sounds like fast. On the flip side, that code will actually save us HOURS. Not convinced? I've got evidence. <!--more-->
Extra code: How often have you ended up with unused code? Maybe you added loops that turned out to be unnecessary, or wrote a function for a task you later dropped. When we build our modules without testing first, we don't really know what's needed or whether our algorithm handles all input types (which often leads to those extra loops). More code means more maintenance, and that translates to higher costs.
Bad API design: Imagine creating a new service. You start writing functions to handle the work and mark some as public to define the service's API. That sounds right, doesn't it? Before long, users complain that the API is confusing and not as intuitive as we'd hoped. This category also covers API functions that turn out to be unnecessary (and that's also extra code).
Refactor: What about when we want to refactor our code? That's a real headache. Even if we're careful not to break the API, an internal change might fail on some edge cases that used to work fine. That can break the app for certain users and they won't be pleased (and these types of bugs are notoriously hard to track down).
Will it work: That's the ultimate goal and probably the biggest time drain in any project. Even something straightforward like a calendar needs math and a few magic numbers to function correctly. We have to be certain it works. How? We pick a date, check it against our OS calendar, and repeat for various dates (both past and future). Then we tweak a service and need to verify the dates again to make sure nothing's broken. Do that 20 times for a typical service development cycle.
How does the unit test help?
Alright, you've convinced me that skipping unit tests might've been a mistake. But how does it actually address those issues? What if we walk through a very basic example? (It's not Angular-specific, and I'll go step by step to make the point crystal clear.)
Suppose I want an object that can perform basic math operations (addition and division). Your first instinct is to write a class with methods for those operations. We'll end up with something similar, but instead, we'll test it first. Test first? Why? Stick with me.
(If you'd like to code along, I've prepared a codesandbox for you.)
This codesandbox (and the Angular app we'll test in the upcoming sections) relies on Jest. Jest is a testing framework that works with any Javascript or Typescript project.
Our object needs to add 5 and 3 to produce 8. Let's write a test for 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);
});
});
Before we even open the Tests tab inside codesandbox, let's take a closer look at this particular snippet.
At first glance, the code reads almost like a hybrid of plain English and TypeScript. The intent behind testing is clarity—it should be effortlessly readable and immediately graspable. Simply scanning through the lines reveals its purpose:
"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.".
Diving back into the specifics, each test is encapsulated within a describe function. These functions serve as containers for grouping related tests. The real test logic lives inside functions named it, where the actual assertions are written.
Within each it function, we adhere to the AAA pattern (Arrange, Act, Assert). By following these three stages, we can construct a valid test.
In the provided scenario, the Arranging step involves instantiating a Calculator object, the Acting step calls its sum method, and the Asserting step compares the outcome against the expected value.
So, what does this test actually produce as its final result?
Not much of a surprise there — the test came first, before Calculator even existed.
What stands out is that we're shaping the API up front, thinking through the design before writing any implementation code. We've already decided on a sum method even though the class hasn't been built yet.
Time to make it happen, right?
Located at: src/calculator.ts
export class Calculator {
sum(num1: number, num2: number): number {
return 8;
}
}
Next, we bring it into our spec file as well:
Located at: src/Calculator.spec.ts:
import { Calculator } from './calculator';
describe('Calculator', () => {
...
});
What does our test says now?
That approach hardly seems right, does it? The value 8 was baked straight into the function, ensuring our tests would succeed.
Our goal should be writing the simplest conceivable code that lets the tests pass. This example is obviously synthetic, and we can tell the logic is incomplete, yet in practical situations—as we will see shortly—you often cannot determine when an implementation is adequate. Thus, the best move is to pass each test with the least effort, just as demonstrated here.
Given the uncertainty whether this code suffices, additional tests are necessary:
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);
});
If we see the test tab we see:
The output confirms that only one of our two tests passed. The failure report points directly to the problematic spot: the function returned 8 when we had anticipated 7. This indicates a bug in our implementation.
With this feedback, the initial uncertainty about the code's correctness disappears. We can instantly confirm that the logic is flawed, and we'll need to make adjustments until every test turns green.
Here’s the correction we need to apply:
File: src/calculator.ts
export class Calculator {
sum(num1: number, num2: number): number {
return num1 + num2;
}
}
Now our tests says:
Let's quickly glance at our existing spec file before we proceed.
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);
});
});
To start with, notice that in our spec file, every it block operates in total isolation. There is no shared state between them, and you must never assume that running one in a particular order sets things up for another to check. In reality, Jest can shuffle the sequence of it blocks to discourage any hidden coupling.
Next, examine the implementation. It contains duplicated logic. The DRY (don't repeat yourself) rule is less strict here than in our core application. Some repetition is acceptable when writing tests, but duplicating the entire setup is unjustified.
Specifically, the Arrange step is duplicated across both tests. If we had 20 such tests, that same setup would appear 20 times. There's a smarter approach.
A hook called beforeEach executes prior to every it block. This is where we can prepare everything required for each test. Let's move the Arrange step there so that calc becomes available in every test.
Here's the refactored version:
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);
});
});
Before we consider any test refactor, we must make sure every test passes. This way, we can be confident that our changes won't introduce failures.
That covers the basics. Now, let's push it further by adding a few more varied cases to verify its behavior under different conditions:
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);
});
Notice that I combined two lines into a single one in the previous example. Since readability remains intact, I see no problem with it.
Our implementation appears to manage these two scenarios without issues.
Next, we’ll examine division; however, prior to that, we can enclose the sum tests within a dedicated describe block as shown:
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);
});
});
});
There is no limit to how many describe blocks we can nest. Also, note the # in #sum; this convention indicates we are about to test a method.
Let's then add another describe, this time covering a division case with a minimal test:
Located in: 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);
});
});
It fails:
Well, that was unexpected. Here's a quick fix:
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;
}
}
With the requirements now more precisely defined, a sharper division implementation was introduced.
Decimals are kept out of Calculator entirely — nobody needs that kind of noise.
See src/calculator.spec.ts:
it('returns a rounded result for a non exact division', () => {
expect(calc.division(20, 3)).toBe(7)
});
As it turns out, TypeScript is quite comfortable with assertions of this kind.
So now, let’s address that issue.
Located at 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);
}
}
Great news — the test suite is not just passing in round numbers anymore, it still behaves correctly.
Next up, we need to raise an error whenever a division by zero occurs.
Open the file at src/calculator.spec.ts:
it('throws an exception if we divide by 0', () => {
expect(() =>
calc.division(5, 0)
).toThrow('Division by 0 not allowed.');
});
Notice how this test is constructed differently — rather than handing a value to expect, we supply a function. The reasoning behind it is: "We anticipate that invoking this function will produce an error." Because division is incapable of yielding any output when it throws, testing the result in the manner we used earlier is not an option.
As expected, this test does not pass:
Here is how the code looks prior to our modifications:
Location: 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);
}
}
When a division by 0 occurs, we know the divisor is 0, but how do we pinpoint which line in our code is responsible? Before refactoring, we have to get all tests green—yet one currently fails. One option is to mark that test as skipped during the 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.');
});
Notice the xit in the snippet. This is how we mark a test as "ignored." While commenting the code out is always an option, doing so risks losing track of the test that still needs fixing. The xit approach lets us keep the test visible while clearly signaling it has been skipped.
NOTE: a quirk of codesandbox is that it doesn't handle
xitcleanly, but the good news is that it still reports zero failing tests
With the broken test now out of the way, we can focus on refactoring the code.
export class Calculator {
sum(num1: number, num2: number): number {
return num1 + num2;
}
division(dividend: number, divisor: number): number {
return Math.round(dividend / divisor);
}
}
Much better and tests still pass:
NOTE: As previously stated, Codesandbox handles this poorly. You might see a failed red X in the UI even when all tests pass in the report, which is expected.
This constitutes a code refactor with no risk of introducing regressions.
Let's switch back from xit to it:
Path: src/calculator.spec.ts:
it('throws an exception if we divide by 0', () => {
expect(() =>
calc.division(5, 0)
).toThrow('Division by 0 not allowed.');
});
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);
}
}
And there you have it—you've successfully built your very first test suite.
Conclusions of this example
Even with such a straightforward case, we've already tackled the challenges mentioned earlier:
Our calculator stays free of extra code because we implemented only what was necessary for it to function. Its API design is solid, thanks to using it as we would in a real-world scenario. Will it work? Absolutely—my tests provide solid proof. Thinking about a refactor? Go for it; if your tests remain green, you're on the right track.
This example might not make it obvious, but with well-crafted tests, you'll spare yourself countless hours maintaining extra code, handling API design that ideally avoids breaking changes, refactoring without hesitation, and having full confidence that your code will work.
Testing is an ally, and with minimal investment, it shields us from real headaches.
Join me in the next part, where we'll explore mocks and spies before building an Angular component from the ground up.











