Angular Tests Made Easy
Spectator is written on top of the Angular Testing Framework and provides two things:
- A cleaner API for testing.
- A set of custom matchers that will help you to test DOM elements more easily.
Writing tests for our code is part of our daily routine. When working on large applications with many components, it can take up a lot of time and effort. Although Angular provides us with great tools to deal with these things, it still requires quite a lot of boilerplate work. For this reason, I decided to create a library that will make it easier for us to write tests by cutting down on the boilerplate and adding custom Jasmine matchers.
To install spectator via NPM run:
npm install @netbasal/spectator --save-dev
To install spectator via Yarn run:
yarn add @netbasal/spectator --dev
Auto generate specs with the cli
- Testing Components
- Testing Components with Host
- Testing Components with Custom Host Component
- Testing Directives
- Testing Services
- Testing Services with Mocks
- Typed Mocks
- Testing Data Services
// button.component.ts
@Component({
selector: 'app-button',
template: `
<button class="{{className}}" (click)="onClick($event)">{{title}}</button>
`
})
export class ButtonComponent {
@Input() className = 'success';
@Input() title = '';
@Output() click = new EventEmitter();
onClick( $event ) {
this.click.emit($event);
}
}
// button.component.spec.ts
import { ButtonComponent } from './button.component';
import { Spectator, createTestComponentFactory } from '@netbasal/spectator';
describe('ButtonComponent', () => {
let spectator: Spectator<ButtonComponent>;
const createComponent = createTestComponentFactory(ButtonComponent);
// const createComponent = createTestComponentFactory({
// component: ButtonComponent,
// imports: [OtherModule],
// providers: [...]
// });
it('should set the "success" class by default', () => {
spectator = createComponent();
expect(spectator.query('button')).toHaveClass('success');
});
it('should set the class name according to the [className]', () => {
spectator = createComponent({ className: 'danger' });
expect(spectator.query('button')).toHaveClass('danger');
expect(spectator.query('button')).not.toHaveClass('success');
});
it('should set the title according to the [title]', () => {
spectator = createComponent({ 'title': 'Click' });
expect(spectator.query('button')).toHaveText('Click');
});
it('should emit the $event on click', () => {
const detectChanges = false;
spectator = createComponent({}, detectChanges);
let output;
spectator.output<{ type: string }>('click').subscribe(result => output = result);
spectator.component.onClick({ type: 'click' });
spectator.detectChanges();
expect(output).toEqual({ type: 'click' });
});
});
describe("CalcComponent", () => {
let spectator: Spectator<CalcComponent>;
const createComponent = createTestComponentFactory(CalcComponent);
it("should be defined", () => {
spectator = createComponent();
expect(spectator.component).toBeTruthy();
});
it("should concat the value", () => {
spectator = createComponent();
const a = spectator.query(".a");
const b = spectator.query(".b");
a.value = "1";
b.value = "2";
spectator.dispatchFakeEvent(a, "input");
spectator.dispatchFakeEvent(b, "input");
expect(spectator.query(".result")).toHaveText("12");
});
});
// zippy.component.ts
@Component({
selector: 'zippy',
template: `
<div class="zippy">
<div (click)="toggle()" class="zippy__title">
<span class="arrow">{{ visible ? 'Close' : 'Open' }}</span> {{title}}
</div>
<div *ngIf="visible" class="zippy__content">
<ng-content></ng-content>
</div>
</div>
`
})
export class ZippyComponent {
@Input() title;
visible = false;
toggle() {
this.visible = !this.visible;
}
}
// zippy.component.spec.ts
import { ZippyComponent } from './zippy.component';
import { createHostComponentFactory, SpectatorWithHost } from '@netbasal/spectator';
import { Component } from '@angular/core';
describe('ZippyComponent', () => {
let host: SpectatorWithHost<ZippyComponent>;
const createHost = createHostComponentFactory(ZippyComponent);
it('should display the title', () => {
host = createHost(`<zippy title="Zippy title"></zippy>`);
expect(host.query('.zippy__title')).toHaveText(( text ) => 'Zippy title');
});
it('should have attribute', () => {
host = createHost(`<zippy title="Zippy title">Zippy content</zippy>`);
expect(host.query('.zippy')).toHaveAttr({ attr: 'id', val: 'zippy' });
});
it('should be checked', () => {
host = createHost(`<zippy title="Zippy title">Zippy content</zippy>`);
expect(host.query('.checkbox')).toHaveProp({ prop: 'checked', val: true });
});
it('should display the content', () => {
host = createHost(`<zippy title="Zippy title">Zippy content</zippy>`);
host.click('.zippy__title');
expect(host.query('.zippy__content')).toHaveText('Zippy content');
});
it('should display the "Open" word if closed', () => {
host = createHost(`<zippy title="Zippy title">Zippy content</zippy>`);
expect(host.query('.arrow')).toHaveText('Open');
expect(host.query('.arrow')).not.toHaveText('Close');
});
it('should display the "Close" word if open', () => {
host = createHost(`<zippy title="Zippy title">Zippy content</zippy>`);
host.click('.zippy__title');
expect(host.query('.arrow')).toHaveText('Close');
expect(host.query('.arrow')).not.toHaveText('Open');
}
);
it('should be closed by default', () => {
host = createHost(`<zippy title="Zippy title"></zippy>`);
expect('.zippy__content').not.toExist();
});
it('should toggle the content', () => {
host = createHost(`<zippy title="Zippy title"></zippy>`);
host.click('.zippy__title');
expect(host.query('.zippy__content')).toExist();
host.click('.zippy__title');
expect('.zippy__content').not.toExist();
});
});
@Component({ selector: 'custom-host', template: '' })
class CustomHostComponent {
title = 'Custom HostComponent';
}
describe('With Custom Host Component', function () {
let host: SpectatorWithHost<ZippyComponent, CustomHostComponent>;
const createHost = createHostComponentFactory({ component: ZippyComponent, host: CustomHostComponent });
it('should display the host component title', () => {
host = createHost(`<zippy [title]="title"></zippy>`);
expect(host.query('.zippy__title')).toHaveText('Custom HostComponent');
});
});
@Directive({
selector: '[highlight]'
})
export class HighlightDirective {
@HostBinding('style.background-color') backgroundColor : string;
@HostListener('mouseover')
onHover() {
this.backgroundColor = '#000000';
}
@HostListener('mouseout')
onLeave() {
this.backgroundColor = '#ffffff';
}
}
import { HighlightDirective } from './highlight.directive';
import { createHostComponentFactory, SpectatorWithHost } from '@netbasal/spectator';
describe('HighlightDirective', function () {
let host: SpectatorWithHost<HighlightDirective>;
const createHost = createHostComponentFactory(HighlightDirective);
it('should change the background color', () => {
host = createHost(`<div highlight>Testing HighlightDirective</div>`);
host.dispatchMouseEvent(host.element, 'mouseover');
expect(host.element).toHaveStyle({
backgroundColor: 'rgba(0,0,0, 0.1)'
});
host.dispatchMouseEvent(host.element, 'mouseout');
expect(host.element).toHaveStyle({
backgroundColor: '#fff'
});
});
});
import { CounterService } from './counter.service';
import { createService } from '@netbasal/spectator';
describe('CounterService Without Mock', () => {
const spectator = createService(CounterService);
it('should be 0', () => {
expect(spectator.service.counter).toEqual(0);
});
});
import { CounterService } from './counter.service';
import { createService } from '@netbasal/spectator';
describe('TodosService', () => {
const spectator = createService({ service: TodosService, mocks: [OtherService] });
it('should', () => {
let otherService = spectator.get<OtherService>(OtherService);
otherService.someMethod.andReturn(customValue);
otherService.someMethod.and.callThrough();
otherService.someMethod.and.callFake(() => fake);
otherService.someMethod.and.throwError('Error');
otherService.someMethod.andCallFake(() => fake);
spectator.service.remove();
expect(spectator.service.someProp).toBeTruthy();
});
});
import { SpyObject, mockProvider } from '@netbasal/spectator';
let otherService: SpyObject<OtherService>;
let service: TestedService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
TestedService,
mockProvider(OtherService)
],
});
otherService = TestBed.get(OtherService);
service = TestBed.get(GoogleBooksService);
});
it('should be 0', () => {
otherService.method.andReturn('mocked value'); // mock is strongly typed
// then test serivce
});
import { TodosDataService } from './todos-data.service';
import { createHTTPFactory, HTTPMethod } from '@netbasal/spectator';
describe('HttpClient testing', () => {
let http = createHTTPFactory<TodosDataService>(TodosDataService);
it('can test HttpClient.get', () => {
let { dataService, controller, expectOne } = http();
dataService.get().subscribe();
expectOne('url', HTTPMethod.GET);
});
it('can test HttpClient.post', () => {
let { dataService, controller, expectOne } = http();
dataService.post(1).subscribe();
const req = expectOne('url', HTTPMethod.POST);
expect(req.request.body['id']).toEqual(1);
});
});
createTestComponentFactory<T>(options: SpectatorOptions<T> | Type<T>): (componentParameters?: Partial<T>, detectChanges?: boolean) => Spectator<T>
createHostComponentFactory<T, H = HostComponent>(options: SpectatorOptions<T, H> | Type<T>): (template: string, detectChanges?: boolean) => SpectatorWithHost<T, H>
createService<S>(options: Params<S> | Type<S>): SpectatorService<S>
createHTTPFactory<T>(dataService: Type<T>, providers = [])
mockProvider<T>(type: Type<T>): Provider
detectChanges()
- Runs detectChanges on the tested element/host
query(selector: string) : HTMLElement
- Returns the first element that is a descendant of the element on which it is invoked that matches the specified group of selectors
queryAll(selector: string) : NodeList
- Returns a static NodeList representing a list of elements matching the specified group of selectors which are descendants of the element on which the method was called
setInput(input : object | string, inputValue? : any)
- Changes the value of an @Input() of the tested component
whenOutput<T>( output : string) : Observable
- Returns an Observable @Output() of the tested component
get<T>(type: Type<T> | InjectionToken<T>): T
- Provides a wrapper for TestBed.get()
click(selector: string)
- Triggers a click event
dispatchMouseEvent(node: Node, type: string, x = 0, y = 0, event = createMouseEvent(type, x, y)): MouseEvent
- Triggers a mouse event
dispatchKeyboardEvent(node: Node, type: string, keyCode: number, target?: Element): KeyboardEvent
- Triggers a keyboard event
dispatchFakeEvent(node: Node | Window, type: string, canBubble?: boolean): Event
- Triggers any event
dispatchTouchEvent(node: Node, type: string, x = 0, y = 0)
- Triggers a touch event
typeInElement(value: string, element: HTMLInputElement)
- Sets focus on the input element, sets its value and dispatches the
input
event, simulating the user typing.
- Sets focus on the input element, sets its value and dispatches the
patchElementFocus(element: HTMLElement)
- Patches an elements focus and blur methods to emit events consistently and predictably
fixture
- The tested component's fixturecomponent
- The tested component's instanceelement
- The tested component's native elementdebugElement
- The tested fixture's debug element
hostFixture
- The host's fixturehostComponent
- The host's component instancehostElement
- The host's native elementhostDebugElement
- The host's fixture debug elementcomponent
- The tested component's instanceelement
- The tested component's native elementdebugElement
- The tested component's debug element
toExist()
- e.g.
expect('.zippy__content').not.toExist();
- e.g.
toHaveLength()
- e.g.
expect('.zippy__content').toHaveLength(3);
- e.g.
toHaveId()
- e.g.
expect('.zippy__content').toHaveId('ID')
- e.g.
toHaveClass(class)
- e.g.
expect('.zippy__content').toHaveClass('class');
- e.g.
expect('.zippy__content').toHaveClass('class a, class b');
- e.g.
toHaveAttr({attr, val})
- e.g.
expect(host.query('.zippy')).toHaveAttr({ attr: 'id', val: 'zippy' });
- e.g.
toHaveProp({prop, val})
- e.g.
expect(host.query('.checkbox')).toHaveProp({ prop: 'checked', val: true });
- e.g.
toHaveText(text)
- e.g.
expect('.zippy__content').toHaveText('Content');
- e.g.
expect('.zippy__content').toHaveText((text) => text.includes('..')
- e.g.
toHaveValue(value)
- e.g.
expect('.zippy__content').toHaveValue('value');
- e.g.
toHaveStyle(value)
- e.g.
expect(host.element).toHaveStyle({ backgroundColor: βrgba(0, 0, 0, 0.1)β });
- e.g.
toHaveData({data, val})
- e.g.
expect('.zippy__content').toHaveData({data: 'role', val: 'admin'});
- e.g.
toBeChecked()
- e.g.
expect('.checkbox').toBeDisabled();
- e.g.
toBeDisabled()
- e.g.
expect('.button').toBeDisabled();
- e.g.
toBeEmpty()
- e.g.
expect('div').toBeEmpty();
- e.g.
toBeEmpty()
- e.g.
expect('div').toBeEmpty();
- e.g.
toBeHidden()
- e.g.
expect('div').toBeHidden();
- e.g.
toBeSelected()
- e.g.
expect('element').toBeSelected();
- e.g.
toBeVisible()
- e.g.
expect('element').toBeVisible();
- e.g.
toBeFocused()
- e.g.
expect('input').toBeFocused();
- e.g.
toBeMatchedBy(selector)
- e.g.
expect('div').toBeMatchedBy('.js-something')
- e.g.
toHaveDescendant(selector)
- e.g.
expect('div').toHaveDescendant('.child')
- e.g.
toHaveDescendantWithText({selector, text})
- e.g.
expect('div').toHaveDescendantWithText({selector: '.child', text: 'text'})
- e.g.
Thanks goes to these wonderful people (emoji key):
I. Sinai π π π¨ |
Valentin Buryakov π» π€ |
Netanel Basal π» π§ |
---|
This project follows the all-contributors specification. Contributions of any kind welcome!