Angular Testing Patterns - TestBed

Spec files are automatically generated by Angular 5’s CLI but most projects leave them empty. Why not actually write some tests? This post covers some useful patterns to make the whole process as painless as possible.

I’ve recently been working on a team which has some downright amazing leadership on the testing side. As a result I’ve had to raise my testing game to a level I’ve not been at before. During the process the team developed some testing patters which might be useful to the general populace. Here they are:

  1. Keep the number of test bed tests to a minimum.
  2. Leverage Observable as a testing seam
  3. Leverage monkey patching as a testing seam
  4. No matter what anybody says e2e tests still need sleeps

I’m going to split this post into parts to keep the posts relatively short.

Test Bed

Angular 2 introduced the idea of the TestBed which is basically a way of testing out a component with a “real” DOM behind it. There is support for injecting services either real or mock into your component as well as binding your component’s model to the template. TestBed tests are the default type of test generated by the angular-cli when you create a new component. They are great and can be used to test a component much more thoroughly than testing with isolated tests alone.

The issue with them is that they tend to be quite slow to run. The interaction with the DOM and the setup of an entire dependency injection instance per test adds several hundred milliseconds for every TestBed test run. Just watching the test counter tick up in my command-line test reporter I can easily see when TestBed tests are encountered as the counter slows right now. The added time may not be huge in isolation but if we add 500ms (pretty conservative in my experience) per test on a collection of 1500 tests (pretty small project) then we’re talking twelve and a half minutes. Angular testing is already glacial so adding this coupled with the Karma runner’s inability to selectively run tests and you’re really in trouble.

Testing should be lightening fast because you want the feedback loop to be as tight as possible. That’s why I’m such a big fan of Live Unit Testing. My mantra is that you should be able to hold your breath during a test run without feeling uncomfortable (this makes former pearl divers well adapted to being Angular developers). Most of the functionality that we test on a component doesn’t need to be tested using a full featured TestBed. Any functions which mutate the state or call out to other services can be written without the need for the TestBed. Many of my components contain just two TestBed tests: one to check the component can be created and one to check it can be initted. These two test generally catch any typos in the template which is a big source of errors as the TypeScript compiler doesn’t catch things in there. In the init test you can also check that appropriate bindings are in place. It is faster to have a test which tests a bunch of properties at once than one test per property.

This being said there are still plenty of time when TestBed tests do come in useful, typically any time you’re building a complex user interface and want to validate that it works cross browsers. I’m certainly not saying don’t use TestBed at all but rather that its use should be limited and isolation tests should be favoured.

Let’s take a look at an example test which we can migrate away from the TestBed.

This component does some simple addition. Left side + right side = answer, unless the answer is less than 0 then ‘Value too small’:

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

@Component({
selector: 'app-math-component',
templateUrl: './math-component.component.html',
styleUrls: ['./math-component.component.css']
})

export class MathComponent implements OnInit {

model: MathComponentModel = {
left: 0,
right: 0,
answer: 0
};

constructor() { }

ngOnInit() {
}

update() {
this.model.answer = this.model.left + this.model.right;
if (this.model.answer < 0) {
this.model.answer = 'Value too small';
}
}
}

export class MathComponentModel {
left: number;
right: number;
answer: number | string;
}

The template for it is equally simple

<p>
<input [(ngModel)]="model.left" (change)="update()" type="number"/>
<input [(ngModel)]="model.right" (change)="update()" type="number"/>=
<input [(ngModel)]="model.answer" disabled>
</p>

A fully testbed test for this might look like

describe('MathComponent', () => {
let component: MathComponent;
let fixture: ComponentFixture<MathComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MathComponent],
imports: [FormsModule]
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(MathComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});

it('should add up two numbers', fakeAsync(() => {
const compiled = fixture.debugElement.nativeElement;
compiled.querySelector('[data-autom=left]').value = '2';
compiled.querySelector('[data-autom=left]').dispatchEvent(new Event('input'));

compiled.querySelector('[data-autom=right]').value = '3';
compiled.querySelector('[data-autom=right]').dispatchEvent(new Event('input'));

component.update();
fixture.detectChanges();
tick();
expect(compiled.querySelector('[data-autom=answer]').value).toBe('5');
}));

it('should set answer to "Value too small" if answer < 0', fakeAsync(() => {
const compiled = fixture.debugElement.nativeElement;
compiled.querySelector('[data-autom=left]').value = '2';
compiled.querySelector('[data-autom=left]').dispatchEvent(new Event('input'));

compiled.querySelector('[data-autom=right]').value = '-3';
compiled.querySelector('[data-autom=right]').dispatchEvent(new Event('input'));

component.update();
fixture.detectChanges();
tick();

expect(compiled.querySelector('[data-autom=answer]').value).toBe('Value too small');
}));
});

A couple of things to point out here: the first is that there is quite a bit of magic to interact with input boxes on the page. The second thing is that compiled component tests seem to be quite slow, doubly so if you haven’t made your modules highly granular. Much of the testing here could be handled by testing the model rather than the rendering. A testbed test is still needed to check the rendering once but after that we’re good with simpler tests.

describe('MathComponent bindings', () => {
let component: MathComponent;
let fixture: ComponentFixture<MathComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [MathComponent],
imports: [FormsModule]
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(MathComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should init', () => {
expect(component).toBeTruthy();
});

it('should create', fakeAsync(() => {
component.model.answer = 5;
component.model.answer = 5;
component.model.left = 3;
component.model.right = 2;

fixture.detectChanges();
tick();
const compiled = fixture.debugElement.nativeElement;
expect(compiled.querySelector('[data-autom=answer]').value).toBe(component.model.answer.toString());
expect(compiled.querySelector('[data-autom=left]').value).toBe(component.model.left.toString());
expect(compiled.querySelector('[data-autom=right]').value).toBe(component.model.right.toString());
}));
});
describe('MathComponent', () => {
it('should add up two numbers', () => {
const component = new MathComponent();
component.model.left = 1;
component.model.right = 2;
component.update();
expect(component.model.answer).toBe(3);
});

it('should set answer to Value too small if answer < 0', () => {
const component = new MathComponent();
component.model.left = 1;
component.model.right = -2;
component.update();
expect(component.model.answer).toBe('Value too small');
});
});

The advantage here is that the tests are simpler and run faster. We also don’t have to worry about fiddling with fake async or ticks.

In the next article we’ll visit how we can use Observables, which are pretty popular in angular, as a seam to help write tests.