Skip to content

Instantly share code, notes, and snippets.

@kaplan81
Last active August 8, 2022 19:27
Show Gist options
  • Star 7 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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

Helpers and examples for unit testing 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);
});
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: 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.
*/
const optionsDataMock: Record<string, string> = {
option1: 'option1',
option2: 'option2',
option3: 'option3',
};
@Component({ selector: 'pfx-child', template: '' })
class ChildStubComponent {
@Input() prop: string;
}
@Injectable()
class MyAsyncServiceMock extends MyAsyncService {
myAsyncServiceMethod: jest.Mock<Observable<any[]>> = jest.fn(() => of([]));
}
@Injectable()
class MyFromModuleServiceMock extends MyFromModuleService {
observableMethod: jest.Mock<Observable<any[]>> = jest.fn(() => of([]));
optionsObj: Record<string, string> = optionsDataMock;
promiseMethod: jest.Mock<Promise<any[]>> = jest.fn(() => Promise.resolve([]));
voidMethod: jest.Mock<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);
});
});
/* 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