Volodymyr Bilyachat

Melbourne, Australia

Component unittesting inside of angular project.

06 November 2018

Introduction

Today is public holiday in Melbourne, and finally I found some time to write small blog post.

Long time I was doing unit tests in a wrong way, yes I did wrote tests but problem was their complexity, recent days I found a way how to do it more efficiently.

What was wrong?

Integration testing instead of unit testing. This is what went wrong, I did setup helper classes which were importing all modules and instead of testing one unit I did test all components at once. And as result most of my tests were testing submodules and integrations. Sometimes it was good but in most cases it was overkill.

Solution?

Do unit testing :)

Firstly if component contains component which is no declared in TestBed then you would receive an error.

Failed: Template parse errors:
'app-newcomponent' is not a known element:
1. If 'app-newcomponent' is an Angular component, then verify that it is part of this module.
2. If 'app-newcomponent' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message. ("
    <div>Main component</div>
    [ERROR ->]<app-newcomponent (buttonClick)="childComponentButtonClicked=$event"></app-newcomponent>"): ng:///DynamicTestModule/AppComponent.html@2:4

To avoid this problem lets update TestBed configuration and set NO_ERRORS_SCHEMA

beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [AppComponent],
    schemas: [NO_ERRORS_SCHEMA]
  }).compileComponents();
}));

Settng up NO_ERRORS_SCHEMA allow me to unittest only one component.

Mock Angular component inside of TestBed

But what to do if we want to mock Angular component? You can use simple mock helper.

import { Component, Type, EventEmitter } from '@angular/core';

export function MockComponent<T>(
  options: Component,
  extraOptions?: Partial<T>
): Type<T> {
  const component: Component = {
    selector: options.selector,
    template: options.template,
    inputs: options.inputs,
    outputs: options.outputs || [],
    exportAs: options.exportAs || ''
  };
  class ComponentMock {}

  component.outputs.forEach(method => {
    ComponentMock.prototype[method] = new EventEmitter<any>();
  });

  if (extraOptions) {
    Object.keys(extraOptions).forEach(key => {
      ComponentMock.prototype[key] = extraOptions[key];
    });
  }

  return Component(component)(MockComponent as any);
}

Then inside of test we just declare it

const mock = MockComponent<NewcomponentComponent>(
  {
    template: `<a (click)="buttonClick.next(true)">Now I am</a>`,
    selector: 'app-newcomponent',
    outputs: ['buttonClick']
  },
  {
    someAction: () => {
      console.log('some action mock');
    },
    someProperty: 2
  }
);

beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [AppComponent, mock],
    schemas: [NO_ERRORS_SCHEMA]
  }).compileComponents();
}));

Source code of full example

app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <div>Main component</div>
    <app-newcomponent (buttonClick)="childComponentButtonClicked=$event"></app-newcomponent>`,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'nit';
  childComponentButtonClicked = false;
}

newcomponent.ts

import { Component, OnInit, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-newcomponent',
  template: `
    <button (click)="buttonClick.emit(true)">I will emit event</button>`,
  styleUrls: ['./newcomponent.component.css']
})
export class NewcomponentComponent implements OnInit {
  @Output()
  buttonClick = new EventEmitter();
  constructor() {}

  get someProperty() {
    return 0;
  }

  someAction() {}

  ngOnInit() {}
}

spec.ts

import { TestBed, async, fakeAsync, tick } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { Component, Type, NO_ERRORS_SCHEMA } from '@angular/core';
import { MockComponent } from './mock.helper';
import { NewcomponentComponent } from './newcomponent/newcomponent.component';
import { By } from '@angular/platform-browser';
import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing';

describe('AppComponent without mock', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [AppComponent],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
  }));

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  });

  it('Child component is not loaded so we dont have button', () => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('button')).toBeFalsy();
  });
});

describe('AppComponent WITH mock', () => {
  const mock = MockComponent<NewcomponentComponent>(
    {
      template: `<a (click)="buttonClick.next(true)">Now I am</a>`,
      selector: 'app-newcomponent',
      outputs: ['buttonClick']
    },
    {
      someAction: () => {
        console.log('some action mock');
      },
      someProperty: 2
    }
  );

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [AppComponent, mock],
      schemas: [NO_ERRORS_SCHEMA]
    }).compileComponents();
  }));
  it('Child component is mocked and loaded so we can click button', fakeAsync(() => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;

    fixture.debugElement
      .query(By.css('app-newcomponent a'))
      .triggerEventHandler('click', null);
    tick();
    expect(compiled.querySelector('button')).toBeFalsy();

    expect(fixture.componentInstance.childComponentButtonClicked).toBe(true);
  }));
});