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

🎉 100 🎉

🎉   🎉

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

Advanced Testing Recipes (v7+)

By Gerard Sans

Advanced Testing Recipes (v7+)

In this talk, we 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.

  • 3,424