Simple, Maintainable Code: Why You Should Learn Test-Driven Development for Angular
Seeing the Big Picture of TDD, mocking data and handling errors in an Angular Application
This is part two of the Test-Driven Development series
Read part one below, where I give an introduction to the Red-Green pattern of testing and how it applies to Angular applications
The Big Picture of Testing
Testing takes a lot of time. I often spend about the same amount of time (or more time) writings tests for my code than writing actual code. But overall, this practice has made me a more seasoned developer, and that’s mostly because of the time it takes.
I mentioned in the previous blog why test-driven development is important, but I’ll reiterate here:
It urges you to write simple, maintainable code
In reality, I’m pretty lazy and I’d like to get my solutions working by writing as few tests as possible.
Consider this: each if / else
in a single method now makes you write a unit test for each possibility. A nested if / else
is now four different unit tests, and another if / else
nested in there runs a whopping total of eight separate unit tests for a single method. In this case, it’s good to consider if refactoring is an option, and why so many branching paths are needed in the first place.
Essentially, it boils down to asking this:
What is the minimum amount of code I can write, so that I don’t have to write as many unit tests?
Otherwise, I’m left asking myself why I’m wasting so much time writing tests - and if I want my test suite to be reliable, I need high-quality code coverage, lest bugs make their way through.
Now, onto the goodies.
Last time, I wrote tests for an HTTP service, where I put in some placeholder JSON data (I plan to refactor when I build the server). Let’s fill out the controllers for the Recipe List and Recipe Detail components.
Making a plan
I have a few different endpoints to hit in the service (the CRUD endpoints). They need to be accessible by my components, so I start there. But first, we need to talk about isolation.
To test that my service was hit, I don’t actually need to render the HTML. In fact, on large enough test suites, rendering the HTML can actually slow the test down significantly. So, I’m not going to do that. I’m splitting my controller tests into two camps: Isolated tests, and DOM tests.
But there’s another, more sinister issue that maybe isn’t apparent at first: my controller is dependent on a service.
Testing these as one thing breaks the mantra of unit tests. The tests should just verify that a singular unit of code works, hence the name. If there’s one thing you take away from reading this, I really hope that it’s this:
The controller does not care about the inner workings of the service, only that it gives it the data it needs.
From the perspective of the controller, why should it matter that the service receives its data from an API, the Library of Alexandria or Hermes himself?
The HTTP service is an abstract layer - a sort of black box - which my component interacts with: The controller asks for data, and the service delivers it. Since we’ve already tested that the service works as intended, there is no need to couple them together and make our tests more complicated than needed. Despite the fact that the two are inextricably linked in practice, we simply tell our test suite that each one of our service endpoints returns some mock data we already created, which you can read about post linked below this paragraph.
This all comes full-circle when I finish part three of this series, where I'll detail how to unit test the DOM and how the Typescipt file acts like a black box to the HTML, in this same sense.
Returning Mocked Data
I’m going to take my stubbed data and throw it into a Jasmine spy object. I encourage you to read the documentation to understand how this works.
To create a spy object, I declare the service at the top of the test file, and add the mock data as Observables for the methods I need for this controller to work. For the Recipe List, I only need the getRecipes()
and delete()
methods, as I’ll have separate components for viewing a single recipe and adding/editing a recipe.
The spy goes in the main beforeEach()
block, because I want this list refreshed after every unit test.
//recipe-list.component.spec.ts
let service: RecipeService
beforeEach(() => {
service = jasmine.createSpyObj("RecipeService", {
getRecipes: of(RECIPES),
deleteRecipe: of(true)
})
})
With that implemented, I can now split my unit tests into two camps and make sure the fake service is provided in both.
describe("Isolated Tests", () => {
beforeEach(() => {
component = new RecipeListComponent(service);
})
})
describe("DOM Tests", () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ RecipeListComponent ],
providers: [{provide: RecipeService, useValue: service}]
})
.compileComponents();
fixture = TestBed.createComponent(RecipeListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
})
Testing the getRecipes() Controller Method
I like to create the names for the methods first so that I don’t get compiler errors when I try to reference non-existing methods in the tests.
//recipe-list.component.ts
private getRecipes(): void {} //private since the DOM does not access this
public deleteRecipe(recipeId: number): void {}
I’ll put the tests for these in my Isolated Tests
block. I’m using the Arrange → Act → Assert pattern of testing. First, I arrange the instance variable recipes
to be an empty array. Then, I act by calling the getRecipes()
method. Finally, I assert that recipes
equals my RECIPES
stub and that the service method was called. The service automatically returns RECIPES
thanks to the spy I set up. I will do something similar for the delete
method.
it("should get the list of recipes when getRecipes() is called", () => {
//arrange
component.recipes = [];
//act
component["getRecipes"]();
//assert
expect(component.recipes).toEqual(RECIPES);
expect(service.getRecipes).toHaveBeenCalled();
})
I fire up the suite with ng test
and, unsurprisingly, it fails:
Let’s write the code that helps it pass. Back in the controller, I fill everything out.
private getRecipes(): void {
this.recipeService.getRecipes()
.pipe(takeUntil(this.destroy$)) //this line prevents memory leaks
.subscribe({
next: recipes => this.recipes = recipes.slice()
})
}
My test passes now.
Since I’m subscribing to something in RxJS, I’ll need to prevent memory leaks. I wrote a unit test for this and ngOnDestroy()
, which you can find on my GitHub.
Branching Paths
I need to create and test the delete method. To prevent accidental deletions, I want to add a confirmation dialog.
Since there are two possibilities (either the user confirms, or they cancel), they each need a unit test. In the spec file, I fill out the first test, simulating that the user confirmed with a spy. It should call service to delete the recipe:
it("should only call the recipe service if the user confirms when deleteRecipe is called", () => {
//arrange
const confirmSpy: jasmine.Spy = spyOn(window, "confirm")
.and.returnValue(true);
//act
component.deleteRecipe(1);
//assert
expect(service.deleteRecipe).toHaveBeenCalledWith(1);
confirmSpy.calls.reset();
})
Let’s make this bad boy pass.
public deleteRecipe(recipeId: number): void {
const confirmDelete: boolean
= confirm("Are you sure you want to delete this recipe?");
this.recipeService.deleteRecipe(1)
.pipe(takeUntil(this.destroy$)) //to stop memory leaks
.subscribe();
}
Naturally, we need to test the other direction too. I set the spy to false this time.
it("should not call the recipe service if the user cancels the deletion", () => {
//arrange
const confirmSpy: jasmine.Spy = spyOn(window, "confirm")
.and.returnValue(false);
//act
component.deleteRecipe(1);
//assert
expect(service.deleteRecipe).not.toHaveBeenCalled();
confirmSpy.calls.reset();
})
To make it pass, I just add this block of code here after the confirmation dialog:
const confirmDelete: boolean
= confirm("Are you sure you want to delete this recipe?");
if(!confirmDelete) return;
Beautiful, and all without any HTML.
Handling Errors
What if there’s an issue getting data back, like an internal server error? We need to handle that case as well. I’ll do more robust error handling in a future post. For now, I’m going to log errors to the console and alert the user with JavaScript’s alert
. To create an alert, I alter our service’s spy object for just this one test - forcing getRecipes()
to throw an error.
it("should display an error if there is an issue with getRecipes()", () => {
//arrange
spyOn(console, "error");
spyOn(window, "alert").and.callThrough(); //prevents the window from freezing
service.getRecipes = jasmine.createSpy()
.and.returnValue(throwError(() => new Error()));
component.recipes = [];
//act
component["getRecipes"]();
//assert
expect(component.recipes).toHaveSize(0);
expect(service.getRecipes).toHaveBeenCalled();
expect(console.error).toHaveBeenCalled();
expect(window.alert).toHaveBeenCalled();
})
We technically don’t want this test to fail even though there was an error. This is the expected behavior. We should expect the recipes to still be an empty array and an error to have been thrown. Otherwise, how would the user know there was an issue?
//in the getRecipes() subscription -
next: recipes => this.recipes = recipes.slice(),
error: (err: Error) => {
console.error("There was an error fetching the recipes: ", err);
alert("There was an error fetching the recipes: " + err.message);
}
Love it. Let’s do something similar with the delete case. We still need to simulate the confirmation, but it essentially looks exactly the same:
//recipe-list.component.spec.ts
it("should not call the recipe service if the user cancels the deletion", () => {
//arrange
const confirmSpy: jasmine.Spy = spyOn(window, "confirm")
.and.returnValue(true);
spyOn(console, "error");
spyOn(window, "alert").and.callThrough();
service.deleteRecipe = jasmine.createSpy()
.and.returnValue(throwError(() => new Error()))
//act
component.deleteRecipe(1);
//assert
expect(service.deleteRecipe).toHaveBeenCalledWith(1);
expect(console.error).toHaveBeenCalled();
expect(window.alert).toHaveBeenCalled();
confirmSpy.calls.reset();
});
//recipe-list.component.ts, in the deleteRecipe() subscription
.subscribe({
error: (err: Error) => {
console.error("There was an error deleting the recipe: ", err);
alert("There was an error deleting the recipe: " + err.message);
}
});
Everything passes. Woohoo! I’ve written more tests for the Recipe Details component, and you can find them on my GitHub.
More with Less
Unit tests can be quite time-consuming, but I have to admit that it is pretty rewarding seeing all the green checkmarks next to my code. After doing this for a good while, I’ve gotten a lot faster at it. Yet, I still catch myself saying, “Is this worth writing a unit test over?” and scrapping so much unnecessary code. Like I said earlier, you learn to get more out of much less, so hopefully your code looks much cleaner and more maintainable in the end.
As usual, thanks for reading. Subscribe to The Logic of Zelda so you don’t miss the next part of the Unit Testing series, and check out the GitHub repo for this project so you don’t miss any of the goodies I left out of this post.