Workshop material for Angular: Unit testing techniques.
Workshop material for: https://www.safaribooksonline.com/live-training/courses/angular-unit-testing-techniques/0636920106197/
You've been tasked to deliver a high-quality, well-tested dashboard to track The Grid's most prominent hackers! We will be learning all about unit testing Angular apps.
This project was generated with Angular CLI version 10.0.7.
npm install -g @angular/cli
You will need to do the following:
npm install -g @angular/cli
angular-testing-workshop
, install dependencies: npm install
The following commands should work:
npm start
: should open your browser and display the app we will be working with:
npm test
: should yield output similar to this (no errors):
git checkout -b solution
We will be working on a new branch and working through the modules. In the last module, we will be opening a pull request and using TravisCI to run our builds.
The empty exercise files you'll be completing end in *.spec.ts
. The solutions are right next to the file, which are named *.specx.ts
. If you to switch between running your specs vs. the solution, in src/app.test.ts
change the regex for the specs to:
const context = require.context('./', true, /\.specx\.ts$/);
The Angular Testing Guide puts it very clearly:
const SuperAwesomeModule = {
featureA: () => {
...
},
featureB: () => {
...
}
}
describe('SuperAwesomeModule', () => {
describe('featureA', () => {
});
describe('featureB', () => {
});
});
it(<string>, <fn>)
describe('SuperAwesomeModule', () => {
describe('featureA', () => {
it('should calculate some super awesome calculation', () => {
...
});
it('should also do this correctly', () => {
...
});
});
});
expect(<actual>).<matcher(expectedValue)>
describe('SuperAwesomeModule', () => {
describe('featureA', () => {
it('should calculate some super awesome calculation', () => {
expect(SuperAwesomeModule.featureA([1, 2, 4]).toEqual(7);
});
it('should also do this correctly', () => {
expect(SuperAwesomeModule.featureB('...').toBe(true);
});
});
});
expect(foo).toBe(true); // uses JS strict equality
expect(foo).not.toBe(true);
expect(foo).toEqual(482); // uses deep equality, recursive search through objects
expect(foo).toBeDefined();
expect(foo).not.toBeDefined();
expect(foo).toBeUndefined();
expect(foo).toBeTruthy(); // boolean cast testing
expect(foo).toBeFalsy();
expect(foo).toContain('student'); // find item in array
expect(e).toBeLessThan(pi);
expect(pi).toBeGreaterThan(e);
expect(a).toBeCloseTo(b, 2); // a to be close to b by 2 decimal points
expect(() => {
foo(1, '2')
}).toThrowError();
expect(() => {
foo(1, '2')
}).toThrow(new Error('Invalid parameter type.')
describe('ApiService', function() {
const serviceInTest;
beforeEach(function() {
serviceInTest = new ApiService();
});
afterEach(function() {
...
});
it('retrieves data', function() {
...
});
it('updates data', function() {
...
});
});
describe('SuperAwesomeModule', () => {
xdescribe('featureA', () => {
it('should ...', () => {
});
it('should ...', () => {
});
});
describe('featureB', () => {
xit('should ...', () => {
});
it('should ...', () => {
});
});
});
describe('SuperAwesomeModule', function() {
beforeEach(function() {
// track all calls to SuperAwesomeModule.asyncHelperFunction()
// and return a mock response
spyOn(SuperAwesomeModule, 'asyncHelperFunction').and.returnValue(Promise.resolve(mockData))
});
describe('featureA', function() {
it('should ...', function() {
expect(SuperAwesomeModule.featureA(x)).toBe(y);
// matchers for spies
expect(SuperAwesomeModule.asyncHelperFunction).toHaveBeenCalled();
});
});
});
describe('long asynchronous specs', function() {
beforeEach(function(done) {
done();
}, 1000);
it('takes a long time', function(done) {
setTimeout(function() {
done();
}, 9000);
}, 10000);
afterEach(function(done) {
done();
}, 1000);
});
We will test drive the implementation of a scoreCalculator
function (sums up scores) that satisfies the following:
should work with one number
should work with more than one score
should treat negative scores as 0
should return zero with empty input
Inside the Angular project, running ng test --no-watch --code-coverage
will output something like this:
It's a bit difficult to know which tests exactly ran, so let's configure our terminal spec reporting. To do so, you will need to install the karma-spec-reporter
plugin and configure karma.conf.js
. It should already be included when you ran the initial npm install
.
Tasks:
plugins
, require the karma-spec-reporter
: require('karma-spec-reporter')
reporters
, replace 'progress'
with 'spec'
reports
array inside the coverageIstanbulReporter
object, add 'text-summary'
Now, when you run your tests, you should get something like this:
Code: src/app/core/menu
In this module, we will learn the basic steps in setting up unit tests using the Angular testing utilities. There are 3 standard methods of testing Angular components:
When testing components, we will be using the shallow method of testing components, and when our components take in inputs, and/or we want to test outputs, we will use a test host component.
We first need to import a few of the testing utilities, and also the component to test:
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MenuComponent } from './menu.component';
We start our describe block, and before each of our tests, we want to configure the testing module. In the declarations property is where you declare the component being tested. We first compile the components in test:
let component: MenuComponent;
let fixture: ComponentFixture<MenuComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ MenuComponent ]
})
.compileComponents();
}));
compileComponents()
will ensure that external templates and styles are inlined. This is an async operation, so we use the async
utility, which runs it in a special async test zone. If you're using webpack, this isn't needed, but it's a good idea to always have this here in case your build system changes.
We then get handles on two important pieces:
beforeEach(() => {
fixture = TestBed.createComponent(MenuComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
DebugElement
, a handle on the component's DOM element.fixture.detectChanges()
initializes the component (calling ngOnInit()
) and runs the change detection cycle.
With setup out of the way, we can start writing assertions. For instance, a test to ensure that two menu items get rendered:
it('should render two menu items', () => {
const menuItems = fixture.debugElement.queryAll(By.css('a'));
expect(menuItems.length).toBe(2);
});
We use the debugElement
's queryAll
method to retrieve all DebugElement
s that satisfy the search, and using the By.css
utitlity.
Running this, you will get an error:
Can't bind to 'routerLink' since it isn't a known property of 'a'
.
Since we aren't importing the module for routing, Angular doesn't recognize this directive. However, we want to shallow test, so we will tell Angular to ignore components and directives not included in the declarations
property by using the NO_ERRORS_SCHEMA
constant:
import { NO_ERRORS_SCHEMA } from '@angular/core';
and declare a new schemas
property when confiuring the test module:
schemas: [NO_ERRORS_SCHEMA]
Write a spec 'should render a different hacker link title'
.
hackerLink
property to something elsedebugElement
and the By
utiltity to assert that the new title is reflected in the DOM.hint: Once you obtain the debugElement
reference to the hacker link, you can get the native HTMLElement
through the nativeElement
property.
Code:
src/app/status
src/app/hacker-search
In this module, we will learn how to test components with inputs and outputs. The best way to test this kind of components is by using a test host component. Essentially, in your test you create a parent component which houses the component you want to test. This way, it's very easy to feed it inputs, and to listen for any output events.
We will first be looking at the StatusComponent
, which displays a small circle indicating the status of the hacker. Green for "safe", yellow for "warning", and red for "danger".
This is how it is used:
<app-status [status]="hacker.status"></app-status>
It takes in as input a status
which can be 'danger'
, 'safe'
, or 'warning'
. If we take a look at the StatusComponent
, the input status gets mapped to a CSS class, which is then used to style the small circle.
<div class="status-pulse">
<span class="pulse" [ngClass]="color"></span>
<span class="dot" [ngClass]="color"></span>
</div>
With this knowledge, let's create a test host component:
@Component({
template: '<app-status [status]="appStatus"></app-status>'
})
class TestHostComponent {
appStatus: string;
}
For the TestBed
configuration, we will include both the StatusComponent
and the TestHostComponent
in the declarations. We then obtain a fixture on the test host component, and the test host component instance. Do not call fixture.detectChanges
here since that will trigger the ngOnInit
method, which will return the incorrect class since we haven't fed any input to the StatusComponent
.
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ StatusComponent, TestHostComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
});
With the setup out of the way, we are now ready to write some tests.
Complete the following tests:
should set pulse color to green when input is "safe"
should set pulse color to yellow when input is "warning"
should set pulse color to red when input is "danger"
should set pulse color to green when input is undefined
For the first four tests, you want to follow these steps:
appStatus
property on the test host component to what you are currently testing, so something like 'safe'
fixture.detectChanges()
), and get a reference to the element with class of .pulse
. Use the fixture.debugElement.query()
utility, and By.css()
. This would look something like fixture.debugElement.query(By.css('.pulse')).nativeElement
classList
property of the element.We now need to take a look at testing components with outputs, and we will be working with the HackerSearch
component. This component renders an input, and uses the ReactiveFormsModule
in order to easily debounce changes to the input value. Once the user stops typing something in, it will emit a newSearch
event.
This is how it is used:
<app-hacker-search (newSearch)="filterData($event)"></app-hacker-search>
The parent component must have a filterData
method which will be called with the new search term as the argument. If we take a peek at the implementation in the HackerSearch
component, we can know when this gets emitted:
ngOnInit() {
this.searchTerm
.valueChanges
.pipe(debounceTime(500))
.subscribe(term => {
this.newSearch.emit(term);
});
}
When testing this component, we can create a test host component:
@Component({
template: '<app-hacker-search (newSearch)="filterData($event)"></app-hacker-search>'
})
class TestHostComponent {
filterData = jasmine.createSpy('filterDataSpy');
}
filterData
is simply a spy which we can use to verify that the method on the host component was called when input changed on the HackerSearch
component.
For the TestBed configuration, we will include both the HackerSearchComponent
and the TestHostComponent
in the declarations. We then obtain a fixture on the test host component, and the test host component instance.
let testHost: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [ReactiveFormsModule],
declarations: [ HackerSearchComponent, TestHostComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
testHost = fixture.componentInstance;
});
Complete the following test:
'should emit new search event'
Your strategy for testing this component should be the following:
fixture.detectChanges()
)fixture.debugElement
to query
for the input
element using the By.css
utility'input'
event (inputEl.dispatchEvent(new Event('input'));
)filterData
method was calledSince we are doing asynchronous work, we need to use the async
testing utility along with the fixture.whenStable()
utility. We will be covering this more in depth in the next module, but for now just understand that the async
function wraps a test function in an asynchronous test zone. The test will automatically complete when all asynchronous calls within this zone are done. The fixture.whenStable()
can be used to write specs after asynchronous activity or change detection has completed.
it('should emit new search event', async(() => {
}));
Reference: Test a component with an async service
Code: src/app/hacker-list
In this module, we will learn how to test components with (async) service dependencies. When performing such tests, we must specify the injected services in the providers
property when configuring the testing module:
TestBed.configureTestingModule({
declarations: [ HackerListComponent ],
providers: [
{ provide: ApiService, useValue: mockApiService },
{ provide: Router, useValue: mockRouter }
],
schemas: [NO_ERRORS_SCHEMA]
})
Here we are using the provide
object literal, such that when the DI system retrieves the ApiService
, it will use the provided value. Here we don't provide the real service, but instead a mock service. The mockApiService
should simply be an object that has the same interface as the actual ApiService
:
const mockApiService = {
getHackers: () => { }
};
This component only utilizes the navigate
method of the router, so we can also create a mock for that.
const mockRouter = {
navigate: () => { }
};
At the top of the describe block, in addition to declaring variables for the component
and fixture
,we also want to declare a variable to hold a reference to the injected service:
let component: HackerListComponent;
let fixture: ComponentFixture<HackerListComponent>;
let api: ApiService;
How do we get the injected service? The best way to do so is to get it from the component's injector:
api = fixture.debugElement.injector.get(ApiService);
From here on, we can spy on api
, and not the mockApiService
. It is simply a clone of that object.
Suppose one of your components method performs async work:
ngOnInit() {
this.api.getProducts()
.then((data: any) => {
this.products = data;
});
}
In your test, you should first spy on the service mock and return a controlled response:
spyOn(api, 'getProducts').and.returnValue(Promise.resolve(mockProducts));
Then, there are two methods of testing this:
async
and fixture.whenStable
fakeAsync
and tick
The first is to use the async
testing utility, which is a function that returns a function, which becomes the second argument to the it
call. You must then uses the fixture's whenStable
method which returns a promise when all async work within this test is complete.
it('...', async(() => {
spyOn(api, 'getProducts').and.returnValue(Promise.resolve(mockProducts));
component.ngOnInit();
fixture.whenStable()
.then(() => {
expect(...).toEqual(...);
});
}));
The second method is to use the fakeAsync
testing utility. It allows you to write a test in a more linear fashion:
it('...', fakeAsync(() => {
spyOn(api, 'getProducts').and.returnValue(Promise.resolve(mockProducts));
component.ngOnInit();
flush(); // "flushes" asynchronous tasks
expect(...).toEqual(...);
}));
If you need fine time control, the tick
function simulates the passage of time, and it can take in an optional argument of milliseconds.
Write tests for the initial display
(describe
block)
makes a call to api.getHackers
sets initial data (using async)
: Since ngOnInit
performs async work, we use the async
testing utilitysets initial data (using fakeAsync)'
: use fakeAsync
instead. You will need to use the tick
function hereWrite a test for the click on hacker
(describe
block):
should navigate to the hacker/:id path
Relevant imports:
import { async, ComponentFixture, TestBed, fakeAsync, flush } from '@angular/core/testing';
import { HackerListComponent } from './hacker-list.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ApiService } from '../core/services/api.service';
import { Router } from '@angular/router';
import { By } from '@angular/platform-browser';
import { mockHackers } from '../core/helpers.spec';
Code: src/app/core/services
When it comes to testing services in Angular, you could write isolated tests (no Angular testing utilities) or shallow tests (using Angular utilities like the TestBed
and the inject
function). I recommend writing isolated tests for services, as they are essentially just a class, as adding in the Angular helping utilities will probably just add complexity to your tests. If your service depends on other services, you can easily stub them out.
Here is our basic ApiService
:
@Injectable()
export class ApiService {
baseUrl = '/api';
constructor(public http: Http) { }
getHackers(search: string = '') {
return this.http.get<Hacker[]>(`${this.baseUrl}/hackers?q=${search}`)
.toPromise();
}
getHackerDetails(id: string) {
return this.http.get<Hacker>(`${this.baseUrl}/hackers/${id}`)
.toPromise();
}
}
When testing services, at the top of your describe block, you will need to declare the a variable that will hold the reference to your service, and create spies for any dependencies.
let service: ApiService;
const httpSpy = jasmine.createSpyObj('http', ['get']);
Using the createSpyObj
method gives us great flexibility as we can instruct it to return different values as needed. Unit tests should isolated, fast, and should not make external http requests, which is why we will stub out the Http
service instead.
Before each spec, create a brand new instance of the service:
beforeEach(() => {
service = new ApiService(httpSpy);
});
Now for each spec, the structure will look like this:
it('...', (done) => {
// create a mock response
// instruct any dependent service to return the mock response
// by using the spy object
// make the call to your service
// if the call is async (returns a Promise), you can listen
// for when the problem resolves, assert, and then call done()
});
Note here that we are using the Jasmine built-in done function. This suffices for our unit tests, and there really is no need to bring in the async
or fakeAsync
utilities. In fact, when dealing with Observables, you will have to use the done
function instead.
Write the following unit tests for both the getHackers
and getHackerDetails
of the ApiService
.
getHackers
: 'should return list of hackers'
: You should assert that http.get
gets called with '/api/hackers?q='
, and the data returned is the mock data.getHackerDetails
: 'should return hacker details given hacker id'
: You should assert that http.get
gets called with '/api/hackers/${id}''
, and the data returned is the mock data.Code: src/app/core/directives
An attribute directive is used to modify behavior of an existing element or component. Suppose we have a directive that can be added to an input element to prevent numeric input. We can easily achieve this using a @HostListener
and listening for the keydown
event.
import { Directive, HostListener, ElementRef } from '@angular/core';
@Directive({
selector: '[appNonNumeric]'
})
export class NonNumericDirective {
constructor(private element: ElementRef) { }
@HostListener('keydown', ['$event'])
onKeydown(event) {
const numberRegex = /[0-9]/;
if (numberRegex.test(event.key)) {
event.preventDefault();
}
}
}
And its usage:
<input appNonNumeric type="text" placeholder="Search...">
Looking at the implementation, you could very well write an isolated test and test the onKeydown
method. However, we want to test how this directive will make other elements behave. We will be using a test host component along with the Angular testing utitlies.
A test host component can look like this:
@Component({
template: `<input appNonNumeric type="text"/>
<textarea appNonNumeric></textarea>`
})
class TestHostComponent {
}
When testing this, we can use the debugElement
and By
to query for the input. DebugElement
s have a useful triggerEventHandler
that you can call. In this case, we would trigger the keydown
event.
Complete the following tests:
should allow regular text input
: You should query for the input
element, and trigger the keydown
event handler. Create a mock event, and call input.triggerEventHandler('keydown', event)
.should not allow numeric text input for input elements
: Similar setup to the first one, except the event's key property should be a string containing a numbershould allow regular text input for textarea elements
should not allow numeric text input for textarea elements
Code: app/core/pipes
In this module we will learn how to test pipes. Testing pipes in Angular is actually very easy, there is really no set up as we are writing vanilla jasmine tests, without any Angular testing utilities. You should write these kind of isolated tests for both services and pipes.
Suppose we have a pipe to transform any string input to all uppercase letters:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'uppercase'
})
export class UppercasePipe implements PipeTransform {
transform(input: string): any {
return input.toUpperCase();
}
}
To test, we are simply testing a class. Below is the setup and some sample tests:
import { UppercasePipe } from './uppercase.pipe';
describe('UppercasePipe', () => {
let pipe: UppercasePipe;
beforeEach(() => {
pipe = new UppercasePipe();
});
it('creates an instance', () => {
expect(pipe).toBeTruthy();
});
it('transforms input string to uppercase', () => {
expect(pipe.transform('angular rocks!')).toBe('ANGULAR ROCKS!');
});
});
In these exercises, we are going to test-drive the implementation of the ShortDatePipe
, which will transform an input ISO date string and return a "short date" format.
'1960-06-01T11:01:12.720Z' ----> '06/01/1960, 11:01am'
Complete the following tests:
creates an instance
should not throw error
returned value should contain date format dd/mm/yyyy
returned value should contain time hh:mm[am|pm]
should convert ISO string to correct date format (am)
should convert ISO string to correct date format (pm)
You can use this sample data:
'1972-08-23T15:22:34.694Z' ----> '06/01/1960, 11:01am'
'1980-10-04T21:35:51.869Z' ----> '10/04/1980, 09:35pm'
Code: src/app/hacker-detail
Testing routed components is not much different than testing components with async services, the only difference is that instead of dealing with timers or Promises, most likely you'll be dealing with Observables
since the router exposes certain Observable
properties to read information from the current route.
Take for instance the HackerDetailComponent
:
@Component({
selector: 'app-hacker-detail',
templateUrl: './hacker-detail.component.html',
styleUrls: ['./hacker-detail.component.scss']
})
export class HackerDetailComponent implements OnInit {
@Input() id: string;
hacker: Hacker;
constructor(private api: ApiService, private route: ActivatedRoute) { }
ngOnInit() {
this.route.params.subscribe(params => {
this.id = params['id'];
this.renderDetails(this.id);
});
}
renderDetails(id: string) {
this.api.getHackerDetails(id)
.then((data) => {
this.hacker = data;
});
}
}
It has two injected dependencies, the ApiService
and the ActivatedRoute
. You know how to create a mock for the api service (simply return a resolved promise). However, the params
property is an observable that emits an object. Here, we care about the id
param, since our route was declared as hackers/:id
.
At the beginning of the describe block, you can create mocks for both:
const mockApiService = {
getHackerDetails: (id) => Promise.resolve(mockHackers[3])
};
const mockActivatedRoute = {
params: of({ id: 'f1b2e9bf-2794-4ccf-a869-9ddb93478f70'})
};
Using of()
is a very convinient way of wrapping objects into an observable.
When configuring the TestBed
, for the providers you instruct Angular to use these when the service dependencies are injected:
providers: [
{ provide: ApiService, useValue: mockApiService },
{ provide: ActivatedRoute, useValue: mockActivatedRoute }
],
In your specs, calling fixture.detectChanges()
will trigger ngOnInit
, which will retrieve the id parameter, and then call renderDetails
, which will then call the getHackerDetails
method on the api
. Lots of async here, so use async()
along with fixture.whenStable()
.
Complete the following tests:
should set the correct hacker name
should set the correct hacker status message
There are specific things that as a developer and tester and you can do to create a better testing workflow. From terminal reporting, to commit hooks, you should take advantage of the tools available.
The Angular CLI generates a project for you with testing included out of the box. It's a good idea to generate code coverage reports when you run your tests:
ng test --no-watch --code-coverage
Better yet, create an npm script for this:
"test": "ng test --no-watch --code-coverage"
and also a script to watch your tests automatically:
"test:watch": "ng test --code-coverage"
Also, configure terminal reporting (refer to Module 2 above).
There are mixed opinions on whether or not you should enforce coverage thresholds. Sure, a codebase of 99% coverage may not necessarily mean that your code is bug free, but tested code is one major step in the way of producing clean code. Enforcing coverage thresholds will promote testability among your team (specially if your team is new to testing), and you can ensure that untested code is not making its way to your codebase.
add the 'json'
reporter to the coverageIstanbulReporter
object:
coverageIstanbulReporter: {
reports: [ 'html', 'lcovonly', 'json', 'text-summary' ],
},
and configure the thresholds:
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/fluent-angular-testing'),
thresholds: {
// set to `true` to let the test command pass when thresholds are not met
emitWarning: false,
// thresholds for all files
global: {
statements: 90,
lines: 90,
branches: 90,
functions: 90
},
// thresholds per file
each: {
statements: 80,
lines: 60,
branches: 60,
functions: 80,
}
},
},
Now, if any of these stats fall below the specified thresholds, running ng test
will fail, even if each spec is passing:
Husky can be used to easily configure git hooks to prevent bad commits. It's an npm module, so install it:
npm i --save-dev husky
Then, you can configure a precommit
and prepush
hook by simply adding npm scripts:
"precommit": "npm run lint",
"prepush": "ng test --single-run --code-coverage"
Before committing, it will run the linter, and before pushing your branch, it will run the test suite. This combined with coverage thresholds can provide a powerful way of enforcing clean, tested code.
You can use TravisCI to automatically test your code as it's pushed to GitHub, and configure it to run for every pull request.
Head over to TravisCI and sign in with your GitHub account. You can then "flick the repository" switch to "on" for your repo. The next step is to add a .travis.yml file.
dist: trusty
addons:
chrome: stable
language: node_js
node_js:
- '8'
before_install:
- google-chrome-stable --headless --disable-gpu --remote-debugging-port=9222 http://localhost &
install:
- npm install
- npm install -g codecov
script:
- npm test
- codecov -f coverage/coverage-final.json
There is a a lot going on here. We are instructing TravisCI to use Ubuntu Trusty, Node 8.x, and further instructions to in order to get Chrome headless running. In addition, we will be using codecov.io in order to provide coverage reports for us. It works out of the box with TravisCI, simply sign up using your GitHub account.
Once you open a PR or push any branch, it will trigger a TravisCI build:
If the build fails, you will know both on GitHub and on TravisCI:
Once fixed, repush your branch, and the build triggers again:
In addition, since we have enabled reporting with Codecov, we get a codecov bot reporting the coverage:
One of the things I love about the Angular community is its willingness to share awesome content to make your every day developer life even better. This isn't the only resource on testing Angular. I recommend you also check out these awesome resources: