Unit testing in Angular

Andrei Antal

@andrei_antal

  • frontend engineer, since i can remember
  • currently doing consulting and training @JSLeague
  • web & JS technologies enthusiast
  • UX and accessibility passionate
  • perpetual learner

Frontend Developer

organizer for ngBucharest

@ngBucharest

groups/angularjs.bucharest

Hello!

Contents

  • Introduction
  • The Jasmine testing framework
  • Writing unit tests for Angular apps
    • Setting up tests
    • Isolated tests
    • Interaction tests
    • Integration tests
  • Upgrade your test tooling with Jest

Why test

What is Automated testing?

  • Unit test

  • Integration/functional tests

  • End to end tests

Benefits of Testing

  • Documented Intentions

  • Improved Design

  • Fewer Bugs into Production

  • No Regressions

  • Safer Refactoring

Unit testing tools

  • Test frameworks - where you write your test

    • Jasmine, Mocha, Tape

  • Test environment - where your tests are executed

    • browsers - Chrome, Firefox, etc

    • headless browsers - JSDom, PhantomJS, Puppeteer

  • Test runners - where you run your tests

    • Karma, Jest

Jasmine

  • A behavior-driven development framework for testing JavaScript code.

  • Create hierarchical suites of test - describe(‘', function)

  • The tests are written as specifications - it('', function)

  • Expectations and Matchers (built-in and custom) - expect(x).toBe(expected)

  • Spies - a test double pattern

  • Asynchronous operations support

Jasmine

describe("A resource",() => {
  const resource;

  beforeEach(() => {
    resource = new Resource();
    resource.allocateSpace();
  });

  afterEach(() => {
    resource.free();
  });

  it("should have allocated 100 units of space",() => {
    expect(resource.space).toEqual(100);
  });
});

Runs before each test (it clause) - good place to initialize test data

Runs after each test - good place to free any used resources

Expectation

Matcher

Jasmine

Jasmine Matchers

  • not
  • toBe
  • toEqual
  • toMatch
  • toBeDefined
  • toBeUndefined
  • toBeNull
  • toBeTruthy
  • toBeFalsy
  • toContain
  • toBeLessThan
  • toBeGreaterThan
  • toBeCloseTo
  • toThrow

Jasmine

class Person {
  helloSomeone(toGreet) {
    return `${this.sayHello()} ${toGreet}`;
  };
  sayHello() {
    return 'Hello';
  };
}

Spies - Test double functions that record calls, arguments and return values

Jasmine

describe('A Person', () => {
  let fakePerson;
  beforeEach( () => {fakePerson = new Person();})

  it('should call the sayHello() function', () => {
      spyOn(fakePerson, 'sayHello');
      fakePerson.helloSomeone('world');
      expect(fakePerson.sayHello).toHaveBeenCalled();
  });

  it('should greet the world', () => {
      spyOn(fakePerson, 'helloSomeone');
      fakePerson.helloSomeone('world');
      expect(fakePerson.helloSomeone).toHaveBeenCalledWith('world')
  });
});

Jasmine

// Actually calling the method
spyOn(fakePerson, 'sayHello').and.callThrough();

// Set a return value for a call
spyOn(fakePerson, 'sayHello').and.returnValue('Hello World');

// Set a return value for a call
spyOn(fakePerson, 'sayHello').and.returnValue('Hello World');

// Call a different function
spyOn(fakePerson, 'sayHello').and.callFake(
  (arguments, can, be, received) => ...);

// Get number of calls
spyOn(fakePerson, 'sayHello')
expect(fakePerson.sayHello.calls.count()).toBe(3)
fakePerson.sayHello.calls.reset() // reset the counts

// Create a "bare" spy
spy = jasmine.createSpy('whatAmI');
expect(spy).toHaveBeenCalled();

// Create a spy object
tape = jasmine.createSpyObj('tape', ['play', 'pause', 'stop', 'rewind']);

Async tests

describe('Asynchronous specs', () => {
  let value;
  beforeEach(() => {
    value = 0;
  });

  it('should support async execution', done => {
    setTimeout(() => {
      value++;
      expect(value).toBeGreaterThan(0);
      done();
    }, 1000);
  });
});

Call the done method when the async test finished

Karma

  • JavaScript test runner that integrates with a browser environment
  • Created by the AngularJS team
  • Configuration file to set:
    • browser launchers
    • test framework
    • reporters
    • preprocessors

Writing good tests

Structuring tests

  • Arrange all necessary preconditions and inputs.

  • Act on the object or method under test.

  • Assert that the expected results have occurred.

Structuring tests

  • DRY  vs DAMP

 

TECHNIQUES

  • Remove less interesting setup to beforeEach()
  • Keep critical setup within the it()
  • Include all of the "Act" and "Assert" test parts are in the it() clause

RULES

  • Repeat yourself if necessary to make it easier to read
    • A test should be a complete story, all within the it()
    • You shouldn’t need to look around much to understand the test
  • Minimize logic out of tests (what will test the tests?)

Overly DRY Test

describe("Hero Detail Component", function() {
  var heroDetCmp;

  beforeEach(function() {
    heroDetCmp = createComponent();
    heroDetCmp.ngOnInit();
  });

  describe('ngOninit' function() { 

    it("should set the hero", function() {
      expect(heroDetCmp.hero).toBeDefined()
    });

    it("should set the heroId", function() {
      expect(heroDetCmp.heroId).toBe(3));
    });
  });
});

DAMP Test

 
describe("Hero Detail Component", function() {
  var heroDetCmp;

  beforeEach(function() {
    heroDetCmp = createComponent();
  });

  describe('ngOninit' function() { 

    it("should set the hero", function() {
      heroDetCmp.ngOnInit();

      expect(heroDetCmp.hero).toBeDefined()
    });

    it("should set the heroId", function() {
      heroDetCmp.ngOnInit();

      expect(heroDetCmp.heroId).toBe(3));
    });
  });
});

DRY vs DAMP

 

Unit testing in Angular

How deep to test?

  • Isolated tests: only the class, mocking everything

  • Integration tests: compiling components and using the injector

    • Shallow: mock out related components

    • Deep: include all components

Isolated Tests

Isolated test

@Component({
  template: `
    <h1>{{message}}</h1>
    <button (click)="clearMessage">Clear</button>
  `
})

export class GreetComponent {
  public message = '';

  constructor() {}

  setMessage(newMessage: string) {
      this.message = newMessage;
  }

  clearMessage() {
    this.message = '';
  }
}

Isolated test

import {GreetComponent} from './greet.component';

describe('Testing message state in greet.component', () => {
  let greetComponent: GreetComponent;

  beforeEach(() => {
    greetComponent = new GreetComponent();
  });

  it('should set new message', () => {
    greetComponent.setMessage('Testing');
    expect(greetComponent.message).toBe('Testing');
  });

  it('should clear message', () => {
    greetComponent.clearMessage();
    expect(greetComponent.message).toBe('');
  });
});

Integrated tests

Angular testing utilities

Angular Testing Utilities

  • TestBed - a harness for compiling components

  • inject() - provides access to injectables

  • waitForAsync() & fakeAsync() - async Zone control

describe('Testing GreetComponent', () => {
  let component: GreetComponent;

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ HeroComponent ],
      imports: [ ... ],
      providers: [ ... ],
      schemas: [ ... ]
    });

  });
});

TestBed configures a temporary NgModule for testing

TestBed

TestBed

describe('Testing GreetComponent', () => {
  let component: GreetComponent;

  beforeEach(() => {
    TestBed.configureTestingModule({...});
    
    TestBed.overrideComponent(GreetComponent, {
      set: {
        template: '<div>Overridden template here</div>'
        // ...
      }
    });
  });
});

TestBed configurations can be overriden

Component Fixture

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

  beforeEach(() => {
    TestBed.configureTestingModule({...});

    fixture = TestBed.createComponent(GreetComponent);
  });
});
  • creates an instance of the component to test
  • returns a component fixture
    • access to the component instance
    • access to Native DOM Element
    • control Change Detection
  • closes current TestBed configurations

Component Fixture

  • Access to the component, its DOM and change detection

    • componentInstance - the instance of the component created by TestBed

    • debugElement - provides insight into the component and its DOM element

    • nativeElement - the native DOM element at the root of the component

    • detectChanges() - trigger a change detection cycle for the component

    • whenStable() - returns a promise that resolves when the fixture is stable

Change detection

describe('Testing message state in greet.component', () => {
  beforeEach(...)
  
  it('should display original greet', () => {
    fixture.detectChanges();
    expect(element.textContent).toContain(component.message);
  });
})
  • tells Angular to perform change detection
  • TestBed.createComponent() does not trigger change detection
import { ComponentFixtureAutoDetect } from '@angular/core/testing';

TestBed.configureTestingModule({
  providers: [
    { provide: ComponentFixtureAutoDetect, useValue: true }
  ]
})

Configure automatic change detection

Change detection

describe('Testing message state in greet.component', () => {
  beforeEach(...)
  
  it('should display original greet', () => {
    fixture.detectChanges();
    expect(element.textContent).toContain(component.message);
  });

  it('should display a different test greet', () => {
    component.setMessage('Test Greet');
    fixture.detectChanges();
    expect(element.textContent).toContain('Test Greet');
  });

  it('should clear the message', () => {
    fixture.detectChanges();
    component.clearMessage();
    fixture.detectChanges();
    expect(element.textContent).toBe('');
  });
})

Debug Element

  • Insights into the component's DOM representation

    • parent / children - the immediate parent or children of this DebugElement

    • query(predicate) - search for one descendant that matches

    • queryAll(predicate) - search for many descendants that match

    • injector - this component's injector

    • listeners - this callback handlers for this component's events and @Outputs

    • triggerEventHandler(listener) - trigger an event or @Output

Querying the DOM

  • nativeElement provides:

    • querySelector(cssSelector)

  • debugElement provides:

    • query(predicate)

    • queryAll(predicate)

  • predicates can be created by helpers:

    • By.css(selector)

    • By.directive(DirectiveType)

Interacting with the DOM

  • nativeElement - can't use outside the browser
    • dispatchEvent
    • textContent
  • debugElement - doesn't have access to textContent
    • triggerEventHandler
    • properties
    • attributes
    • classes
    • styles

Dependency Injection

  • Gets services from the root injector
  • Can be placed in beforeEach or it blocks:
let heroService;
beforeEach(() => {
  heroService = TestBed.inject(HeroService);
}));
TestBed.inject(Type)

Deep Integration Tests

Deep Component Testing

  • Nested Components need to be tested too
  • Shallow testing (mocking all children) is not enough
  • Deep tests check that
    • the parent is rendering the children correctly
    • the child is receiving the correct values in its inputs
    • the parent handles output events correctly

Testing components with inputs and outputs

Accessing child components

  •  Search for instances of the child component:

movieElements =        
       fixture.debugElement.queryAll(By.directive(MovieItemComponent));

 

  • Check the value of @Input properties on the child component :

​expect(movieElements[0].componentInstance.movie).toBe(MOVIES[0]);

 

  • Trigger @Output bindings:

​movieElements[0].triggerEventHandler('delete', null); 

Testing inputs and outputs

  • Goal - Test if inputs and outputs work correctly

  • Approaches

    • test as a standalone component

    • test inside a container component

@Component({
  selector: 'greet-message',
  template: `<div class="greet">
    {{message}} 
    <button (click)="handleClick()">LIKE</button>
  </div>`
})
export class GreetComponent {
  @Input() message: string;
  @Output() onLiked = new EventEmitter<string>();
  
  handleClick() { 
    this.onLiked.emit(this.message); 
  }
}

Testing inputs and outputs

Inputs - set a value to the input property on the component object

it('should display greeting', () => {
  expect(greetElementText.nativeElement.textContent).toBe(expectedMessage);
});

Outputs - subscribe to EventEmitter, trigger click event

it('should raise selected event when clicked', () => {
  let likedMessage: string;
  component.onLiked.subscribe((message: string) => {
    likedMessage = message;
  });

  greetElementButton.triggerEventHandler('click', null);
  expect(likedMessage).toBe(expectedMessage);
});

Testing inputs and outputs

Test in host component - create an on the fly component to test the target component

@Component({
  template: `
    <greet-message [message]="greet" (onLiked)="handleLike($event)">
    </greet-message>
`,
})
class TestHostComponent {
  greet = 'Wassuuuup?!?';
  
  handleLike(message: string) {
    this.greet = 'New greet';
  }
}

Testing inputs and outputs

describe('Test input/output for components', () => {
  let fixture: ComponentFixture<TestHostComponent>;
  let testHost: TestHostComponent;
  let greetElementText: DebugElement;
  let greetElementButton: DebugElement;

  beforeEach(
    waitForAsync(() => {
      TestBed.configureTestingModule({
        declarations: [GreetComponent, TestHostComponent],
      }).compileComponents();
    }),
  );

  beforeEach(() => {
    fixture = TestBed.createComponent(TestHostComponent);
    testHost = fixture.componentInstance;
    greetElementText = fixture.debugElement.query(By.css('.greet span'));
    greetElementButton = fixture.debugElement.query(By.css('.greet button'));
    fixture.detectChanges();
  });
...
});

Declare both components in the TestBed

Only create the test host component

Testing inputs and outputs

Inputs - set a value to the input property on the component object

it('should display greeting', () => {
  const expectedGreet = testHost.greet;
  expect(greetElementText.nativeElement.textContent).toBe(expectedGreet);
});

Outputs - subscribe to EventEmitter, trigger click event

it('should raise selected event when clicked', () => {
  let likedMessage: string;
  component.onLiked.subscribe((message: string) => {
    likedMessage = message;
  });

  greetElementButton.triggerEventHandler('click', null);
  expect(likedMessage).toBe(expectedMessage);
});

Testing with depencencies

Testing inputs and outputs

import {Component} from '@angular/core';
import {UserService} from './user.service';

@Component({
  selector: 'greet-message',
  template: '<h1>{{message}}</h1>'
})
export class GreetComponent {
  public message = 'Hello';

  constructor(private userService: UserService) { }

  ngOnInit(): void {
    this.message = this.userService.isLoggedIn ?
      'Welcome, ' + this.userService.user.name :
      'Please log in.';
  }
}

The component injects a service

Stubbing the service

userServiceStub = {
  isLoggedIn: true,
  user: { name: 'Test User'}
};
    

TestBed.configureTestingModule({
   declarations: [ WelcomeComponent ],
   // providers: [ UserService ]
   providers: [{
     provide: UserService, 
     useValue: userServiceStub 
   }]
});
    

userService = TestBed.inject(UserService);

A stub is just a replacement for the actual service. We can only declare the properties/methods that will be in use for the current test suite

Always access service from injector, never directly (userService Stub) - the injected service is a clone

Mocking HTTP

Mocking HTTP

describe('HttpClient testing', () => {
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [ HttpClientTestingModule ]
    });

    httpTestingController = TestBed.inject(HttpTestingController);
  });

});

Mock the Http service

  • HttpClientTestingModule - don't use the regular HttpClientModule

  • HttpTestingController  - used to control the HTTP calls

Mocking HTTP

it('should get the proper todo\'s', () => {
  const testData: Data = [{todo: 'Test Data'}];
  
  // when call is made, observable emits 
  component.todos$.subscribe(data =>
    expect(data).toEqual(testData)
  );
  
  // Match request URL's
  const req = httpTestingController.expectOne('/data');

  // Assert request method
  expect(req.request.method).toEqual('GET');
  
  // respond with mock data
  req.flush(testData);
 
  // make sure no outstanding calls
  httpTestingController.verify();
});

Asynchronicity in Unit Tests

Zone.js

Intercepts and tracks asynchronous callbacks

  • Intercept asynchronous task scheduling
  • Wrap callbacks for error-handling and zone tracking across async operations.
  • Provide a way to attach data to zones
  • Provide a context specific last frame error handling

 

Configured by rules (or specs)

  • AsyncTestZoneSpec - rules for async test zones
  • FakeAsyncTestZoneSpec - rules for fake async test zones

Components with async dependencies

@Component({
  selector: 'greet-message',
  template: '<h1>{{message}}</h1>'
})
export class GreetComponent {
  public message = 'Hello';

  constructor(private greetingsService: GreetingsService) { }

  ngOnInit() {
      this.greetingsService.getGreets()
        .then(greets => this.message = greets[0]);
  }
}

Components with async dependencies

it('should show message (async)',
  waitForAsync(() => {
    fixture.detectChanges();

    fixture.whenStable().then(() => {
      fixture.detectChanges();
      expect(element.textContent)
        .toBe(testGreetings[0]);
    });
  }),
);

it('should show message (fakeAsync)',
  fakeAsync(() => {
    fixture.detectChanges();
    tick();
    fixture.detectChanges();
    expect(element.textContent)
      .toBe(testGreetings[0]);
  }),
);

running the test in a special async test zone

  • no access to promises called in component
  • whenStable() - called when all async operations in the test complete (still need to detect changes)

allows for a more linear coding style, no need for then()-s

can only be called inside a fakeAsync, “waits” for all async operations to finish

Jest

Jest

  • A testing platform, created by Facebook, mostly used in the React community
  • The API is very close to the Jasmine API
  • It's VERY fast
  • Uses JSdom out of the box
  • Snapshot testing
  • Code coverage out of the box
  • Watch mode (only run tests for files affected by git chenges)
npm install --save-dev jest

Jest

Behaviour Driven Development (BDD) Testing Framework

  • Create hierarchical suites of test - describe('', function)

  • The tests are written as specifications - it('', function)

  • Expectations and Matchers (built-in and custom) - expect(x).toBe(expected)

  • Spies - a test double pattern

  • Asynchronous operations support

Jest

describe("A resource",() => {
  const resource;

  beforeEach(() => {
    resource = new Resource();
    resource.allocateSpace();
  });

  afterEach(() => {
    resource.free();
  });

  it("should have allocated 100 units of space",() => {
    expect(resource.space).toEqual(100);
  });
});

Runs before each test (it clause) - good place to initialize test data

Runs after each test - good place to free any used resources

Expectation

Matcher

Mocks and Spys

const mockObj = {
  mockFn: jest.fn()
};

mockObj.mockFn('test');

expect(mockObj.mockFn).toHaveBeenCalled();
expect(mockObj.mockFn).toHaveBeenCalledTimes(1);
expect(mockObj.mockFn).toHaveBeenCalledWith('test');
const mockObj = {
  mockFn: (value) => { this.prop = value; },
  prop: false
};

mockObj.mockFn(true);

const spy = jest.spyOn(mockObj, 'mockFn');

expect(spy).toHaveBeenCalled();
expect(mockObj.prop).toBe(true);

Mocking implementatons

Spying on methods

Migration from Jasmine

  • jasmine.createSpyObj('name', ['key']) --> jest.fn({key: jest.fn()})
  • jasmine.createSpy('name') --> jest.fn()
  • and.returnValue() --> mockReturnValue()
  • spyOn(...).and.callFake(() => {}) --> jest.spyOn(...).mockImplementation(() => {})
  • jasmine.any, jasmine.objectContaining, etc. --> expect.any, expect.objectContaining

Angular Unit testing

By Andrei Antal

Angular Unit testing

  • 1,065