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 our RestaurantsService
  • 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 test HttpClient calls.

HttpTestingController

Angular’s HTTP testing library was designed with a pattern in mind:

  1. Make a request;
  2. Expect one or more requests have (or not have) been made;
  3. Perform assertions;
  4. Resolve requests (flush);
  5. 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.ts

import {
  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();
  });
});