Advanced Testing Recipes v7+
Advanced Tesing Recipes v7+
Gerard Sans
@gerardsans
Gerard Sans
@gerardsans
SANS
GERARD
Google Developer Expert
Google Developer Expert
International Speaker
Spoken at 110 events in 27 countries
Blogger
Blogger
Community Leader
900
1.6K
Trainer
Master of Ceremonies
Master of Ceremonies
#cfpwomen
Introduction
Unit Tests
Assertion Libraries
Spies, Stubs
Test
Automation
Browsers
Coverage Reports
e2e Tests
Test
Runner
Testing Architecture
WebDriverJS
Selenium
Protractor
Overview
- Does this method work?
- Does this feature work?
- Does this product work?
Unit tests
e2e Tests
Acceptance Tests
- app.component.ts
- app.component.spec.ts
- app.e2e.ts
Angular CLI Conventions
$ npm run tests $ npm run e2e
stackblitz.com
Mocks vs Stubs
Mocks
- Used to replace Complex Objects/APIs
- Examples:
- MockBackend
- MockEventEmitter
- MockLocationStrategy
Stubs
- Used to cherry pick calls and change their behaviour for a single test
- When to use:
- control behaviour to favour/avoid certain path
Main Concepts
- Suites describe('', function)
- Specs it('', function)
- Expectations and Matchers
- expect(x).toBe(expected)
- expect(x).toEqual(expected)
Basic Test
let calculator = {
add: (a, b) => a + b
};
describe('Calculator', () => {
it('should add two numbers', () => {
expect(calculator.add(1,1)).toBe(2);
})
})
Setup and teardown
-
beforeAll (once)
- beforeEach (many)
- afterEach (many)
- afterAll (once)
Useful techniques
- Nesting suites and using scopes
-
Utility APIs
-
fail(msg), pending(msg)
-
- Disable
-
xdescribe, xit
-
- Focused
-
fdescribe, fit
-
Jasmine Spies
Test double functions that record calls, arguments and return values
Tracking Calls
describe('Spies', () => {
let calculator = { add: (a,b) => a+b };
it('should track calls but NOT call through', () => {
spyOn(calculator, 'add');
let result = calculator.add(1,1);
expect(calculator.add).toHaveBeenCalled();
expect(calculator.add).toHaveBeenCalledTimes(1);
expect(calculator.add).toHaveBeenCalledWith(1,1);
expect(result).not.toEqual(2);
})
})
Calling Through
describe('Spies', () => {
it('should call through', () => {
spyOn(calculator, 'add').and.callThrough();
let result = calculator.add(1,1);
expect(result).toEqual(2);
//restore stub behaviour
calculator.add.and.stub();
expect(calculator.add(1,1)).not.toEqual(2);
})
})
Set return values
describe('Spies', () => {
it('should return value with 42', () => {
spyOn(calculator, 'add').and.returnValue(42);
let result = calculator.add(1,1);
expect(result).toEqual(42);
})
it('should return values 1, 2, 3', () => {
spyOn(calculator, 'add').and.returnValues(1, 2, 3);
expect(calculator.add(1,1)).toEqual(1);
expect(calculator.add(1,1)).toEqual(2);
expect(calculator.add(1,1)).toEqual(3);
})
})
Set fake function
describe('Spies', () => {
it('should call fake function returning 42', () => {
spyOn(calculator, 'add').and.callFake((a,b) => 42);
expect(calculator.add(1,1)).toEqual(42);
})
})
Error handling
describe('Spies', () => {
it('should throw with error', () => {
spyOn(calculator, 'add').and.throwError("Ups");
expect(() => calculator.add(1,1)).toThrowError("Ups");
})
})
Creating Spies
describe('Spies', () => {
it('should be able to create a spy manually', () => {
let add = jasmine.createSpy('add');
add();
expect(add).toHaveBeenCalled();
})
})
// usage: create spy to use as a callback
// setTimeout(add, 100);
Creating Spies
describe('Spies', () => {
it('should be able to create multiple spies manually', () => {
let calculator = jasmine.createSpyObj('calculator', ['add']);
calculator.add.and.returnValue(42);
let result = calculator.add(1,1);
expect(calculator.add).toHaveBeenCalled();
expect(result).toEqual(42);
})
})
Angular Testing
Testing APIs
- inject,TestBed
- async
- fakeAsync/tick
Setup
import { TestBed } from '@angular/core/testing';
import {
BrowserDynamicTestingModule,
platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';
TestBed.initTestEnvironment(
BrowserDynamicTestingModule,
platformBrowserDynamicTesting()
);
Testing a Service
import {Injectable} from '@angular/core';
@Injectable()
export class LanguagesService {
get() {
return ['en', 'es', 'fr'];
}
}
Testing a Service
describe('Service: LanguagesService', () => {
//setup
beforeEach(() => TestBed.configureTestingModule({
providers: [ LanguagesService ]
}));
//specs
it('should return available languages', inject([LanguagesService], service => {
let languages = service.get();
expect(languages).toContain('en');
expect(languages).toContain('es');
expect(languages).toContain('fr');
expect(languages.length).toEqual(3);
});
});
Refactoring inject
describe('Service: LanguagesService', () => {
let service;
beforeEach(() => TestBed.configureTestingModule({
providers: [ LanguagesService ]
}));
beforeEach(inject([LanguagesService], s => {
service = s;
}));
it('should return available languages', () => {
let languages = service.get();
expect(languages).toContain('en');
expect(languages).toContain('es');
expect(languages).toContain('fr');
expect(languages.length).toEqual(3);
});
});
Asynchronous Testing
Asynchronous APIs
- Jasmine.done
- async
- fakeAsync/tick
Http Service
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import 'rxjs/add/operator/map';
@Injectable()
export class UsersService {
constructor(private http: HttpClient) { }
public get() {
return this.http.get('./src/assets/users.json')
.map(response => response.users);
}
}
Testing Real Service 1/2
describe('Service: UsersService', () => {
let service, http;
beforeEach(() => TestBed.configureTestingModule({
imports: [ HttpClientModule ],
providers: [ UsersService ]
}));
beforeEach(inject([UsersService, HttpClient], (s, h) => {
service = s;
http = h;
}));
[...]
Testing Real Service 2/2
describe('Service: UsersService', () => {
[...]
it('should return available users (LIVE)', done => {
service.get()
.subscribe({
next: res => {
expect(res.users).toBe(USERS);
expect(res.users.length).toEqual(2);
done();
}
});
});
});
Testing HttpMock 1/2
describe('Service: UsersService', () => {
let service, httpMock;
beforeEach(() => TestBed.configureTestingModule({
imports: [ HttpClientTestingModule ],
providers: [ UsersService ]
}));
beforeEach(inject([UsersService, HttpTestingController], (s, h) => {
service = s;
httpMock = h;
}));
afterEach(httpMock.verify);
[...]
Testing HttpMock 2/2
describe('Service: UsersService', () => {
[...]
it('should return available users', done => {
service.get()
.subscribe({
next: res => {
expect(res.users).toBe(USERS);
expect(res.users.length).toEqual(2);
done();
}
});
httpMock.expectOne('./src/assets/users.json')
.flush(USERS);
});
});
Components Testing
Greeter Component
import {Component, Input} from '@angular/core';
@Component({
selector: 'greeter', // <greeter name="Igor"></greeter>
template: `<h1>Hello {{name}}!</h1>`
})
export class Greeter {
@Input() name;
}
Testing Fixtures (sync)
describe('Component: Greeter', () => {
let fixture, greeter, element, de;
//setup
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ Greeter ]
});
fixture = TestBed.createComponent(Greeter);
greeter = fixture.componentInstance;
element = fixture.nativeElement;
de = fixture.debugElement;
});
}
Testing Fixtures (async)
describe('Component: Greeter', () => {
let fixture, greeter, element, de;
//setup
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ Greeter ],
})
.compileComponents() // compile external templates and css
.then(() => {
fixture = TestBed.createComponent(Greeter);
greeter = fixture.componentInstance;
element = fixture.nativeElement;
de = fixture.debugElement;
});
))
});
Using Change Detection
describe('Component: Greeter', () => {
it('should render `Hello World!`', async(() => {
greeter.name = 'World';
//trigger change detection
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(element.querySelector('h1').innerText).toBe('Hello World!');
expect(de.query(By.css('h1')).nativeElement.innerText).toBe('Hello World!');
});
}));
}
Using fakeAsync
describe('Component: Greeter', () => {
it('should render `Hello World!`', fakeAsync(() => {
greeter.name = 'World';
//trigger change detection
fixture.detectChanges();
//execute all pending asynchronous calls
tick();
expect(element.querySelector('h1').innerText).toBe('Hello World!');
expect(de.query(By.css('h1')).nativeElement.innerText).toBe('Hello World!');
}));
}
Override Template
describe('Component: Greeter', () => {
let fixture, greeter, element, de;
//setup
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ Greeter ],
})
.compileComponents() // compile external templates and css
.then(() => {
TestBed.overrideTemplate(Greeter, '<h1>Hi</h1>');
fixture = TestBed.createComponent(Greeter);
greeter = fixture.componentInstance;
element = fixture.nativeElement;
de = fixture.debugElement;
});
))
});
Shallow Testing
NO_ERRORS_SCHEMA
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ MyComponent ],
schemas: [ NO_ERRORS_SCHEMA ]
})
});
Marble Testing
- Test RxJS Observables
-
Features
- Test cold Observables
- Test hot Observables
- Control values and errors
- Control time (TestScheduler)
Cold Observable Example
import {marbles} from "rxjs-marbles";
describe("Cold Observables", () => {
describe("basic marbles", () => {
it("should support simple values as strings", marbles(m => {
const input = m.cold("--1--1|");
const expected = m.cold("--1--2|");
const output = input.pipe(map(x => x));
m.expect(output).toBeObservable(expected);
}));
});
});
toBeObservable Output
const input = m.cold("--1--1|");
const expected = m.cold("--1--2|");
...
Cold Observables basic marbles should support simple values as strings
Error:
Expected
{"frame":20,"notification":{"kind":"N","value":"1","hasValue":true}}
{"frame":50,"notification":{"kind":"N","value":"1","hasValue":true}}
{"frame":60,"notification":{"kind":"C","hasValue":false}}
to deep equal
{"frame":20,"notification":{"kind":"N","value":"1","hasValue":true}}
{"frame":50,"notification":{"kind":"N","value":"2","hasValue":true}}
{"frame":60,"notification":{"kind":"C","hasValue":false}}
Using Symbols
import {marbles} from "rxjs-marbles";
describe("Cold Observables", () => {
describe("basic marbles", () => {
it("should support simple values as strings", marbles(m => {
const values = { a: 1, b: 2 };
const input = m.cold("--a--a|", values);
const expected = m.cold("--b--b|", values);
const output = input.pipe(map(x => x+1));
m.expect(output).toBeObservable(expected);
}));
});
});
Error Handling (1/2)
import {marbles} from "rxjs-marbles";
describe("Cold Observables", () => {
describe("basic marbles", () => {
it("should support custom errors (symbols)", marbles(m => {
const values = { a: 1 };
const input = m.cold("--a#", values, new Error('Ups'));
const expected = m.cold("--a#", values, new Error('Ups'));
const output = input.pipe(map(x => x));
m.expect(output).toBeObservable(expected);
}));
});
});
Error Handling (2/2)
import {marbles} from "rxjs-marbles";
describe("Cold Observables", () => {
describe("basic marbles", () => {
it("should support custom Observables", marbles(m => {
const input = throwError(new Error('Ups')));
const expected = m.cold("#", undefined, new Error('Ups'));
const output = input.pipe(map(x => x));
m.expect(output).toBeObservable(expected);
}));
});
});
Hot Observable Example
import {marbles} from "rxjs-marbles";
describe("Hot Observables", () => {
describe("Subscriptions", () => {
it("should support basic subscriptions", marbles(m => {
const values = { a: 1, b: 2 };
const input = m.hot( "--a^-a|", values);
const expected = m.cold( "--b|", values);
const output = input.pipe(map(x => x+1));
m.expect(output).toBeObservable(expected);
}));
});
});
Subscription Test
import {marbles} from "rxjs-marbles";
describe("Hot Observables", () => {
describe("Subscriptions", () => {
it("should support testing subscriptions", marbles(m => {
const values = { a: 1, b: 2 };
const input = m.hot( "--a^-a|", values);
const subs = "^-!";
const expected = m.cold( "--b|", values);
const output = input.pipe(map(x => x+1));
m.expect(output).toBeObservable(expected);
m.expect(input).toHaveSubscriptions(subs);
}));
});
});
toHaveSubscriptions Output
const values = { a: 1, b: 2 };
const input = m.hot( "--a^-a|", values);
const subs = "^-!";
const expected = m.cold( "--b|", values);
...
Hot Observables Subscriptions should support complex subscriptions
Error:
Expected
{"subscribedFrame":0,"unsubscribedFrame":30}
to deep equal
{"subscribedFrame":0,"unsubscribedFrame":20}
Testing ngrx
Testing Actions
// actions/spinner.actions.spec.ts
import { SpinnerShow, SpinnerHide, SpinnerActionTypes } from './spinner.actions';
describe('SpinnerShow', () => {
it('should create an instance', () => {
const action = new SpinnerShow();
expect(action).toBeTruthy();
expect(action.type).toBe(SpinnerActionTypes.Show);
});
});
describe('SpinnerHide', () => {
it('should create an instance', () => {
const action = new SpinnerHide();
expect(action).toBeTruthy();
expect(action.type).toBe(SpinnerActionTypes.Hide);
});
});
Testing Reducers (1/2)
// reducers/spinner.reducer.spec.ts
describe('Spinner Reducer', () => {
it('should return the initial state', () => {
const action = { type: '🚀' } as any;
const result = reducer(initialState, action);
expect(result).toBe(initialState);
expect(initialState).toBe(false);
});
});
Testing Reducers (2/2)
// reducers/spinner.reducer.spec.ts
describe('Spinner Reducer', () => {
it('should handle SpinnerShow Action', () => {
const action = new SpinnerShow();
const result = reducer(initialState, action);
expect(result).toBe(true);
});
it('should handle SpinnerHide Action', () => {
const action = new SpinnerHide();
const result = reducer(initialState, action);
expect(result).toBe(false);
});
});
Testing Entity Selectors (1/2)
// reducers/selectors.spec.ts
describe('Selectors', () => {
let adapter : EntityAdapter<Todo>;
let initialState : any;
let t: Array<Todo> = [
{ id: 1, text: 'Learn French', completed: false },
{ id: 2, text: 'Try Poutine', completed: true }
];
beforeAll(() => {
adapter = createEntityAdapter<Todo>();
initialState = adapter.getInitialState();
})
})
Testing Entity Selectors (2/2)
// reducers/selectors.spec.ts
describe('Selectors', () => {
describe('getFilteredTodos', () => {
it('should return only active todos', () => {
const todos = adapter.addMany(t, initialState);
const state: TodosState = {
todos,
currentFilter: "SHOW_ACTIVE"
}
expect(getFilteredTodos(state)).toEqual([t[0]]);
})
})
})
Testing Component (1/2)
// loading/loading.component.spec.ts
describe('LoadingComponent', () => {
let fixture, loading, element, de;
// setup
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ LoadingComponent ]
});
fixture = TestBed.createComponent(LoadingComponent);
loading = fixture.componentInstance;
element = fixture.nativeElement;
de = fixture.debugElement;
});
})
Testing Component (2/2)
// loading/loading.component.spec.ts
describe('LoadingComponent', () => {
let fixture, loading, element, de;
// specs
it('should render Spinner', () => {
loading.loading = true;
fixture.detectChanges(); //trigger change detection
fixture.whenStable().then(() => {
expect(element.querySelector('svg')).toBeTruthy();
});
});
})
More
Blog Post
Demos
-
Components, Directives, Pipes
-
Services, Http, MockBackend
-
Router, Observables, Spies
-
Animations
-
Marble Testing
Advanced Testing Recipes (v7+)
By Gerard Sans
Advanced Testing Recipes (v7+)
In this talk, we cover the most common testing scenarios to use while developing rock solid Angular Applications, like: Components, Services, Http and Pipes; but also some less covered areas like: Directives, the Router and Observables. We will provide examples for using TestBed, fixtures, async and fakeAsync/tick while recommending best practices.
- 3,424