Looking for prompt engineering tips tailored for QA? | Jump in

Testing Angular Services: A Walk-Through With Examples

You've been going along writing your Angular application, and you've now reached a point where you have enough code in…

By Testim,

You’ve been going along writing your Angular application, and you’ve now reached a point where you have enough code in your application for things to get complicated. You may even have a few tests here and there to validate things are working. Now you want to focus your efforts on tests that will give back the most value. Some of the most valuable tests you’ll write are tests that verify your app’s logic is correct and data is being handled correctly. These are usually your services in your Angular application.

In this blog post, let’s show you how to take advantage of all of Angular’s built-in paths for testing out an Angular service to make sure you deliver the highest-quality code consistency.

Testing Angular Services: Why Do It in the First Place?

Let’s set the stage for why we even bother with testing Angular services in the first place. We want to test services because these should house a lot of your business logic. They house the business logic because it’s usually easier to maintain components that do one thing and do them well. Services were designed to house how data gets in and out of your application. This is why testing them is so valuable: if we can ensure data is getting in and out correctly, we can release with confidence when all of the tests pass.

Compare that to a component that does rendering or other DOM interactions. We have to make sure we set up and simulate the browser APIs to ensure we have the right things working. We don’t have to do such heavy lifting in a service.

Because of this, your services are usually where your highest code coverage should be. If your project is starting to get testing under control, services are a great place to start. Now let’s make sure your project is set up for testing and cover some of the basics of the testing libraries we’ll be concerned about.

Setting Up Your Project for Testing

I’m going to assume you used the ng tool to set up your project because it really does all the heavy lifting. The default testing library it sets up is the Karma testing framework, which wires everything up for you. A basic test will look something like this:

import { TestBed } from '@angular/core/testing';

import { DataService } from './data.service';

describe('DataService', () => {
  beforeEach(() => TestBed.configureTestingModule({}));

  it('should be created', () => {
    const service: DataService = TestBed.get(DataService);
    expect(service).toBeTruthy();
  });

  it('should be something', () => {
    expect(false).toBeTruthy();
  })
});

So what are all of those parts? Here are some of the highlights of what we see here:

  • The outside describe block gives you a place to bundle up a set of tests to set the stage for what you’re testing.
  • The it blocks are where your tests go. Each test will go through setting up the environment, acting upon the environment, and then asserting that everything did what it was supposed to (the AAA style of testing).
  • The assertions built into Jasmine allow you to verify different parts.

Running these tests is just as simple as running ng test. Angular handles all of the wiring and configuration for you. This thankfully makes it very easy to get up and run quickly with your testing needs.

Testing a Service in Isolation

Now that we see what a basic test looks like let’s go through an example of testing a service in isolation. I mean by “isolation” here that this service doesn’t depend on anything else—well, except a way to fetch data from outside the application. Let’s take this service as an example:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';

export interface MyData {
  name: string
}

@Injectable({
  providedIn: 'root'
})
export class DataService {

  private API_URL = 'http://localhost:9999'

  constructor(private httpClient: HttpClient) { }

  public getData(nameFilter: string = ''): Observable<MyData[]> {
    let apiObserverable = this.httpClient.get<MyData[]>(this.API_URL + '/data');

    if (nameFilter != '') {
      apiObserverable = apiObserverable.pipe(map(value => value.filter(data => data.name.indexOf(nameFilter) != -1)));
    }

    return apiObserverable.pipe(catchError(error => of<MyData[]>([])));
  }
}

This service has a function that fetches data. From there, it’ll filter out the data by the type property.

So what should we test? Well, here are a few examples of things we may want to test to ensure they’re working:

  • A test in which data from a URL is returned
  • Another test that if we specify a filter variable to the method, the data is indeed filtered
  • And another test that if the URL doesn’t return successfully, we handle the error appropriately.

So what does that look like? Here are the tests for those cases:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';

import { DataService, MyData } from './data.service';

describe('DataService', () => {
  let httpTestingController: HttpTestingController;
  let service: DataService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule]
    });

    httpTestingController = TestBed.get(HttpTestingController);

    service = TestBed.get(DataService);
  });

  afterEach(() => {
    httpTestingController.verify();
  });

  it('#getData should return expected data', (done) => {
    const expectedData: MyData[] = [
      { 'name': 'one' },
      { 'name': 'two' },
      { 'name': 'three' },
    ];

    service.getData().subscribe(data => {
      expect(data).toEqual(expectedData);
      done();
    });

    const testRequest = httpTestingController.expectOne('http://localhost:9999/data');

    testRequest.flush(expectedData);
  });

  it('#getData should use GET to retrieve data', () => {
    service.getData().subscribe();

    const testRequest = httpTestingController.expectOne('http://localhost:9999/data');

    expect(testRequest.request.method).toEqual('GET');
  });

  it('#getData should filter out data', (done) => {
    const returnedData: MyData[] = [
      { 'name': 'my stuff' },
      { 'name': 'my stuff #2' },
      { 'name': 'your stuff' },
    ];

    const expectedData: MyData[] = [
      { 'name': 'my stuff' },
      { 'name': 'my stuff #2' },
    ]

    service.getData('my stuff').subscribe(data => {
      expect(data).toEqual(expectedData);
      done();
    });

    const testRequest = httpTestingController.expectOne('http://localhost:9999/data');

    testRequest.flush(returnedData);
  });

  it('#getData should return an empty object on error', (done) => {
    const expectedData: MyData[] = []

    service.getData().subscribe(data => {
      expect(data).toEqual(expectedData);
      done();
    });

    const testRequest = httpTestingController.expectOne('http://localhost:9999/data');

    testRequest.flush('error', { status: 500, statusText: 'Broken Service' });
  });
});

As you can see from the tests, we’re testing the behavior around submitting data to endpoints. This is a common behavior of services. Services usually wrap behavior around submitting data to HTTP endpoints or retrieving data from HTTP endpoints.

Another important point is that while we’re testing the happy path, we also want to test out the opposite of the happy path, the unhappy path. These tests show that you can handle error scenarios or problems that may arise. This way, you can ensure that your user experiences the right thing, rather than getting some kind of unexpected error screen and getting stuck with a bad user experience.

The trickiest bit of the above example is the async behavior. You may have noticed the done call in the subscribed block. That signals to Jasmine that the test has finished executing and to process any events. While this doesn’t give the best flow for a test, it is a technique that helps when your test needs to test async operations. Later, I’ll show a different way of testing out async tests by controlling the async flow.

Testing a Service That Has Dependencies With Mocking

What would happen if we need to test a service that depends on another service? Well, we have a way of doing that too. We can inject our dependency with the behavior we want so we can control the environment. This is called mocking. Mocking is a testing strategy to control the behavior of what is under test so you can get the environment set up appropriately. This way, you are in control of what is under test. So, what does a service look like when it takes a dependency? Let’s take this example:

import { Injectable } from '@angular/core';

export class ValidationService {
  isValid(): boolean {
    return false;
  }
}

@Injectable({
  providedIn: 'root'
})
export class DataService {

  constructor(private validator: ValidationService) { }

  public getData(): boolean {
    return this.validator.isValid();
  }
}

This service takes another service that will confirm the data submitted to it is valid. This is just a simple example to get a feeling of what you may actually use. For this example, the test cases we care about are as follows:

  • We want to test the right behavior if the verification is true.
  • We want to test the right behavior if the verification is false.

Here is what those tests would look like:

import { TestBed } from '@angular/core/testing';

import { DataService, ValidationService } from './data.service';

describe('DataService', () => {
  let service: DataService;
  let mockValidator: jasmine.SpyObj<ValidationService>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        DataService,
        { provide: ValidationService, useValue: jasmine.createSpyObj('ValidationService', ['isValid']) }
      ]
    });

    service = TestBed.get(DataService);
    mockValidator = TestBed.get(ValidationService);
  });

  it('#getData should true when verification is true', () => {
    mockValidator.isValid.and.returnValue(true);
    expect(service.getData()).toBeTruthy()
  });

  it('#getData should return false when verification is fales', () => {
    mockValidator.isValid.and.returnValue(false);
    expect(service.getData()).toBeFalsy()
  });
});

As you can see, we inject our mock into the constructor and control the behavior. We do this so we don’t have to instantiate a full implementation of that dependency, and we control the limited behavior we care about.

Testing Observables in Services

Another feature of Angular you may need to test from time to time is the Observables. These objects are where you usually have an event you want to take action on. In these events, we want to make sure that your service takes the appropriate action for the submitted event, which makes for an excellent place for a test. Here is what an example service may look like when doing that:

import { Injectable } from '@angular/core';
import { of, Observable } from 'rxjs';
import { filter } from 'rxjs/operators';

export class DataStream {
  dataStream(): Observable<string> {
    return of('');
  }
}

@Injectable({
  providedIn: 'root'
})
export class DataService {

  constructor(private dataStream: DataStream) { }

  public getData(): Observable<string> {
    return this.dataStream.dataStream().pipe(
      filter(value => value.indexOf('test') >= 0)
    );
  }
}

This service will take a stream from the observer and filter out any value that doesn’t contain the value of “test.” Here are what the tests look like:

import { TestBed, fakeAsync, tick, flushMicrotasks } from '@angular/core/testing';

import { DataService, DataStream } from './data.service';
import { Observable, from, of } from 'rxjs';

describe('DataService', () => {
  let service: DataService;
  let mockStream: jasmine.SpyObj<DataStream>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [
        DataService,
        { provide: DataStream, useValue: jasmine.createSpyObj('DataStream', ['dataStream']) }
      ]
    });

    service = TestBed.get(DataService);
    mockStream = TestBed.get(DataStream);
  });

  it('#getData should return value that contains test', fakeAsync(() => {
    const stream = of('testing value');

    mockStream.dataStream.and.returnValue(stream);

    let capturedValue: String = null;

    service.getData().subscribe(value => {
      capturedValue = value;
    });

    flushMicrotasks();

    expect(capturedValue).toBe('testing value');
  }));

  it('#getData should filter out value that doesn\'t have test', fakeAsync(() => {
    const stream = of('wrong value');

    mockStream.dataStream.and.returnValue(stream);

    let capturedValue: String = null;

    service.getData().subscribe(value => {
      capturedValue = value;
    });

    flushMicrotasks();

    expect(capturedValue).toBeNull();
  }));
});

As you can see, we create an event in the test running, then submit it to our controlled Observable. After that, we verify we got the behavior we wanted. To control the flow of when the observer returns data, we’re using the fakeAsync function provided by the Angular library. This allows us to control the flow of when async events are happening. The flushMicrotasks function is where all the async operations are processed, and then we can do our assertions if the state is correct.

Where Should These Tests Be Run?

Now that we have all of these tests, what should we do with them? When should they be run, and who should care about them? As we’ve been doing so far, all of these tests we are running locally. That’s great, but what if you forget to run your tests? Well, you need a continuous integration/continuous deployment (CI/CD) system to automate your tests. Your CI/CD system should run the tests and report back which tests failed. You can also set up code coverage from the tool so that you know you cover enough of your system to be confident in your deployment. While setting this up is outside of this blog post’s scope, it’s worth noting and exploring the topic to ensure that you know where to go next.

Conclusion

I hope this blog post showed some good examples of where you can start testing your Angular services. Services should be some of your foundational pieces in your codebase. With these examples, you should be able to cover a lot of your basic needs. From there, you can build on that solid foundation and have enough coverage that when you release your application, you can do so with confidence.

A good next step would be implementing an end-to-end test (also known as e2e tests). Testim has tools and services that can help you with your end-to-end testing needs.

The author of this post, Erik Lindblom, has been a full stack developer for the last 13 years. During that time, he’s tried to understand everything that’s required to deliver high quality, valuable software. Today that means using cloud services, microservices techniques, container technologies. Tomorrow? Well, he’s ready to find out.

What to read next

Angular Integration Testing: A Practical, Introductory How-To

Angular Component Testing: A Detailed How-To With Examples