slides.com/gerardsans |  @gerardsans

Testing Angular Applications 4+

Google Developer Expert

Master of Ceremonies

Blogger

International Speaker

Spoken at 53 events in 18 countries

Angular Trainer

Community Leader

900

1K

Angular In Flip Flops

Introduction

Unit Tests

Assertion Libraries

Spies, Stubs

Test

Automation

Browsers

Coverage Reports

e2e Tests

Test

Runner

Testing Architecture

WebDriverJS

Selenium

Protractor

Overview

  • Does this method work?

 

  • Does this feature work?

 

  • Does this product work?

Unit tests

e2e Tests

Acceptance Tests

Angular Rapid Development

  • app.component.ts
  • app.component.spec.ts
  • app.e2e.ts

Filename conventions

$ npm run tests 
$ npm run e2e
        

Tools Online

Tools Online

Mocks vs Stubs

Mocks

  • Used to replace Complex Objects/APIs
  • Examples:
    • MockBackend
    • MockEventEmitter
    • MockLocationStrategy

Stubs

  • Used to cherry pick calls and change their behaviour for a single test
  • When to use:
    • control behaviour to favour/avoid certain path

Jasmine

Main Concepts

  • Suites  ​ describe('', function)
  • Specs  it('', function)
  • Expectations and Matchers
    • expect(x).toBe(expected)
    • expect(x).toEqual(expected)

Basic Test

let calculator = { 
  add: (a, b) => a + b 
};

describe('Calculator', () => {  
  it('should add two numbers', () => {
    expect(calculator.add(1,1)).toBe(2);
  })  
})

Setup and teardown

  • beforeAll (once)
    • beforeEach (many)
    • afterEach (many)
  • afterAll (once)

Useful techniques

  • Nesting suites and using scopes
  • Utility APIs
    • fail(msg), pending(msg)
  • Disable
    • xdescribe, xit
  • Focused
    • fdescribe, fit

Jasmine Spies

Test double functions that record calls, arguments and return values

Tracking Calls

describe('Spies', () => {
  let calculator = { add: (a,b) => a+b };
  
  it('should track calls but NOT call through', () => {
    spyOn(calculator, 'add'); 
    let result = calculator.add(1,1);
    expect(calculator.add).toHaveBeenCalled();
    expect(calculator.add).toHaveBeenCalledTimes(1);
    expect(calculator.add).toHaveBeenCalledWith(1,1);
    expect(result).not.toEqual(2);
  })  
})

Calling Through

describe('Spies', () => {
  it('should call through', () => {
    spyOn(calculator, 'add').and.callThrough(); 
    let result = calculator.add(1,1);
    expect(result).toEqual(2);

    //restore stub behaviour
    calculator.add.and.stub();
    expect(calculator.add(1,1)).not.toEqual(2);
  })  
})

Set return values

describe('Spies', () => {
  it('should return value with 42', () => {
    spyOn(calculator, 'add').and.returnValue(42);
    let result = calculator.add(1,1);
    expect(result).toEqual(42);
  }) 
  
  it('should return values 1, 2, 3', () => {
    spyOn(calculator, 'add').and.returnValues(1, 2, 3);
    expect(calculator.add(1,1)).toEqual(1);
    expect(calculator.add(1,1)).toEqual(2);
    expect(calculator.add(1,1)).toEqual(3);
  })   
})

Set fake function

describe('Spies', () => {
  it('should call fake function returning 42', () => {
    spyOn(calculator, 'add').and.callFake((a,b) => 42);
    expect(calculator.add(1,1)).toEqual(42);
  }) 
})

Error handling

describe('Spies', () => {
  it('should throw with error', () => {
    spyOn(calculator, 'add').and.throwError("Ups");
    expect(() => calculator.add(1,1)).toThrowError("Ups");
  }) 
})

Creating Spies

describe('Spies', () => {
  it('should be able to create a spy manually', () => {
    let add = jasmine.createSpy('add');
    add();
    expect(add).toHaveBeenCalled();
  }) 
})

// usage: create spy to use as a callback
//  setTimeout(add, 100);

Creating Spies

describe('Spies', () => {
  it('should be able to create multiple spies manually', () => {
    let calculator = jasmine.createSpyObj('calculator', ['add']);
    calculator.add.and.returnValue(42);

    let result = calculator.add(1,1);
    expect(calculator.add).toHaveBeenCalled();
    expect(result).toEqual(42);
  }) 
})

Angular Testing

Testing APIs

  • inject,TestBed
  • async
  • fakeAsync/tick

Setup

import { TestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule, 
  platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

TestBed.initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);

Testing a Service

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

@Injectable()
export class LanguagesService {
  get() {
    return ['en', 'es', 'fr'];
  }
}

Testing a Service

describe('Service: LanguagesService', () => {
  //setup
  beforeEach(() => TestBed.configureTestingModule({
    providers: [ LanguagesService ]
  }));

  //specs
  it('should return available languages', inject([LanguagesService], service => {
    let languages = service.get();
    expect(languages).toContain('en');
    expect(languages).toContain('es');
    expect(languages).toContain('fr');
    expect(languages.length).toEqual(3);
  });
});

refactoring inject

describe('Service: LanguagesService', () => {
  let service;

  beforeEach(() => TestBed.configureTestingModule({
    providers: [ LanguagesService ]
  }));
  beforeEach(inject([LanguagesService], s => {
    service = s;
  }));

  it('should return available languages', () => {
    let languages = service.get();
    expect(languages).toContain('en');
    expect(languages).toContain('es');
    expect(languages).toContain('fr');
    expect(languages.length).toEqual(3);
  });
});

refactoring beforeEach

describe('Service: LanguagesService', () => {
  let service;

  beforeEach(() => { 
    TestBed.configureTestingModule({
      providers: [ LanguagesService ]
    })}
    service = TestBed.get(LanguagesService);
  );

  it('should return available languages', () => {
    let languages = service.get();
    expect(languages).toContain('en');
    expect(languages).toContain('es');
    expect(languages).toContain('fr');
    expect(languages.length).toEqual(3);
  });
});

Asynchronous Testing

Asynchronous APIs

  • Jasmine.done
  • async
  • fakeAsync/tick

Http Service

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import 'rxjs/add/operator/map';

@Injectable()
export class UsersService {
  constructor(private http: HttpClient) { }

  public get() { 
    return this.http.get('./src/assets/users.json')
      .map(response => response.users);
  }
}

Testing Real Service 1/2

describe('Service: UsersService', () => {
  let service, http;

  beforeEach(() => TestBed.configureTestingModule({
    imports: [ HttpClientModule ],
    providers: [ UsersService ]
  }));

  beforeEach(inject([UsersService, HttpClient], (s, h) => {
    service = s;
    http = h;
  }));

  [...]

Testing Real Service 2/2

describe('Service: UsersService', () => {
  [...]

  it('should return available users (LIVE)', done => {
    service.get()
      .subscribe({
        next: res => {
          expect(res.users).toBe(USERS);
          expect(res.users.length).toEqual(2);
          done();
        }
      });
  });

});

Testing HttpMock 1/2

describe('Service: UsersService', () => {
  let service, httpMock;

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

  beforeEach(inject([UsersService, HttpTestingController], (s, h) => {
    service = s;
    httpMock = h;
  }));

  afterEach(httpMock.verify);

  [...]

Testing HttpMock 2/2

describe('Service: UsersService', () => {
  [...]

  it('should return available users', done => {
    service.get()
      .subscribe({
        next: res => {
          expect(res.users).toBe(USERS);
          expect(res.users.length).toEqual(2);
          done();
        }
      });
    httpMock.expectOne('./src/assets/users.json')
      .flush(USERS);
  });

});

Components Testing

Greeter Component

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

@Component({
  selector: 'greeter',   // <greeter name="Igor"></greeter>
  template: `<h1>Hello {{name}}!</h1>`
})
export class Greeter { 
  @Input() name;
}

Component Fixture

Component Test Context

  • Access to Component Instance
  • Access to Native DOM Element
  • Control Change Detection
  • Wait for changes/rendering

Testing Fixtures (sync)

describe('Component: Greeter', () => {
  let fixture, greeter, element, de;
  
  //setup
  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ Greeter ]
    });

    fixture = TestBed.createComponent(Greeter);
    greeter = fixture.componentInstance;
    element = fixture.nativeElement;
    de = fixture.debugElement;
  });
}

Testing Fixtures (async)

describe('Component: Greeter', () => {
  let fixture, greeter, element, de;
  
  //setup
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ Greeter ],
    })
    .compileComponents() // compile external templates and css
    .then(() => {
      fixture = TestBed.createComponent(Greeter);
      greeter = fixture.componentInstance;
      element = fixture.nativeElement;
      de = fixture.debugElement;
    });
 ))
});

Using Change Detection

describe('Component: Greeter', () => {
  it('should render `Hello World!`', async(() => {
    greeter.name = 'World';
    //trigger change detection
    fixture.detectChanges();
    fixture.whenStable().then(() => { 
      expect(element.querySelector('h1').innerText).toBe('Hello World!');
    });
  }));
}

Debug

Element

Angular Test Context

  • Access to DOM helpers
    • query, queryAll
    • By.all, By.css, By.directive
  • Access to Component Injector
  • Access to Component Instance

Dependency Injection Tree

source: blog

TestBed.get(AuthService)

de.injector.get(AuthService)

@NgModule({providers:[]})

@Component({providers:[]})

DebugElement

/* <greeter name="World">
     <div highlight> 
       <h1>Hello World!</h1>
     </div>
   </greeter> */ 

// single match: query
expect(de.query(By.all()).nativeElement.innerText).toBe('Hello World!');
expect(de.query(By.css('h1')).nativeElement.innerText).toBe('Hello World!');
expect(de.query(By.directive(Highlight)).nativeElement.innerText).toBe('Hello World!');
expect(de.query(By.directive(Highlight)).componentInstance.name).toBe('World');

// multiple match: queryAll
de.queryAll(By.all()).forEach(node => {
  if (node.nativeElement.matches('h1')) {
    expect(node.nativeElement.innerText).toBe('Hello World!');
  }
});

Using fakeAsync

describe('Component: Greeter', () => {
  it('should render `Hello World!`', fakeAsync(() => {
    greeter.name = 'World';
    //trigger change detection
    fixture.detectChanges();
    //execute all pending asynchronous calls
    tick();
    expect(element.querySelector('h1').innerText).toBe('Hello World!');
    expect(de.query(By.css('h1')).nativeElement.innerText).toBe('Hello World!');
  }));
}

Override Template

describe('Component: Greeter', () => {
  let fixture, greeter, element, de;
  
  //setup
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ Greeter ],
    })
    .compileComponents() // compile external templates and css
    .then(() => {
      TestBed.overrideTemplate(Greeter, '<h1>Hi</h1>');
      fixture = TestBed.createComponent(Greeter);
      greeter = fixture.componentInstance;
      element = fixture.nativeElement;
      de = fixture.debugElement;
    });
 ))
});

Shallow Testing

NO_ERRORS_SCHEMA

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

e2e Testing

End to End Testing

  • Test features instead of methods
  • Test as final user no Mocking
  • Run on multiple browsers
  • Complex to create/debug
  • Resource intensive (slow)

Protractor

  • Automate browser testing
  • WebDriverJS Wrapper
  • ControlFlow
    • Deals with async code (zones)

Protractor

  • Browser
    • browser.driver
    • browser.get(url)
  • DOM
    • by.id('user')
    • element(selector).getText()

Timeouts

browser.get(url)

//  Error: Timed out waiting for page to load after 10000ms

getPageTimeout: NEW_TIMEOUT_MS

browser.get(url, NEW_TIMEOUT_MS)

Angular timeout

// Timed out waiting for asynchronous Angular tasks 
//  to finish after 11 seconds.

allScriptsTimeout: NEW_TIMEOUT_MS

this.ngZone.runOutsideAngular(() => {
  setTimeout(() => {
    // Changes here will not propagate into your view.
    this.ngZone.run(() => {
      // Run inside the ngZone to trigger change detection.
    });
  }, REALLY_LONG_DELAY);
});

disable wait

browser.waitForAngularEnabled(false);
browser.get('/non-angular-page.html');

browser.waitForAngularEnabled(true);
browser.get('/angular-page.html');

spec timeout

// timeout: timed out waiting for spec to complete

jasmineNodeOpts: {
  defaultTimeoutInterval: NEW_TIMEOUT_MS
}

// it(title, fn, timeout)
it('should work with long timeout', () => { 
  service.isOnline().then(online => {
   expect(online).toBe(true)
  })
}, NEW_TIMEOUT_MS)

More?

Blog Post

Examples covering

  • Components, Directives, Pipes

  • Services, Http, MockBackend

  • Router, Observables

  • Spies

Testing Angular 4+ Applications

By Gerard Sans

Testing Angular 4+ Applications

In this talk, we will cover the most common testing scenarios to use while developing rock solid Angular Applications, like: Components, Services, Http and Pipes; but also some less covered areas like: Directives, the Router and Observables. We will provide examples for using TestBed, fixtures, async and fakeAsync/tick while recommending best practices.

  • 2,815