Helpers and examples for unit testing on Angular applications and libraries.
Last active
August 8, 2022 19:27
-
-
Save kaplan81/a97662f5909867aa64852122c58e64af to your computer and use it in GitHub Desktop.
Angular unit test helpers and samples
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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)); | |
} | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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(); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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); | |
}); | |
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(); | |
// }); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* FILE TYPE: TEST HELPER */ | |
import { Directive, HostListener, Input, NgModule } from '@angular/core'; | |
// export for convenience. | |
export { RouterLink } from '@angular/router'; | |
/* tslint:disable:directive-class-suffix */ | |
@Directive({ | |
selector: '[routerLink]' | |
}) | |
export class RouterLinkStubDirective { | |
navigatedTo: any = null; | |
@Input() | |
routerLink: any; | |
@HostListener('click') | |
onClick() { | |
this.navigatedTo = this.routerLink; | |
} | |
} | |
@NgModule({ | |
declarations: [RouterLinkStubDirective] | |
}) | |
export class RouterStubsModule {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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. | |
*/ | |
const optionsDataMock: { [key: string]: any } = { | |
option1: 'option1', | |
option2: 'option2', | |
option3: 'option3', | |
}; | |
@Component({ selector: 'pfx-child', template: '' }) | |
class ChildStubComponent { | |
@Input() prop: string; | |
} | |
@Injectable() | |
class MyAsyncServiceMock extends MyAsyncService { | |
myAsyncServiceMethod: () => Observable<any[]> = jest.fn(() => of([])); | |
} | |
@Injectable() | |
class MyFromModuleServiceMock extends MyFromModuleService { | |
observableMethod: () => Observable<any[]> = jest.fn(() => of([])); | |
optionsObj = optionsDataMock; | |
promiseMethod: () => Promise<any[]> = jest.fn(() => Promise.resolve([])); | |
voidMethod: () => void = 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; | |
beforeEach(async(() => { | |
// myAsyncServiceMethod = new myAsyncService().myAsyncServiceMethod; // OLD | |
// myValue = 'myValue'; // OLD | |
TestBed.configureTestingModule({ | |
imports: [CoreModule], | |
declarations: [MyComponent, ChildStubComponent], | |
providers: [MyService, { provide: MyAsyncService, useClass: MyAsyncServiceMock }] | |
}) | |
.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 = TestBed.inject(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); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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