Services are the core of any good application. They help connect your application’s business logic to the data it needs to operate. Whether it’s from module to module, or component to web API, services help to insulate your application from potentially breaking data or architectural changes.
Looking at services from a design perspective, one pattern we use them in is the Adapter Pattern. Adapter patterns are an architectural design choice that removes the need for the business logic to know or care about where data comes from or how that data is formatted when it gets to you. Have a relational database today, and move to a document DB tomorrow? With the adapter pattern in place, you would rewrite your code in that one place, the service, to handle the transition.
In our two examples, we’re interested in testing the service itself, and not interested in data provider or consumer component level tests. While writing unit tests, we shouldn’t test the service where it’s consumed, but add in test doubles. We shouldn’t test the data provider, but add in mock data. For examples of isolating your SUT (System Under Test) when your SUT is a component, to remove a service dependency, you can find a few posts on stubs and spies on this site.
A quick overview.
We have two services waiting for components to consume data from them. The first service brokers information exchange between multiple components. This includes some observables that provide event notification. The second service retrieves data from an API and simply returns that data to the calling component. In a more advanced case, we might consume data from several sources – such as databases or multiple APIs. We might also perform some type of transform or merging of the information we retrieve. We don’t handle those cases in our examples below, but they would be trivial to add into what we have.
Testing our first service.
In our service, we have two observables that provide event notification when some business process finishes. We also have three methods. The first method handles a cupcake order, the second handles the completion of the cupcake and sends notification that the cupcake is done, and the third method sends notification when the cupcake is delivered.
When the cupcake is first ordered, an observable named “cupcakeIsDone$” is passed back to the caller.
private cupcakeIsDoneSource = new Subject<boolean>(); cupcakeIsDone$ = this.cupcakeIsDoneSource.asObservable(); orderCupcake(frosting: string, filling: string): Observable<any> { this.cupcake = { frosting, filling }; return this.cupcakeIsDone$; }
When the cupcake is done and the message is sent back to the caller, a “cupcakeDelivery$” observable is also passed back.
private cupcakeDeliverySource = new Subject<{ filling: string; frosting: string; }>(); cupcakeDelivery$ = this.cupcakeDeliverySource.asObservable(); announceCompletionOfCupcake() { this.cupcakeIsDoneSource.next(true); return this.cupcakeDelivery$; }
We test both of these individually, then test the full flow. We’re trying to do more BDD with our TDD (behavior driven development/test driven development).
Here’s our full cupcake service under test. There’s nothing too tricky about this service. It contains two subjects and applicable Observable streams which are returned back to the client code as the cupcake moves through the process. It’s important to note that these methods expect to be called in a certain sequence, orderCupCake(), then announceCompletionOfCupcake(), and finally deliverCupCake().
import { Injectable } from '@angular/core'; import { Subject, Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) // https://angular.io/guide/component-interaction#parent-and-children-communicate-via-a-service export class InterComponentCommunicationService { private cupcake = { filling: 'none', frosting: 'vanilla' }; // Observables private cupcakeIsDoneSource = new Subject<boolean>(); private cupcakeDeliverySource = new Subject<{ filling: string; frosting: string; }>(); // Observable string streams // let folks know their cupcake is done and will be on the way cupcakeIsDone$ = this.cupcakeIsDoneSource.asObservable(); // Give the people their cake! cupcakeDelivery$ = this.cupcakeDeliverySource.asObservable(); constructor() {} orderCupcake(frosting: string, filling: string): Observable<any> { this.cupcake = { frosting, filling }; return this.cupcakeIsDone$; } announceCompletionOfCupcake() { this.cupcakeIsDoneSource.next(true); return this.cupcakeDelivery$; } deliverCupcake() { this.cupcakeDeliverySource.next(this.cupcake); } }
In our tests, we move to simplicity. There’s not much setup here in the beforeEach section, as again we’re completely isolating our SUT. Also take a look at our “it” clauses for our tests. The verbiage they use is what a user expects to see happen from the workflow “should let us know the cupcake is done after ordering”. I admit the wording could be made better, though when writing these tests it gave me a clear picture of what the code needed to do, based on the user’s needs.
Something to make note of, is that we shouldn’t make our expect call within the subscribe block. If we do, the test will pass even if the code in the subscribe block is not called. This is the same result as if you do not include any expect call within an “it” clause. Here’s the code I’m referencing.
cupcakeIsDone.subscribe(result => { resultToTest = result; }); service.announceCompletionOfCupcake(); expect(resultToTest).toBeTruthy();
For our test related to the completion of the cupcake, the setup is the same. Our expect call is outside of the subscribe call, and we’re testing to ensure we receive the correct output.
Our third test, ‘should return our ordered cupcake when all is done’, runs through the full business flow, and tests only the final the outcome. Here’s the full spec.
import { TestBed } from '@angular/core/testing'; import { InterComponentCommunicationService } from './inter-component-communication.service'; describe('Service Tests - InterComponentCommunicationService', () => { let service: InterComponentCommunicationService; beforeEach(() => TestBed.configureTestingModule({})); beforeEach(() => { service = TestBed.get(InterComponentCommunicationService); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should let us know the cupcake is done after ordering', () => { const cupcakeIsDone = service.orderCupcake('chocolate', 'whipped cream'); let resultToTest; // notice we're not doing the expect in the subscribe block. Go ahead and // comment out the assignment of result to result to test, // and uncomment the expect and console.log. // After you run the tests and see you get the expected result, // comment out the call to service.announceCompletionOfCupcake. // What happens? // You do NOT see the console.log in the subscribe block called, // which means you never received a result from the observable. // but your test still passes! cupcakeIsDone.subscribe(result => { resultToTest = result; // expect(result).toBeTruthy(); // console.log(result); }); service.announceCompletionOfCupcake(); expect(resultToTest).toBeTruthy(); }); it('should return the cupcake when it is being delivered', () => { const cupcakeIsDelivered = service.announceCompletionOfCupcake(); let resultToTest; cupcakeIsDelivered.subscribe(result => { resultToTest = result; // console.log(resultToTest); }); service.deliverCupcake(); // here's a better way to do this expect(resultToTest).toEqual({ filling: 'none', frosting: 'vanilla' }); }); it('should return our ordered cupcake when all is done', () => { let cupcakeIsDone; let cupcakeIsDelivered; let resultToTest; const cupcake = {frosting: 'Chocolate', filling: 'Strawberry'}; cupcakeIsDone = service.orderCupcake(cupcake.frosting, cupcake.filling); cupcakeIsDone.subscribe(result => result); cupcakeIsDelivered = service.announceCompletionOfCupcake(); cupcakeIsDelivered.subscribe(result => resultToTest = result); service.deliverCupcake(); expect(resultToTest).toEqual(cupcake); }); });
Since we’re using Observables in the above, we could opt for marble testing. This is where we map a “marble diagram” around the passage of time, and then do time based tests. We don’t here, though I might write about marble testing in the future. For more info on marble testing check out this github repo, as well as the jasmine-marbles npm package.
Our second test scenario – Web API calls.
In our first test scenario we set up a spec that uses straight spies to do basic testing. In the second spec we go deep and set up tests that use HttpClientTestingModule to do things like flushing data to verify any data processing code works, as well as flushing errors into our call so we can test error handling.
Our service is the most basic you can write when retrieving data from an API. It uses HttpClient to retrieve all of the data available, and returns an observable. There’s no data transform, no error handling, just straight passing of data.
import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; @Injectable({ providedIn: 'root' }) export class WebapiService { URL = 'https://jsonplaceholder.typicode.com/todos/'; constructor(private http: HttpClient) {} // { // "userId": 1, // "id": 1, // "title": "delectus aut autem", // "completed": false // } getAllTodos() { // we don't call subscribe, the caller should control this! return this.http.get(this.URL); } }
Setting up our first tests.
For our first set of tests, we go with the straight Jasmine spies method. We start by setting up our HttpClient spy, as we don’t want to have the test actually reaching out to an endpoint. It only takes a few lines to do this. First, we instantiate our jasmine SpyObj object. We’re only interested in mocking the get method here.
let httpClientSpy: jasmine.SpyObj<HttpClient>; httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
Next we create some mock data. Often what I’ll do is make a call to our real endpoint, and use that data as a snapshot of what we’ll actually see. If you work in any regulated industry such as finance or healthcare, be sure you aren’t plugging in any PHI, PII or HPI!
const mockTodoData = { userId: 999, id: 999, title: 'Test this thing', completed: false };
We then inject our spy into the instantiation of our service. This pattern of dependency injection helps to satisfy the “D” in the SOLID principle. Finally we use Jasmin’s get.and.returnValue to intercept our call to the get method and return our mocked data.
service = new WebapiService(httpClientSpy); httpClientSpy.get.and.returnValue(of(mockTodoData));
Here’s our full spec. Admittedly we didn’t do as good a job formatting these tests in the BDD method as we would have liked, though the tests do an OK job of testing the raw functionality.
import { WebapiService } from './webapi.service'; import { HttpClient } from '@angular/common/http'; import { of } from 'rxjs'; describe('Service Tests - WebapiService', () => { let httpClientSpy: jasmine.SpyObj<HttpClient>; let service: WebapiService; const URL = 'https://jsonplaceholder.typicode.com/todos/'; const mockTodoData = { userId: 999, id: 999, title: 'Test this thing', completed: false }; beforeEach(() => { httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']); service = new WebapiService(httpClientSpy); httpClientSpy.get.and.returnValue(of(mockTodoData)); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should return our todos with a #get call', () => { const todos = service.getAllTodos(); todos.subscribe(data => { expect(data).toEqual(mockTodoData); }); }); it('should only call the service one time per request', () => { service.getAllTodos(); expect(httpClientSpy.get.calls.count()).toBe(1, 'one call'); }); it('should call the expected URL', () => { service.getAllTodos(); expect(httpClientSpy.get.calls.allArgs()[0][0]).toEqual(URL); }); });
Our first test ensures that we receive all of the mocked data back. In the real world, we would include several records in our mocked data and test both calling for and receiving all of the data, as well as specific records.
Our second test ensures that we aren’t unnecessarily calling our service multiple times during the course of our logic. This could be the case if we are injecting this service into other services which each call our injected service, or if we have some odd logic with the service itself that is unnecessarily triggering service calls itself.
Our last test feels ugly. We want to be sure our service is calling the correct URL, but the way we find that is digging deep into the HttpClient spy’s args array. This is one place where our second method of testing shines. We do get the job done, though, and if you construct your API calls from multiple pieces, or pass in end points, testing that we’re calling the correct endpoint is a must.
We could definitely do better with our testing above. Again, it doesn’t fit in with our BDD mentality, and is limited in what it tests. From a BDD perspective our next spec does no better, but we think it does a better job of testing our functionality than our first spec.
Setting up our second spec using HttpClientTestingModule.
For our second set of tests, we’re going to use the more powerful HttpClientTestingModule. To get this set up, we pull in HttpTestingController, which actually mocks and flushes our requests. The two lines pertaining to this are
let backend: HttpTestingController; backend = TestBed.get(HttpTestingController);
There’s not much else to setup different than our first spec. We pull in our service under test, include our expected URL and some mock data. Here’s our full spec.
import { TestBed } from '@angular/core/testing'; import { WebapiService } from './webapi.service'; import { HttpClientTestingModule, HttpTestingController, TestRequest } from '@angular/common/http/testing'; import { HttpRequest } from '@angular/common/http'; describe('Service Tests - WebapiService with HttpClientTestingModule', () => { let service: WebapiService; let backend: HttpTestingController; const expectedURL = 'https://jsonplaceholder.typicode.com/todos/'; const mockTodoData = { userId: 999, id: 999, title: 'Test this thing', completed: false }; beforeEach(() => { TestBed.configureTestingModule({ providers: [WebapiService], imports: [HttpClientTestingModule] }).compileComponents(); }); beforeEach(() => { service = TestBed.get(WebapiService); backend = TestBed.get(HttpTestingController); }); it('should be created', () => { expect(service).toBeTruthy(); }); it('should call the expected URL', () => { // expectOne returns our mock as a TestRequest object, // so let's save that and we can use it later! service.getAllTodos().subscribe(); const httpCall: TestRequest = backend.expectOne( expectedURL, 'Saving to a const' ); // for stuff like this expect(httpCall.request.url).toBe(expectedURL); // console.log(httpCall); backend.verify(); }); it('should call the expected URL and method and test the explicit params', () => { // One overloaded method of expectOne - we can specify what params we expect service.getAllTodos().subscribe(); backend.expectOne( { url: expectedURL, method: 'GET' }, 'We want these explicit params to be in our mock call' ); backend.verify(); }); it('should call the expected URL and test using a function', () => { // we can also overload with a function service.getAllTodos().subscribe(); backend.expectOne((request: HttpRequest<any>) => { // we should probably do something more fun here return request.method === 'GET'; }, 'This is for our function call'); backend.verify(); }); it('should flush the response with our test data', () => { // if we do some processing in our service, // we can flush the expect call with our mocked data, errors, or other things // to test how we handle this service.getAllTodos().subscribe(); backend.expectOne(expectedURL).flush(mockTodoData); backend.verify(); }); it('should flush with an error', () => { // if we do some processing in our service, // we can flush the expect call with our mocked data, errors, or other things // to test how we handle this const errorStatusText = 'Danger Will Robinson!'; service.getAllTodos().subscribe( response => { throw('Nope, not on my watch!'); }, error => { expect(error.status).toEqual(400); expect(error.statusText).toEqual(errorStatusText); } ); backend .expectOne(expectedURL) .flush(mockTodoData, { status: 400, statusText: errorStatusText }); backend.verify(); }); });
The power comes in our “it” clauses. For our first test, we’re checking that our URL called is the URL we expected. Remember in our first spec we moved into an array to find this data. With HttpClientTestingModule we can ask the call for the information directly by saving the result of expectOne() to a TestRequest variable.
it('should call the expected URL', () => { service.getAllTodos().subscribe(); const httpCall: TestRequest = backend.expectOne( expectedURL, 'Saving to a const' ); expect(httpCall.request.url).toBe(expectedURL); backend.verify(); });
We can actually use the expectOne() method a few different ways. Above it was a call to gather return values and the request. In this example we add in a fat arrow function to do some type of custom checking. Though this example is mundane, what you test is very open, only asking that you return a boolean.
backend.expectOne((request: HttpRequest<any>) => { return request.method === 'GET'; }, 'This is for our function call');
Another powerful part of using HttpClientTestingModule is flushing. Flushing allows us to complete the call by draining our queue of any data. What makes this particularly powerful is our ability to add in mocked data, as below, or even inject HTTP errors to throw.
// Return mocked data to test backend.expectOne(expectedURL).flush(mockTodoData); // Throwing an error service.getAllTodos().subscribe( response => { throw('Nope, not on my watch!'); }, error => { expect(error.status).toEqual(400); expect(error.statusText).toEqual(errorStatusText); } ); backend .expectOne(expectedURL) .flush(mockTodoData, { status: 400, statusText: errorStatusText });
There’s a lot of code to digest here, and this is just the tip of the iceberg. It’s important to have good, isolated testing of our SUT here, and not rely on testing our services through the components they provide data to. It’s also very important to isolate the web service SUT from the web service that provides the actual data. Remember, the point of unit tests is to completely isolate your SUT (System Under Test) to ensure it behaves properly when receiving the inputs it expects. This will help with determining where the source of issues (regressions) is.
All of the code shown here, and more Angular tests, can be found in this GitHub repo.