Skip to content

Instantly share code, notes, and snippets.

@kaplan81
Last active August 8, 2022 19:27
Show Gist options
  • Save kaplan81/a97662f5909867aa64852122c58e64af to your computer and use it in GitHub Desktop.
Save kaplan81/a97662f5909867aa64852122c58e64af to your computer and use it in GitHub Desktop.
Angular unit test helpers and samples

Testing

Helper classes and example specs for unit testing purposes on Angular applications and libraries.

/* FILE TYPE: TEST HELPER */
// export for convenience.
export { ActivatedRoute } from '@angular/router';
import { convertToParamMap, Data, ParamMap, Params } from '@angular/router';
import { Observable, of, BehaviorSubject } from 'rxjs';
export interface ActivatedRouteSnapshotStub {
data?: Data;
paramMap?: ParamMap;
}
export interface ActivatedRouteProps {
initialData?: Data;
initialSnapshot?: ActivatedRouteSnapshotStub;
initialParams?: Params;
}
export class ActivatedRouteStub {
data: Observable<Data>;
snapshot: ActivatedRouteSnapshotStub;
readonly paramMap: Observable<ParamMap>;
// ReplaySubject is not compatible with snapshot testing since it produces a window timestamp.
private subject = new BehaviorSubject<ParamMap>(null);
constructor(init: ActivatedRouteProps = {}) {
this.paramMap = this.subject.asObservable();
if (init.initialSnapshot) this.snapshot = init.initialSnapshot;
if (init.initialData) {
this.data = of(init.initialData);
this.setParamMap(init.initialParams);
}
}
setParamMap(params?: Params) {
this.subject.next(convertToParamMap(params));
}
}
/* FILE TYPE: TEST HELPER */
import { DebugElement } from '@angular/core';
import { ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
export interface ComponentSuiteElements<H, N = any> {
host: ComponentTestingElement<H>;
nested?: ComponentTestingElement<N>;
}
export interface ComponentTestingElement<T> {
component: T;
debugEl: DebugElement;
nativeEl: Element | HTMLElement;
}
export class ComponentSuite<H, N = any> {
elements: ComponentSuiteElements<H, N>;
constructor(private fixture: ComponentFixture<H>, private selector?: string) {
this.setElements();
}
private getHost(): ComponentTestingElement<H> {
const component: H = this.fixture.componentInstance;
const debugEl: DebugElement = this.fixture.debugElement;
const nativeEl: Element | HTMLElement = debugEl.nativeElement;
return { component, debugEl, nativeEl };
}
private getIntegrationElements(): ComponentSuiteElements<H, N> {
const host: ComponentTestingElement<H> = this.getHost();
const nested: ComponentTestingElement<N> = this.getNested(host.debugEl);
return {
host,
nested
};
}
private getNested(hostDebugEl: DebugElement): ComponentTestingElement<N> {
const debugEl: DebugElement = hostDebugEl.query(By.css(this.selector));
const component: N = debugEl.componentInstance;
const nativeEl: Element | HTMLElement = debugEl.nativeElement;
return { component, debugEl, nativeEl };
}
private getShallowElements(): ComponentSuiteElements<H> {
return { host: this.getHost() };
}
private setElements(): void {
if (this.selector) {
this.elements = this.getIntegrationElements();
} else {
this.elements = this.getShallowElements();
}
}
}
/* FILE TYPE: HTTP TEST */
// Http testing module and mocking controller
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
// Other imports
import { TestBed } from '@angular/core/testing';
interface Data {
name: string;
}
const testUrl = '/data';
describe('HttpClient testing', () => {
let httpClient: HttpClient;
let httpTestingController: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule]
});
// Inject the http service and test controller for each test
httpClient = TestBed.get(HttpClient);
httpTestingController = TestBed.get(HttpTestingController);
});
afterEach(() => {
// After every test, assert that there are no more pending requests.
httpTestingController.verify();
});
/// Tests begin ///
it('can test HttpClient.get', () => {
const testData: Data = { name: 'Test Data' };
// Make an HTTP GET request
httpClient.get<Data>(testUrl).subscribe(data =>
// When observable resolves, result should match test data
expect(data).toEqual(testData)
);
// The following `expectOne()` will match the request's URL.
// If no requests or multiple requests matched that URL
// `expectOne()` would throw.
const req = httpTestingController.expectOne('/data');
// Assert that the request is a GET.
expect(req.request.method).toEqual('GET');
// Respond with mock data, causing Observable to resolve.
// Subscribe callback asserts that correct data was returned.
req.flush(testData);
// Finally, assert that there are no outstanding requests.
httpTestingController.verify();
});
it('can test HttpClient.get with matching header', () => {
const testData: Data = { name: 'Test Data' };
// Make an HTTP GET request with specific header
httpClient
.get<Data>(testUrl, {
headers: new HttpHeaders({ Authorization: 'my-auth-token' })
})
.subscribe(data => expect(data).toEqual(testData));
// Find request with a predicate function.
// Expect one request with an authorization header
const req = httpTestingController.expectOne(r => r.headers.has('Authorization'));
req.flush(testData);
});
it('can test multiple requests', () => {
const testData: Data[] = [
{ name: 'bob' },
{ name: 'carol' },
{ name: 'ted' },
{ name: 'alice' }
];
// Make three requests in a row
httpClient.get<Data[]>(testUrl).subscribe(d => expect(d.length).toEqual(0));
httpClient.get<Data[]>(testUrl).subscribe(d => expect(d).toEqual([testData[0]]));
httpClient.get<Data[]>(testUrl).subscribe(d => expect(d).toEqual(testData));
// get all pending requests that match the given URL
const requests = httpTestingController.match(testUrl);
expect(requests.length).toEqual(3);
// Respond to each request with different results
requests[0].flush([]);
requests[1].flush([testData[0]]);
requests[2].flush(testData);
});
it('can test for 404 error', () => {
const emsg = 'deliberate 404 error';
httpClient.get<Data[]>(testUrl).subscribe(
data => fail('should have failed with the 404 error'),
(error: HttpErrorResponse) => {
expect(error.status).toEqual(404);
expect(error.error).toEqual(emsg);
}
);
const req = httpTestingController.expectOne(testUrl);
// Respond with mock error
req.flush(emsg, { status: 404, statusText: 'Not Found' });
});
it('can test for network error', () => {
const emsg = 'simulated network error';
httpClient.get<Data[]>(testUrl).subscribe(
data => fail('should have failed with the network error'),
(error: HttpErrorResponse) => {
expect(error.error.message).toEqual(emsg);
}
);
const req = httpTestingController.expectOne(testUrl);
// Create mock ErrorEvent, raised when something goes wrong at the network level.
// Connection timeout, DNS error, offline, etc
const errorEvent = new ErrorEvent('so sad', {
message: emsg,
// The rest of this is optional and not used.
// Just showing that you could provide this too.
filename: 'HeroService.ts',
lineno: 42,
colno: 21
});
// Respond with mock error
req.error(errorEvent);
});
it('httpTestingController.verify should fail if HTTP response not simulated', () => {
// Sends request
httpClient.get('some/api').subscribe();
// verify() should fail because haven't handled the pending request.
expect(() => httpTestingController.verify()).toThrow();
// Now get and flush the request so that afterEach() doesn't fail
const req = httpTestingController.expectOne('some/api');
req.flush(null);
});
// Proves that verify in afterEach() really would catch error
// if test doesn't simulate the HTTP response.
//
// Must disable this test because can't catch an error in an afterEach().
// Uncomment if you want to confirm that afterEach() does the job.
// it('afterEach() should fail when HTTP response not simulated',() => {
// // Sends request which is never handled by this test
// httpClient.get('some/api').subscribe();
// });
});
/* FILE TYPE: HERO SERVICE TEST */
/**
* Test the HeroService when implemented with the OLD HttpModule
*/
import {
async, inject, TestBed
} from '@angular/core/testing';
import {
MockBackend,
MockConnection
} from '@angular/http/testing';
import {
HttpModule, Http, XHRBackend, Response, ResponseOptions
} from '@angular/http';
import { of } from 'rxjs';
import { Hero } from './hero';
import { HttpHeroService } from './http-hero.service';
const makeHeroData = () => [
{ id: 1, name: 'Windstorm' },
{ id: 2, name: 'Bombasto' },
{ id: 3, name: 'Magneta' },
{ id: 4, name: 'Tornado' }
] as Hero[];
//////// Tests /////////////
describe('HttpHeroService (using old HttpModule)', () => {
let backend: MockBackend;
let service: HttpHeroService;
beforeEach( () => {
TestBed.configureTestingModule({
imports: [ HttpModule ],
providers: [
HttpHeroService,
{ provide: XHRBackend, useClass: MockBackend }
]
});
});
it('can instantiate service via DI', () => {
service = TestBed.get(HttpHeroService);
expect(service instanceof HttpHeroService).toBe(true);
});
it('can instantiate service with "new"', () => {
const http = TestBed.get(Http);
expect(http).not.toBeNull('http should be provided');
let service = new HttpHeroService(http);
expect(service instanceof HttpHeroService).toBe(true, 'new service should be ok');
});
it('can provide the mockBackend as XHRBackend', () => {
const backend = TestBed.get(XHRBackend);
expect(backend).not.toBeNull('backend should be provided');
});
describe('when getHeroes', () => {
let fakeHeroes: Hero[];
let http: Http;
let response: Response;
beforeEach(() => {
backend = TestBed.get(XHRBackend);
http = TestBed.get(Http);
service = new HttpHeroService(http);
fakeHeroes = makeHeroData();
let options = new ResponseOptions({status: 200, body: {data: fakeHeroes}});
response = new Response(options);
});
it('should have expected fake heroes (then)', () => {
backend.connections.subscribe((c: MockConnection) => c.mockRespond(response));
service.getHeroes().toPromise()
// .then(() => Promise.reject('deliberate'))
.then(heroes => {
expect(heroes.length).toBe(fakeHeroes.length,
'should have expected no. of heroes');
})
.catch(fail);
});
it('should have expected fake heroes (Observable tap)', () => {
backend.connections.subscribe((c: MockConnection) => c.mockRespond(response));
service.getHeroes().subscribe(
heroes => {
expect(heroes.length).toBe(fakeHeroes.length,
'should have expected no. of heroes');
},
fail
);
});
it('should be OK returning no heroes', () => {
let resp = new Response(new ResponseOptions({status: 200, body: {data: []}}));
backend.connections.subscribe((c: MockConnection) => c.mockRespond(resp));
service.getHeroes().subscribe(
heroes => {
expect(heroes.length).toBe(0, 'should have no heroes');
},
fail
);
});
it('should treat 404 as an Observable error', () => {
let resp = new Response(new ResponseOptions({status: 404}));
backend.connections.subscribe((c: MockConnection) => c.mockRespond(resp));
service.getHeroes().subscribe(
heroes => fail('should not respond with heroes'),
err => {
expect(err).toMatch(/Bad response status/, 'should catch bad response status code');
return of(null); // failure is the expected test result
});
});
});
});
/* FILE TYPE: TEST HELPER */
import { Component, DebugElement } from '@angular/core';
import { By } from '@angular/platform-browser';
import { Router } from '@angular/router';
export class Page {
private router: Router;
constructor(
private component: Component,
private debugEl: DebugElement,
private nativeEl: Element | HTMLElement
) {
this.router = debugEl.injector.get(Router);
}
queryByAll<T>(): T[] {
return <any>this.debugEl.query(By.all());
}
queryByCss<T>(selector: string): T {
return <any>this.debugEl.query(By.css(selector));
}
queryAllByCss<T>(selector: string): T[] {
return <any>this.debugEl.queryAll(By.css(selector));
}
queryByDirective<T>(directive: any): T {
return <any>this.debugEl.query(By.directive(directive));
}
queryAllByDirective<T>(directive: any): T[] {
return <any>this.debugEl.query(By.directive(directive));
}
spyOnMethod(method: string) {
spyOn(this.component, <any>method);
}
spyOnMethodAndCallThrough(method: string) {
spyOn(this.component, <any>method).and.callThrough();
}
}
/* FILE TYPE: JEST COMPONENT TEST SAMPLE */
import { Component, DebugElement, Input } from '@angular/core';
import { ComponentFixture, TestBed, async } from '@angular/core/testing';
// Use Typescript path mapping to import test helpers.
import { CoreModule } from '@app/core/core.module';
import { Page } from '@testing';
import { ComponentSuite, ComponentSuiteElements } from '@testing';
import { cold, getTestScheduler } from 'jasmine-marbles';
import { of } from 'rxjs/observable/of';
import { MyAsyncService } from './my-async.service';
import { MyFromModuleService } from './my-from-module.service';
import { MyComponent } from './my.component';
import { MyService } from './my.service';
/* HERE YOUR STUBS AND MOCKS */
/*
Use stub components for shallow testing.
Do not use NO_ERRORS_SCHEMA.
*/
@Component({ selector: 'child-comp', template: '' })
class ChildStubComponent {
@Input() prop: string;
}
const myAsyncService = jest.fn<MyAsyncService>(() => ({
myAsyncServiceMethod: jest.fn(() => of([]))
}));
const optionsMock = {
option1: 'option1',
option2: 'option2',
option3: 'option3'
};
const myFromModuleServiceMock = jest.fn<MyFromModuleService>(() => ({
optionsObj: optionsMock,
promiseMethod: jest.fn(() => Promise.resolve()),
observableMethod: jest.fn(() => of([])),
voidMethod: jest.fn()
}));
/* HERE YOUR SUITES (describe) */
describe('MyComponent', () => {
/* Declare all variables that you need for your specs. */
let fixture: ComponentFixture<MyComponent>;
let els: ComponentSuiteElements<MyComponent>;
let page: Page;
let myService: MyService;
// Methods from mocked services.
let myAsyncServiceMethod: any;
// Other values.
let myValue: string;
beforeEach(async(() => {
/* Initialized here variables that are not depending on the fixture. */
myAsyncServiceMethod = new myAsyncService().myAsyncServiceMethod;
myValue = 'myValue';
TestBed.configureTestingModule({
imports: [CoreModule],
declarations: [MyComponent, ChildStubComponent],
providers: [MyService, { provide: MyAsyncService, useValue: myAsyncService }]
})
.overrideComponent(MyComponent, {
set: {
providers: [{ provide: MyFromModuleService, useClass: myFromModuleServiceMock }]
}
})
.compileComponents()
.then(() => {
fixture = TestBed.createComponent(MyComponent);
els = new ComponentSuite<MyComponent>(fixture).elements;
/* User the Page class (or extend it) to encapsulate component's complexity. */
page = new Page(component, debugEl, nativeEl);
/*
We can also have non-mocked services here.
For mocked services we directly test on their mocked methods.
*/
myService = debugEl.injector.get(MyService);
});
}));
/* HERE YOUR SPECS (it -> expect) */
it('should create', () => {
/*
Detect changes on every spec and not on the beforeEach()
since you might also want to check some logic on the component's constructor().
*/
fixture.detectChanges();
/* We use Jest snapshot testing instead of the usual matcher. */
expect(fixture).toMatchSnapshot();
});
/* Shallow test example with ComponentSuite helper class */
it('should generate as many titles as contents', () => {
fixture.detectChanges();
const titles: number = els.host.debugEl.queryAll(By.css('.title')).length;
const contents: number = els.host.component.contents.toArray().length;
expect(titles).toEqual(contents);
});
/* Work with jasmine marbles to test observables. */
it('should test with jasmine marbles', () => {
const q$ = cold('---x|', { x: myValue });
myAsyncServiceMethod.and.returnValue(q$);
fixture.detectChanges(); // ngOnInit()
expect(els.host.nativeEl.textContent).toBe('...');
getTestScheduler().flush(); // flush the observables
fixture.detectChanges(); // update view
expect(els.host.nativeEl.textContent).toBe(myValue);
});
});
/* FILE TYPE: TEST HELPER */
/** Wait a tick, then detect changes */
export function advance(f: ComponentFixture<any>): void {
tick();
f.detectChanges();
}
/**
* Create custom DOM event the old fashioned way
*
* https://developer.mozilla.org/en-US/docs/Web/API/Event/initEvent
* Although officially deprecated, some browsers (phantom) don't accept the preferred "new Event(eventName)"
*/
export function newEvent(eventName: string, bubbles = false, cancelable = false) {
const evt = document.createEvent('CustomEvent'); // MUST be 'CustomEvent'
evt.initCustomEvent(eventName, bubbles, cancelable, null);
return evt;
}
// See https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button
/** Button events to pass to `DebugElement.triggerEventHandler` for RouterLink event handler */
export const ButtonClickEvents = {
left: { button: 0 },
right: { button: 2 }
};
/** Simulate element click. Defaults to mouse left-button click event. */
export function click(
el: DebugElement | HTMLElement,
eventObj: any = ButtonClickEvents.left
): void {
if (el instanceof HTMLElement) {
el.click();
} else {
el.triggerEventHandler('click', eventObj);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment