Writing Unit Tests page
Write a unit test for a service in Angular.
Overview
In this part, we will:
- Write a new
getRestaurant
method on ourRestaurantsService
- Write a unit test for the
getRestaurant
method
Problem
In the next section we’re going to be creating a restaurant detail view. We’ll need to have a getRestaurant
method on our service that returns one restaurant from the list. Once the method is set up, write a unit test ensuring it makes the correct request and returns an object type of Restaurant
.
What you need to know
How to write a unit test. Here’s a codeblock to get you started:
it('should make a GET request to get a restaurant based on its slug', () => { });
How to use
HttpTestingController
to testHttpClient
calls.
HttpTestingController
Angular’s HTTP testing library was designed with a pattern in mind:
- Make a request;
- Expect one or more requests have (or not have) been made;
- Perform assertions;
- Resolve requests (flush);
- Verify there are no unexpected requests.
Items 2
through 5
are covered by the Angular HttpTestingController
, which enables mocking and flushing of requests.
The HttpClientTestingModule
needs to be imported in the TestBed, and HttpTestingController
can be injected using the TestBed for usage within test blocks.
For this exercise, both HttpClientTestingModule
and HttpTestingController
are already configured in restaurant.service.spec.ts
file.
You can access HttpTestingController
with the httpTestingController
variable.
Expecting a request to be made
expectOne
method is handy when you want to test if a single request was made. expectOne
will return a TestRequest object in case a matching request was made. If no request or more than one request was made, it will fail with an error.
const req = httpTestingController.expectOne('/api/states');
Verifying the request
A TestRequest
’s request
property provides access to a wide variety of properties that may be used in a test. For example, if we want to ensure an HTTP GET request is made:
expect(req.request.method).toEqual('GET');
Flushing request data
Without a specific command, outgoing requests will keep waiting for a response forever. You can resolve requests by using the TestRequest
’s flush
method
req.flush(mockStates);
Avoiding the unexpected
When testing service methods in isolation, it’s better to ensure we don’t have unexpected effects. HttpTestingController
’s verify
method ensures there are no unmatched requests that were not handled.
httpTestingController.verify();
Putting it all together
it('should make a GET request to get the states data', () => {
const mockStates = {
data: [{ name: 'California', short: 'CA' }]
};
service
.getStates()
.subscribe((states: State[]) => {
expect(states).toEqual(mockStates);
});
const req = httpTestingController.expectOne('/api/states');
expect(req.request.method).toEqual('GET');
req.flush(mockStates);
httpTestingController.verify();
});
Setup
✏️ Update src/app/restaurant/restaurant.service.ts file with the new getRestaurants method:
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { environment } from '../../environments/environment';
import { Restaurant } from './restaurant';
export interface ResponseData<DataType> {
data: DataType[];
}
export interface State {
name: string;
short: string;
}
export interface City {
name: string;
state: string;
}
@Injectable({
providedIn: 'root'
})
export class RestaurantService {
constructor(private httpClient: HttpClient) {}
getRestaurants(
state: string,
city: string
): Observable<ResponseData<Restaurant>> {
const params = new HttpParams()
.set('filter[address.state]', state)
.set('filter[address.city]', city);
return this.httpClient.get<ResponseData<Restaurant>>(
environment.apiUrl + '/restaurants',
{ params }
);
}
getStates(): Observable<ResponseData<State>> {
return this.httpClient.get<ResponseData<State>>(
environment.apiUrl + '/states'
);
}
getCities(state: string): Observable<ResponseData<City>> {
const params = new HttpParams().set('state', state);
return this.httpClient.get<ResponseData<City>>(
environment.apiUrl + '/cities',
{ params }
);
}
getRestaurant(slug: string): Observable<Restaurant> {
return this.httpClient.get<Restaurant>(
environment.apiUrl + '/restaurants/' + slug
);
}
}
Solution
Hint: you may use existing tests on the service as a guide.
Click to see the solution
✏️ Update src/app/restaurant/restaurant.service.spec.tsimport {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { Restaurant } from './restaurant';
import {
City,
ResponseData,
RestaurantService,
State,
} from './restaurant.service';
describe('RestaurantService', () => {
let httpTestingController: HttpTestingController;
let service: RestaurantService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
});
httpTestingController = TestBed.inject(HttpTestingController);
service = TestBed.inject(RestaurantService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should make a GET request to restaurants', () => {
const mockRestaurants = {
data: [
{
name: 'Brunch Place',
slug: 'brunch-place',
images: {
thumbnail:
'node_modules/place-my-order-assets/images/4-thumbnail.jpg',
owner: 'node_modules/place-my-order-assets/images/2-owner.jpg',
banner: 'node_modules/place-my-order-assets/images/2-banner.jpg',
},
menu: {
lunch: [
{ name: 'Ricotta Gnocchi', price: 15.99 },
{ name: 'Garlic Fries', price: 15.99 },
{ name: 'Charred Octopus', price: 25.99 },
],
dinner: [
{ name: 'Steamed Mussels', price: 21.99 },
{ name: 'Roasted Salmon', price: 23.99 },
{ name: 'Crab Pancakes with Sorrel Syrup', price: 35.99 },
],
},
address: {
street: '2451 W Washburne Ave',
city: 'Ann Arbor',
state: 'MI',
zip: '53295',
},
_id: 'xugqxQIX5rPJTLBv',
},
{
name: 'Taco Joint',
slug: 'taco-joint',
images: {
thumbnail:
'node_modules/place-my-order-assets/images/4-thumbnail.jpg',
owner: 'node_modules/place-my-order-assets/images/2-owner.jpg',
banner: 'node_modules/place-my-order-assets/images/2-banner.jpg',
},
menu: {
lunch: [
{ name: 'Beef Tacos', price: 15.99 },
{ name: 'Chicken Tacos', price: 15.99 },
{ name: 'Guacamole', price: 25.99 },
],
dinner: [
{ name: 'Shrimp Tacos', price: 21.99 },
{ name: 'Chicken Enchilada', price: 23.99 },
{ name: 'Elotes', price: 35.99 },
],
},
address: {
street: '13 N 21st St',
city: 'Chicago',
state: 'IL',
zip: '53295',
},
_id: 'xugqxQIX5dfgdgTLBv',
},
],
};
service
.getRestaurants('IL', 'Chicago')
.subscribe((restaurants: ResponseData<Restaurant>) => {
expect(restaurants).toEqual(mockRestaurants);
});
const url =
'http://localhost:7070/restaurants?filter%5Baddress.state%5D=IL&filter%5Baddress.city%5D=Chicago';
// url parses to 'http://localhost:7070/restaurants?filter[address.state]=IL&filter[address.city]=Chicago'
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockRestaurants);
httpTestingController.verify();
});
it('can set proper properties on restaurant type', () => {
const restaurant: Restaurant = {
name: 'Taco Joint',
slug: 'taco-joint',
images: {
thumbnail: 'node_modules/place-my-order-assets/images/4-thumbnail.jpg',
owner: 'node_modules/place-my-order-assets/images/2-owner.jpg',
banner: 'node_modules/place-my-order-assets/images/2-banner.jpg',
},
menu: {
lunch: [
{ name: 'Beef Tacos', price: 15.99 },
{ name: 'Chicken Tacos', price: 15.99 },
{ name: 'Guacamole', price: 25.99 },
],
dinner: [
{ name: 'Shrimp Tacos', price: 21.99 },
{ name: 'Chicken Enchilada', price: 23.99 },
{ name: 'Elotes', price: 35.99 },
],
},
address: {
street: '13 N 21st St',
city: 'Chicago',
state: 'IL',
zip: '53295',
},
_id: 'xugqxQIX5dfgdgTLBv',
};
// will error if interface isn’t implemented correctly
expect(true).toBe(true);
});
it('should make a GET request to states', () => {
const mockStates = {
data: [{ name: 'Missouri', short: 'MO' }],
};
service.getStates().subscribe((states: ResponseData<State>) => {
expect(states).toEqual(mockStates);
});
const url = 'http://localhost:7070/states';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockStates);
httpTestingController.verify();
});
it('should make a GET request to cities', () => {
const mockCities = {
data: [{ name: 'Kansas City', state: 'MO' }],
};
service.getCities('MO').subscribe((cities: ResponseData<City>) => {
expect(cities).toEqual(mockCities);
});
const url = 'http://localhost:7070/cities?state=MO';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockCities);
httpTestingController.verify();
});
it('should make a GET request to get a restaurant based on its slug', () => {
const mockRestaurant = {
name: 'Brunch Place',
slug: 'brunch-place',
images: {
thumbnail: 'node_modules/place-my-order-assets/images/4-thumbnail.jpg',
owner: 'node_modules/place-my-order-assets/images/2-owner.jpg',
banner: 'node_modules/place-my-order-assets/images/2-banner.jpg',
},
menu: {
lunch: [
{ name: 'Ricotta Gnocchi', price: 15.99 },
{ name: 'Garlic Fries', price: 15.99 },
{ name: 'Charred Octopus', price: 25.99 },
],
dinner: [
{ name: 'Steamed Mussels', price: 21.99 },
{ name: 'Roasted Salmon', price: 23.99 },
{ name: 'Crab Pancakes with Sorrel Syrup', price: 35.99 },
],
},
address: {
street: '2451 W Washburne Ave',
city: 'Ann Arbor',
state: 'MI',
zip: '53295',
},
_id: 'xugqxQIX5rPJTLBv',
};
service
.getRestaurant('brunch-place')
.subscribe((restaurant: Restaurant) => {
expect(restaurant).toEqual(mockRestaurant);
});
const url = 'http://localhost:7070/restaurants/brunch-place';
const req = httpTestingController.expectOne(url);
expect(req.request.method).toEqual('GET');
req.flush(mockRestaurant);
httpTestingController.verify();
});
});