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

Made with Slides.com