Unit Testing
Angular with Jasmine
Agenda
1. Jasmine
2. Arrange, Act, Assert
3. Unit testing Angular
- pipes
- services
- components
- attribute directives
4. Debugging unit tests
Jasmine
Typical structures
- describe()
- it()
- fdescribe(), fit()
- beforeEach()
- "expect()" asertions:
- toEqual()
- toBe()
- toHaveBeenCalled()
- toHaveBeenCalledWith()
- "expect().not" matcher
- spyOn()
Basic example
beforeEach()
- helps keeping your unit tests DRY where it's possible by grouping common arrangements
- works for each it() block which is inside of describe() it is defined in
- each describe() block can have it's own beforeEach()
beforeEach() example
Spies
- spyOn() global function
- tested method isolation
- stubbing return values of existing methods
-
can be asserted with additional matchers such as:
- toHaveBeenCalled()
- toHaveBeenCalledWith(), etc.
basic spyOn() examples
spyOn() Promises
spyOn() Observables
Arrange, Act, Assert
- put all of your unit test preparation/bootstrap code inside of Arrange section of unit test
- perform action, result of which you want to test inside of Act section of unit test
- check expected result of executed Action inside of Assert section of unit test
AAA example
Unit Testing Angular
Pipes
- easy to test without the Angular testing utilities
- has only one method transform()
- most pipes have no dependence on Angular other than the @Pipe metadata and an interface
Pipe example
Pipe testing example
Services
- services without dependencies can be tested as simple classes
- when having dependencies, Angular TestBed utility shall be used to mock all of them
- most pipes have no dependence on Angular other than the @Pipe metadata and an interface
Service example
import { Injectable } from '@angular/core';
// moment
import * as moment from 'moment';
// services
import { StateService } from '../state/state.service';
import { LoadHelperService } from './helpers/load-helper/load-helper.service';
// models
import { Granularity } from '../../models/granularity.model';
import { LoadSettings } from '../../models/load-settings.model';
@Injectable()
export class LoadService {
private loadObject: LoadConfig = {} as any;
constructor(
private loadHelper: LoadHelperService,
private stateService: StateService,
) { }
// ...
prefillConfig(loadSettings: LoadSettings): void {
if (loadSettings.startDate && loadSettings.endDate && typeof loadSettings.hoursDay === 'number') {
const start = moment(loadSettings.startDate).startOf('d');
const end = moment(loadSettings.endDate).endOf('d');
const hours = loadSettings.hoursDay;
const workDays = this.stateService.workingSettings().value.workingDays;
this.loadObject = {
...loadSettings,
id: loadSettings.id,
load: loadSettings.load,
startDate: moment(loadSettings.startDate).valueOf(),
endDate: moment(loadSettings.endDate).valueOf(),
workDays: this.loadHelper.getWorkDays(start, end, hours, workDays),
workWeeks: this.loadHelper.getWorkPeriod(LoadUnitType.week, start, end, hours, workDays),
workMonths: this.loadHelper.getWorkPeriod(LoadUnitType.month, start, end, hours, workDays),
totalHours: this.loadHelper.getWorkHoursForPeriod(start, end, hours, workDays),
};
this.loadObject.suggestedGranularity = this.suggestGranularity();
}
}
private suggestGranularity(): Granularity {
// ...
}
}
Service testing example
import { TestBed } from '@angular/core/testing';
import { LoadService } from './load.service';
// Models
import { LoadConfig } from '../../models/load-config.model';
import { LoadUnitType } from '../../models/load-unit-type.model';
import { Granularity } from '../../models/granularity.model';
// Services
import { StateService } from '../state/state.service';
import { LoadHelperService } from './helpers/load-helper/load-helper.service';
describe('LoadService', () => {
let loadHelperServiceSpy: jasmine.SpyObj<LoadHelperService>;
let stateServiceSpy: jasmine.SpyObj<StateService>;
let service: LoadService;
const testLoad: LoadConfig = {
id: '1',
startDate: 1,
endDate: 20,
hoursDay: 1,
totalHours: 0,
load: 10,
workMonths: [],
workWeeks: [],
workDays: [],
};
beforeEach(() => {
loadHelperServiceSpy = jasmine.createSpyObj<LoadHelperService>('LoadHelperService', [
'getWorkDays',
'getWorkPeriod',
'getWorkHoursForPeriod',
]);
stateServiceSpy = jasmine.createSpyObj<StateService>('StateService', ['workingSettings']);
TestBed.configureTestingModule({
providers: [
{
provide: LoadHelperService,
useValue: loadHelperServiceSpy,
},
{
provide: StateService,
useValue: stateServiceSpy,
},
LoadService,
],
});
service = TestBed.get(LoadService);
});
it('should exist', () => {
expect(service).toBeTruthy();
});
describe('prefillConfig()', () => {
it('prefills config work', () => {
// Arrange
const settings = {
user: { Id: '1', Name: 'test user' },
load: 100,
hoursDay: 5,
startDate: 100,
endDate: 200,
};
loadHelperServiceSpy.getWorkDays.and.returnValue([]);
loadHelperServiceSpy.getWorkPeriod.and.returnValue([]);
loadHelperServiceSpy.getWorkHoursForPeriod.and.returnValue(0);
stateServiceSpy.workingSettings.and.returnValue({
value: {
workingDays: {},
},
});
spyOn(service, 'suggestGranularity').and.returnValue(Granularity.days);
// Act
service.prefillConfig(settings);
// Assert
expect(service['loadObject'].workDays).toEqual([]);
expect(service['loadObject'].workWeeks).toEqual([]);
expect(service['loadObject'].workMonths).toEqual([]);
expect(service['loadObject'].totalHours).toEqual(0);
expect(service.suggestGranularity).toHaveBeenCalled();
expect(loadHelperServiceSpy.getWorkDays).toHaveBeenCalled();
expect(loadHelperServiceSpy.getWorkPeriod).toHaveBeenCalled();
expect(loadHelperServiceSpy.getWorkHoursForPeriod).toHaveBeenCalled();
});
});
});
Components
- can be tested as class
- DOM elements of component's template can be queried to test DOM interactions
- heavily depends on TestBed testing utility
- tested like service, but additionally utilizes declares property of TestBed.configureTestingModule, besides providers
- if component has router events subscription, include RouterTestingModule to automatically mock ActivatedRoute and Router injections
- if component has nested components, either mock each one of them, or use: schemas: [ NO_ERRORS_SCHEMA ]
Component example
@Component({
selector: 'cc-select',
templateUrl: './select.component.html',
styleUrls: ['./select.component.scss'],
providers: [SELECT_VALUE_ACCESSOR],
})
export class SelectComponent implements ControlValueAccessor, OnInit {
@Input() options: SelectOption[] = [];
selectedOption: SelectOption;
onChange;
onTouched;
ngOnInit() {
this.selectedOption = this.options[0];
}
selectOption(option: SelectOption): void {
this.onChange(option.value);
this.selectedOption = option;
}
writeValue(formFieldValue: string): void {
this.selectedOption = this.options.find((option) => option.value === formFieldValue);
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
}
Component testing example
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SelectComponent } from './select.component';
describe('SelectComponent', () => {
let component: SelectComponent;
let fixture: ComponentFixture<SelectComponent>;
const options = [
{ value: 'days', label: 'Days' },
{ value: 'weeks', label: 'Weeks' },
];
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
SelectComponent,
],
});
fixture = TestBed.createComponent(SelectComponent);
component = fixture.componentInstance;
component.options = options;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe(`selectOption()`, () => {
beforeEach(() => {
component.onChange = () => {};
spyOn(component, 'onChange');
});
it(`sets selectedOption variable`, () => {
component.selectOption(options[1]);
expect(component.selectedOption).toEqual(options[1]);
});
it(`calls onChange with option value`, () => {
component.selectOption(options[1]);
expect(component.onChange).toHaveBeenCalledWith(options[1].value);
});
});
describe(`writeValue()`, () => {
it(`sets selectedOption`, () => {
component.writeValue('days');
expect(component.selectedOption).toEqual(options[0]);
});
});
});
Attribute directives
- can be tested as class
- if there a need to test actual user interaction, it is recommended to test inside of mocked wrapper components
Attribute directive example
Attribute Directive testing example
import { ForbiddenKeyCodesDirective } from './forbidden-key-codes.directive';
describe('ForbiddenKeyCodesDirective', () => {
let directive: ForbiddenKeyCodesDirective;
beforeEach(() => {
directive = new ForbiddenKeyCodesDirective();
});
describe(`onKeyDown()`, () => {
const event = {
preventDefault() {},
stopImmediatePropagation() {},
key: '+',
} as any;
beforeEach(() => {
spyOn(event, 'preventDefault');
spyOn(event, 'stopImmediatePropagation');
});
it(`prevents event if key code is forbidden`, () => {
directive.ccForbiddenKeyCodes = ['+', '-'];
directive.onKeyDown(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopImmediatePropagation).toHaveBeenCalled();
});
it(`doesn't prevent event if key code is allowed`, () => {
directive.ccForbiddenKeyCodes = ['-'];
directive.onKeyDown(event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
});
});
Debugging
- by default Karma runs unit tests at http://0.0.0.0:9876 (http://localhost:9876)
- Karma runner browser UI - represents current test run and all possible error logs in a user friendly manner
- debug mode - can be accessed both by clicking Debug button at Karma UI or going by entering address http://0.0.0.0:9876/debug.html
- in Debug mode unit tests can be debugged via browser's of choice DevTools, same way as any Javascript application
Contacts
oleksandr.hutsulyak@techmagic.co
kami_lviv
Unit Testing Angular
By Oleksandr Hutsulyak
Unit Testing Angular
Overview of Angular
- 562