Sometimes stubbing is not enough. When testing we will, at times, need to understand more deeply how a System Under Test (SUT) calls a dependency. When this happens, we turn to spies.
As Martin Fowler defines spies –
Spies are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
https://martinfowler.com/bliki/TestDouble.html
Spies can be quite easy to use, particularly with test tools like Jasmine. They not only isolate your SUT, keep all of the code in one place, but also tell you the state of the called dependencies. In this example we’re going to rely on the Jasmine library to do our heavy lifting.
Before we get on with it, one more comment. There are many ways to test functionality. We could use other types of test doubles, such as fakes, mocks, and stubs. In this article, we are only concerned with isolating the SUT using spies. Also, this article does not cover deeper testing methods using spies, only the high level setup and use of spies in testing a parent component.
First, let’s start getting aquinted with what our functionality looks like. We have two components in a parent child relationship. The parent grabs data from a service that provides a list of pickles (yes, those green things!) and displays the list using the child component for details.
Here is our pickle (target) service. For our testing, we’ll only spy on the getPickleUpdateListener method, which provides our observable, and our allPickles method.
import { Injectable } from '@angular/core'; import { Subject } from 'rxjs'; @Injectable({ providedIn: 'root' }) export class TargetSpiesService { private pickles = [ { type: 'dill', taste: 'tart', smell: 'vinager' }, { type: 'bread and butter', taste: 'sugary', smell: 'bread' } ]; private picklesUpdated = new Subject< {} >(); constructor() {} public getPickleUpdateListener() { return this.picklesUpdated.asObservable(); } public allPickles() { return this.pickles.slice(); } addPickle( pickle ) { this.pickles.push( pickle ); this.picklesUpdated.next(this.allPickles()); } }
Next our child component is very simple with only an input and a div.
import { Component, OnInit, Input } from '@angular/core'; @Component({ selector: 'app-secondary', templateUrl: './secondary.component.html', styleUrls: ['./secondary.component.css'] }) export class SecondarySpiesComponent implements OnInit { @Input() pickle: { type: string, taste: string, smell: string}; constructor() { } ngOnInit() { } }
secondary-component.html <span>{{ pickle.type }} {{ pickle.taste }} {{ pickle.smell }}</span>
Now on to our primary component. In the typescript file, we inject our pickle (target) service. In ngOnInit we grab a subscription to our observable by calling the service’s getPickleUpdateListener method. The first time through, this would give us nothing so we also call the service’s allPickles method to get the current, initial, list of pickles.
We’ve also added a addPickle method, which we’ll wire up to a button in the html.
import { Component, OnInit } from '@angular/core'; import { TargetSpiesService } from '../target.service'; import { Subscription } from 'rxjs'; @Component({ selector: 'app-primary', templateUrl: './primary.component.html', styleUrls: ['./primary.component.css'] }) export class PrimarySpiesComponent implements OnInit { public thesePickles; private subscription: Subscription; constructor(private pickleFactory: TargetSpiesService) {} ngOnInit() { this.subscription = this.pickleFactory .getPickleUpdateListener() .subscribe(pickles => (this.thesePickles = pickles)); this.thesePickles = this.pickleFactory.allPickles(); } addPickle() { this.pickleFactory.addPickle({ type: 'Unknown', taste: 'Unknown', smell: 'Unknown'}); console.log('Pickle added'); } }
Our html file is very simple. The button, wired up to add a pre-defined “unknown” pickle, sits before our child element. The ngFor loops through the list of pickles returned by our pickle service and displays them.
<span> <button (click)="addPickle()">Add Pickle</button> </span> <app-secondary *ngFor="let pickle of thesePickles" [pickle]=pickle></app-secondary>
Now for our testing. In this example, we’re only going to test the parent, primary, component. I’ve tried to add narrative comments to the code below, and will give a higher level view here in the text.
Our first order of business is to get rid of any errors caused by NOT importing our secondary-component. As a good standard practice, we want to only test our single component – its handling of inputs, its outputs and its logic. We could do this two ways, mock our child component, or use core Angular functionality to completely remove it from the equation. So here we add in the import for CUSTOM_ELEMENTS_SCHEMA and then add it to our testbed to remove it from the equation.
Next, we want to double our service. We could create a separate stub or mock and spy on that, with some values hard coded in, but instead we are going fully isolate our SUT and use Jasmine’s functionality to create an object right here in our test, that will isolate away the service and use data that we provide right in the test. We’ll then substitute any calls to the real service, using this spy, by adding in a line to the providers property in our testbed that does just that.
providers: [{ provide: TargetSpiesService, useValue: spy }],
In our beforeEach we create that service spy, and stubs for the methods we want to test. At that point if you were to look into that target spy object, you would see two undefined methods. Immediately after we are going to stub those methods, using RxJS’s of() method to return an observable in the first, and returning the plain data in the second.
updateListenerMethodSpy = spy.getPickleUpdateListener.and.returnValue( of(picklesStub) ); allPicklesMethodSpy = spy.allPickles.and.returnValue(picklesStub);
Here’s the full test code, with comments.
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { PrimarySpiesComponent } from './primary.component'; import { TargetSpiesService } from '../target.service'; // so we can return our observable normally given by the service import { of } from 'rxjs'; // don't care about our secondary (nested) component right now import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; describe('Spies - PrimaryComponent', () => { let component: PrimarySpiesComponent; let fixture: ComponentFixture<PrimarySpiesComponent>; // This will be called later, to double and spy on the existing service methods let targetServiceSpy: jasmine.SpyObj<TargetSpiesService>; // Will be used later to stub in values returned by our service let updateListenerMethodSpy; let allPicklesMethodSpy; const picklesStub = [ { type: 'stubOne', taste: 'stubOne Taste', smell: 'stubOne Smell' }, { type: 'stubTwo', taste: 'stubTwo Taste', smell: 'stubTwo Smell' } ]; beforeEach(async(() => { // ok, let's create our service spy, and fake any method we need const spy = jasmine.createSpyObj('TargetSpiesService', [ 'allPickles', 'getPickleUpdateListener' ]); // Now to create a mock for any fake method that needs to return a value // if anything calls the getPickleUpdateListener method, return our stubbed pickles updateListenerMethodSpy = spy.getPickleUpdateListener.and.returnValue( of(picklesStub) ); // console.log('******', updateListenerMethodSpy); // and do the same for the allPickles method, // but we should not return an observable, just the value allPicklesMethodSpy = spy.allPickles.and.returnValue(picklesStub); TestBed.configureTestingModule({ declarations: [PrimarySpiesComponent], providers: [{ provide: TargetSpiesService, useValue: spy }], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); })); beforeEach(() => { fixture = TestBed.createComponent(PrimarySpiesComponent); component = fixture.componentInstance; // this is where we create a replacment for service with our spy // so we can use it later for tests. In useValue in the provider above // we've asked for the value of spy instead of the TargetSpiesService // if you console.log targetServiceSpy you'll see the spyStrategy of any methods // faked above in the spy const targetServiceSpy = TestBed.get(TargetSpiesService); // see? // console.log('************', targetServiceSpy); fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); it('should call #getPickleUpdateListener exactly once', () => { expect(updateListenerMethodSpy.calls.count()).toEqual(1); }); it('should call #getPickleUpdateListener', () => { // calls.any tracks whether this spy was called expect(updateListenerMethodSpy.calls.any()).toBe( true, 'getPickleServiceSpy called' ); }); it('should call #allPickles exactly once', () => { expect(targetServiceSpy.allPickles.calls.count()).toEqual(1); }); it('should call #allPickles to return stubbed values from spy', () => { // making sure it's organically called // it will also give us our stubbed values, since we added the // spy to returnValue in the before each above expect(targetServiceSpy.allPickles).toHaveBeenCalled(); // let's be sure the values weren't mutated along the way // we shouldn't really do it this way, but we'll fix this expect(targetServiceSpy.allPickles()).toEqual(picklesStub); }); });
At this point basic testing with spies is in place. VERY basic! We have isolated our SUT by removing our dependence on external functionality and data, intercepting calls to our service and then spying on those calls as well as returning mocked data. We have removed any calls to functionality that relies on the output of our SUT (our secondary/child component) and can test that output at will. You can be sure you are testing only that functionality which exists in the SUT, and have the ability to look into those calls with Jasmine Spies.
All of the code shown here, and more Angular tests, can be found in this GitHub repo.