Advanced Testing Recipes v7+

Advanced Tesing Recipes v7+

Gerard Sans

@gerardsans

Gerard Sans

@gerardsans

 

SANS

GERARD

Google Developer Expert

Google Developer Expert

International Speaker

Spoken at 110 events in 27 countries

Blogger

Blogger

Community Leader

900

1.6K

Trainer

Master of Ceremonies

Master of Ceremonies

#cfpwomen

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

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

Angular CLI Conventions

$ npm run tests 
$ npm run e2e
        

stackblitz.com

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

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);
  });
});

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;
}

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!');
      expect(de.query(By.css('h1')).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 ]
  })
});

Marble Testing

  • Test RxJS Observables
  • Features
    • ​Test cold Observables
    • Test hot Observables
    • Control values and errors
    • Control time (TestScheduler)

Cold Observable Example

import {marbles} from "rxjs-marbles";

describe("Cold Observables", () => {
  describe("basic marbles", () => {
    it("should support simple values as strings", marbles(m => {
      const input    = m.cold("--1--1|");
      const expected = m.cold("--1--2|");
      
      const output = input.pipe(map(x => x));
      m.expect(output).toBeObservable(expected);
    }));
  });
});

toBeObservable Output

 const input    = m.cold("--1--1|");
 const expected = m.cold("--1--2|");

...

Cold Observables basic marbles should support simple values as strings

Error: 
Expected 
	{"frame":20,"notification":{"kind":"N","value":"1","hasValue":true}}
	{"frame":50,"notification":{"kind":"N","value":"1","hasValue":true}}
	{"frame":60,"notification":{"kind":"C","hasValue":false}}
	
to deep equal 
	{"frame":20,"notification":{"kind":"N","value":"1","hasValue":true}}
	{"frame":50,"notification":{"kind":"N","value":"2","hasValue":true}}
	{"frame":60,"notification":{"kind":"C","hasValue":false}}

Using Symbols

import {marbles} from "rxjs-marbles";

describe("Cold Observables", () => {
  describe("basic marbles", () => {
    it("should support simple values as strings", marbles(m => {
      const values   = { a: 1, b: 2 };
      const input    = m.cold("--a--a|", values);
      const expected = m.cold("--b--b|", values);
      
      const output = input.pipe(map(x => x+1));
      m.expect(output).toBeObservable(expected);
    }));
  });
});

Error Handling (1/2)

import {marbles} from "rxjs-marbles";

describe("Cold Observables", () => {
  describe("basic marbles", () => {
    it("should support custom errors (symbols)", marbles(m => {
      const values   = { a: 1 };
      const input    = m.cold("--a#", values, new Error('Ups'));
      const expected = m.cold("--a#", values, new Error('Ups'));
      
      const output = input.pipe(map(x => x));
      m.expect(output).toBeObservable(expected);
    }));
  });
});

Error Handling (2/2)

import {marbles} from "rxjs-marbles";

describe("Cold Observables", () => {
  describe("basic marbles", () => {
    it("should support custom Observables", marbles(m => {
      const input    = throwError(new Error('Ups')));
      const expected = m.cold("#", undefined, new Error('Ups'));
      
      const output = input.pipe(map(x => x));
      m.expect(output).toBeObservable(expected);
    }));
  });
});

Hot Observable Example

import {marbles} from "rxjs-marbles";

describe("Hot Observables", () => {
  describe("Subscriptions", () => {
    it("should support basic subscriptions", marbles(m => {
      const values   = { a: 1, b: 2 };
      const input    = m.hot( "--a^-a|", values);
      const expected = m.cold(   "--b|", values);
      
      const output = input.pipe(map(x => x+1));
      m.expect(output).toBeObservable(expected);
    }));
  });
});

Subscription Test

import {marbles} from "rxjs-marbles";

describe("Hot Observables", () => {
  describe("Subscriptions", () => {
    it("should support testing subscriptions", marbles(m => {
      const values   = { a: 1, b: 2 };
      const input    = m.hot( "--a^-a|", values);
      const subs     =           "^-!";
      const expected = m.cold(   "--b|", values);
      
      const output = input.pipe(map(x => x+1));
      m.expect(output).toBeObservable(expected);
      m.expect(input).toHaveSubscriptions(subs);
    }));
  });
});

toHaveSubscriptions Output

  const values   = { a: 1, b: 2 };
  const input    = m.hot( "--a^-a|", values);
  const subs     =           "^-!";
  const expected = m.cold(   "--b|", values);

...

Hot Observables Subscriptions should support complex subscriptions

Error: 
Expected 
	{"subscribedFrame":0,"unsubscribedFrame":30}
	
to deep equal 
	{"subscribedFrame":0,"unsubscribedFrame":20}

Testing ngrx

Testing Actions

// actions/spinner.actions.spec.ts
import { SpinnerShow, SpinnerHide, SpinnerActionTypes } from './spinner.actions';

describe('SpinnerShow', () => {
  it('should create an instance', () => {
    const action = new SpinnerShow();
    expect(action).toBeTruthy();
    expect(action.type).toBe(SpinnerActionTypes.Show);
  });
});

describe('SpinnerHide', () => {
  it('should create an instance', () => {
    const action = new SpinnerHide();
    expect(action).toBeTruthy();
    expect(action.type).toBe(SpinnerActionTypes.Hide);
  });
});

Testing Reducers (1/2)

// reducers/spinner.reducer.spec.ts
describe('Spinner Reducer', () => {

  it('should return the initial state', () => {
    const action = { type: '🚀' } as any;
    const result = reducer(initialState, action);
    expect(result).toBe(initialState);
    expect(initialState).toBe(false);
  });

});

Testing Reducers (2/2)

// reducers/spinner.reducer.spec.ts
describe('Spinner Reducer', () => {

    it('should handle SpinnerShow Action', () => {
      const action = new SpinnerShow();
      const result = reducer(initialState, action);
      expect(result).toBe(true);
    });

    it('should handle SpinnerHide Action', () => {
      const action = new SpinnerHide();
      const result = reducer(initialState, action);
      expect(result).toBe(false);
    });

});

Testing Entity Selectors (1/2)

// reducers/selectors.spec.ts
describe('Selectors', () => {
  let adapter : EntityAdapter<Todo>;
  let initialState : any;
  let t: Array<Todo> = [
    { id: 1, text: 'Learn French', completed: false },
    { id: 2, text: 'Try Poutine', completed: true }
  ];

  beforeAll(() => {
    adapter = createEntityAdapter<Todo>();
    initialState = adapter.getInitialState();
  })

})

Testing Entity Selectors (2/2)

// reducers/selectors.spec.ts
describe('Selectors', () => {

  describe('getFilteredTodos', () => {
    it('should return only active todos', () => {
      const todos = adapter.addMany(t, initialState);
      const state: TodosState = {
        todos,
        currentFilter: "SHOW_ACTIVE"
      }
      expect(getFilteredTodos(state)).toEqual([t[0]]);
    })
  })

})

Testing Component (1/2)

// loading/loading.component.spec.ts
describe('LoadingComponent', () => {
  let fixture, loading, element, de;

  // setup
  beforeEach(() => {                        
    TestBed.configureTestingModule({
      declarations: [ LoadingComponent ]
    });
    fixture = TestBed.createComponent(LoadingComponent);
    loading = fixture.componentInstance;
    element = fixture.nativeElement;
    de = fixture.debugElement;
  });

}) 

Testing Component (2/2)

// loading/loading.component.spec.ts
describe('LoadingComponent', () => {
  let fixture, loading, element, de;

  // specs
  it('should render Spinner', () => {        
    loading.loading = true;
    fixture.detectChanges(); //trigger change detection
    fixture.whenStable().then(() => { 
      expect(element.querySelector('svg')).toBeTruthy();
    });
  });

}) 

More

Blog Post

Demos

  • Components, Directives, Pipes

  • Services, Http, MockBackend

  • Router, Observables, Spies

  • Animations

  • Marble Testing