How Does Test-Driven Development Work in Angular?
The red-green approach is a common testing pattern. Here's how to implement it in a service where you Create, Read, Update and Delete (CRUD) data. This is part 3 of the dev diary for Potluck.
This is an ongoing tutorial series and development diary about full-stack software development
Last time, we started scaffolding a feature service to view recipes in our app. I encourage you to check out where the app is so far, so that you have more context going in.
As always, the code for this project can be found in the GitHub repo.
Why Test-Driven Development?
There are a lot of ways your application can benefit from unit test coverage, especially when testing components and features in isolation. The biggest reason to write tests helps you write cleaner and more maintainable code, because messy and disorganized code is difficult to test.
Test-Driven Development (TDD) is a design pattern where you write the test first, and then write the business logic that helps that test pass. This is also known as a Red-Green approach (not to be confused with the popular Canadian sketch comedy show of the same name), where you write a failing test (red) and add code little-by-little until that test passes (green).
Today, I'm adding some new methods to the Recipe Service. But before adding each method, I'm going to write the unit tests for them. Test-Driven Development is going to be an important part of the blog going forward.
Making a Plan
Currently, I want to write a method that creates a new recipe. I don't have an actual API set up yet though, so I'm going to be using JSON Placeholder for now. We'll be adding in the rest of the CRUD methods for the Recipe Service. There are endpoints for each of these in JSON Placeholder's website, and I encourage you to play around with this.
CRUD is more-or-less how most apps handle their data. Users need to be able to create new data, edit their data, read data other users share, and remove their data.
Arrange-Act-Assert
The testing pattern I'm using today is Arrange-Act-Assert. You arrange your code to fit a specific set of conditions, act on that condition, and then verify your assertion about what should have happened.
In Angular, tests are written on the .spec.ts
files added with the CLI.
A standard test might look something like this:
it("should get a list of recipes when getRecipes is called", () =>
//arrange
//act
//assert
})
Planning a Test
For the Arrange portion, I'm going to set up necessary variables like the endpoint URL to start out.
In the Act portion, I want to call the method and verify the right data comes through when I subscribe to the method.
Finally, I’m going to assert several things - I want to assert the data coming back looks right, an HTTP request was made and the right HTTP method was used.
Setting up the Test Suite to Handle Fake HTTP Requests
The test suite by-and-large will be set up the same, regardless of whether you use the real API or this placeholder API. We need the HttpTestingController
service and the HttpClientTestingModule
. Angular’s documentation has a lot covering this subject, so feel free to give that a look too if you want some more info.
let service: RecipeService;
let http: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
service = TestBed.inject(RecipeService);
http = TestBed.inject(HttpTestingController);
});
Writing the First (Failing) Test
Let’s set up a test for the getRecipes()
method. Technically, this method already exsists, but it’s not set up to handle an HTTP request. I went ahead and removed everything and return of(undefined)
so we can start from scratch without a compilation error:
public getRecipes(): Observable<any> { return of(undefined) }
I’m incrementally adding logic to the test, and then the method - passing and failing it in intervals until the method is finished.
Arrange
This part is easy. I’m just adding in the endpoint URL so I can use it later. I’m getting my endpoints from here.
it("should get a list of recipes when getRecipes is called", () => {
//arrange
const url: string = "https://jsonplaceholder.typicode.com/posts"
//act
//assert
});
Act
I want to fire our method and subscribe to it, verifying the data is what we want. I am using the stub file created in the previous post (and the complete stub file can be found in the GitHub repo). Technically there is an assert statement inside the subscription, but we’ll be writing more.
//act
service.getRecipes().subscribe({
//assert
next: recipes => expect(recipes).toEqual(RECIPES),
error: () => fail("GetRecipes test failed. This should not be reached.")
});
Don’t worry about the recipe object not looking like what comes back from the JSON Placeholder. I know it can be confusing, but the beauty of unit tests is that we’re only testing a singular unit. What comes back from the API is the server’s business, and that’s what End-to-End tests are for, which are FAR outside the scope of this post.
Assert
We’ve asserted the data looks correct. Let’s go ahead and run the test to see what happens. To run only this test, I’ve added the letter f (short for “focus”) to the it
statement ( fit("should get a list . . .
). You can do the same for a describe
block as well (fdescribe
) and exclude a block or test with x (xdescribe
/ xit
).
Running the test with ng test
in the console window shows me the test failed, and why:
The console is really helpful here. Let’s focus on why the test failed. Up near the top it says, Error: Expected undefined to equal [ Object... ]
and also shows us where it happened in the stack trace. Let’s fix that. I altered the method to pass what we’ve written in the test, but no more.
public getRecipes(): Observable<Recipe[]> { return of(RECIPES) }
The test passes now. Yay!
Being Thorough
This isn’t the best behavior for the test, though. It’s not making an HTTP request, and I want to verify it does that.
//assert
http.expectOne(url);
http.verify();
These seem like innocuous lines, but we see now that the test fails again because the method didn’t make an HTTP request.
I don’t even have the HTTP Client injected in our service, so I go ahead and add that and the new HTTP request.
//recipe.service.ts
private readonly url: string = "https://jsonplaceholder.typicode.com";
constructor(private http: HttpClient){}
public getRecipes(): Observable<Recipe[]> {
return this.http.get<Recipe[]>(this.url + "/posts")
}
After adding this in, the test doesn’t fail, but it shows we have no expectations.
I need to add one more line to the test to fire the result.
//assert
const request: TestRequest = http.expectOne(url);
request.flush(RECIPES);
http.verify();
request.flush(RECIPES)
fires the fake HTTP call with our fake recipe data, and we can subscribe to it. Now, the test passes. Yay serotonin!
After adding one last expect
statement to the end, this is what the complete test looks like:
it("should get a list of recipes when getRecipes is called", () => {
//arrange
const url: string = "https://jsonplaceholder.typicode.com/posts"
//act
service.getRecipes().subscribe({
next: recipes => expect(recipes).toEqual(RECIPES),
error: () => fail("GetRecipes test failed. This should not be reached.")
});
//assert
const request: TestRequest = http.expectOne(url);
expect(request.request.method).toBe("GET");
request.flush(RECIPES);
http.verify();
});
I went ahead and added the test for the other existing service method, and both the test and method look similar to what we already wrote. In addition, for display purposes, I added a .pipe()
and map()
to return the RECIPES
stub so that I can keep displaying the dummy data for now. You can check those out at the GitHub repo.
Create, Update and Delete Tests
To make this simpler, I extracted the URL to a variable above all the tests since it never changes. Also, I moved the http.verify()
to an afterEach()
block, so that it runs after every test.
The tests for these are going to be fairly similar to the other two I’ve already written:
it("should create a new recipe and return a copy of it from the backend when createNewRecipe is called", () => {
//act
service.createRecipe(CREATE_RECIPE).subscribe({
next: recipe => expect(recipe).toEqual({RecipeId: 4, ...CREATE_RECIPE}),
error: () => fail("CreateRecipe test failed. This should not be reached.")
});
//assert
const request: TestRequest = http.expectOne(url);
expect(request.request.method).toBe("POST");
request.flush(CREATE_RECIPE);
});
it("should update an existing recipe when editRecipe() is called", () => {
//act
service.editRecipe(RECIPES[0]).subscribe({
next: recipe => expect(recipe).toEqual(RECIPES[0]),
error: () => fail("EditRecipe test failed. This should not be reached.")
});
//assert
const request: TestRequest = http.expectOne(url + "/1");
expect(request.request.method).toBe("PATCH");
request.flush(RECIPES[0]);
});
it("should delete a recipe when deleteRecipe() is called", () => {
//act
service.deleteRecipe(1).subscribe({
next: recipe => expect(recipe).toEqual(true),
error: () => fail("EditRecipe test failed. This should not be reached.")
});
//assert
const request: TestRequest = http.expectOne(url + "/1");
expect(request.request.method).toBe("DELETE");
request.flush({});
});
The basic setup is the same for these. I went in, added a test, and then added enough logic to the method to help it pass. I’m kinda cheating with these by mapping the value I want to these, but I still need these methods to work for the testing data. Without the pipe(map())
methods, these tests fortunately still pass.
public createRecipe(newRecipe: CreateRecipe): Observable<Recipe> {
return this.http.post<Recipe>(this.url + "/posts", newRecipe)
.pipe(map(() => {return {RecipeId: 4, ...newRecipe}}))
}
public editRecipe(recipe: Recipe): Observable<Recipe> {
const id: number = recipe.RecipeId;
return this.http.patch<Recipe>(this.url + "/posts/" + id, recipe)
.pipe(map(() => recipe))
}
public deleteRecipe(recipeId: number): Observable<boolean> {
return this.http.delete<any>(this.url + "/posts/" + recipeId)
.pipe(map(res => !!res))
}
End of Part 1
This is the end for Part 1 of CRUD testing. You can read Part 2 here: