Table des matières
Unit testing in Angular
Testing Overview
End to End
- Live running application
- Tests exercise live application
- Automated
- Validate application as a hole
Unit testing
- Single unit of code (often a class)
Integration and functional testing
- More than a unit, less than the complete application
- Check that a part is working with another part
Mocking
Permet d'isoler le code que l'on veut tester.
Types de mocks:
- Dummies
- Stubs
- Spies
- True mocks
Unit Tests in Angular
Types de tests unitaires dans Angular
- Isolated
- Integration (création de module)
- Shallow
- Deep
Tools of Unit Testing with Angular
- Karma
- Jasmin
Autres
- Jest
- Mocha/Chai
- Sinon
- TestDouble
- Wallaby
- Cypress
- End to end tools
Installing and running
Écrire le premier test unitaire
Exemple de test de base:
describe('my first test', () => {
let sut; // system under test
beforeEach(() => {
sut = {};
});
it('should be true if true', () => {
// arrange
sut.a = false;
// act
sut.a = true;
//assert
expect(true).toBe(true);
});
});
Exécuter les tests unitaires
Par défaut, avec un projet Angular:
$ npm test
Écrire de bons tests unitaires
Structure:
- Arrange all necessary preconditions and inputs
- Act on the object or class under test
- Assert that the expected result have occured
DAMP vs DRY:
- DRY (don't repeat yourself) → remove duplication
- DAMP → Repeat yourself if necessary
Tell the story:
- A test should be a complete story, all within the
it() - You shouldn't need to look around much to understant the test
- Techniques
- Move less interesting setup to
beforeEach() - Keep critical setup within the
it() - Include Arrange, Act, and Assert inside the
it()
Isolated Unit Tests
Tester un Pipe
Exemple de pipe:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'strength'
})
export class StrengthPipe implements PipeTransform {
transform(value: number): string {
if(value < 10) {
return value + " (weak)";
} else if(value >= 10 && value < 20) {
return value + " (strong)";
} else {
return value + " (unbelievable)";
}
}
}
Le test:
import { StrengthPipe } from "./strength.pipe";
describe('StrengthPipe', () => {
it('should display weak if strength is 5', () => {
let pipe = new StrengthPipe();
expect(pipe.transform(5)).toEqual('5 (weak)');
})
it('should display strong if strength is 10', () => {
let pipe = new StrengthPipe();
expect(pipe.transform(10)).toEqual('10 (strong)');
})
})
Tester un service
import { Injectable } from '@angular/core';
@Injectable()
export class MessageService {
messages: string[] = [];
add(message: string) {
this.messages.push(message);
}
clear() {
this.messages = [];
}
}
import { MessageService } from "./message.service"; describe('MessageService', () => { let service: MessageService; beforeEach(() => { }); it('should have no messages to start', () => { service = new MessageService(); expect(service.messages.length).toBe(0); }); it('should add a message when add is called', () => { service = new MessageService(); service.add('message1'); expect(service.messages.length).toBe(1); }); it('should remove all messages when clear is called', () => { service = new MessageService(); service.add('message1'); service.clear(); expect(service.messages.length).toBe(0); }); });
Tester un Component
import { Component, OnInit } from '@angular/core'; import { Hero } from '../hero'; import { HeroService } from '../hero.service'; @Component({ selector: 'app-heroes', templateUrl: './heroes.component.html', styleUrls: ['./heroes.component.css'] }) export class HeroesComponent implements OnInit { heroes: Hero[]; constructor(private heroService: HeroService) { } ngOnInit() { this.getHeroes(); } getHeroes(): void { this.heroService.getHeroes() .subscribe(heroes => this.heroes = heroes); } add(name: string): void { name = name.trim(); var strength = 11 if (!name) { return; } this.heroService.addHero({ name, strength } as Hero) .subscribe(hero => { this.heroes.push(hero); }); } delete(hero: Hero): void { this.heroes = this.heroes.filter(h => h !== hero); this.heroService.deleteHero(hero).subscribe(); } }
import { HeroesComponent } from "./heroes.component"; import { of } from "rxjs"; describe('HeroesComponent', () => { let component: HeroesComponent; let HEROES; let mockHeroService; beforeEach(() => { HEROES = [ {id:1, name: 'SpiderDude', strength: 8}, {id:2, name: 'Wonderful Woman', strength: 24}, {id:3, name: 'SuperDude', strength: 55} ] mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']) component = new HeroesComponent(mockHeroService); }) describe('delete', () => { it('should remove the indicated hero from the heroes list', () => { mockHeroService.deleteHero.and.returnValue(of(true)) component.heroes = HEROES; component.delete(HEROES[2]); expect(component.heroes.length).toBe(2); }) it('should call deleteHero', () => { mockHeroService.deleteHero.and.returnValue(of(true)) component.heroes = HEROES; component.delete(HEROES[2]); expect(mockHeroService.deleteHero).toHaveBeenCalledWith(HEROES[2]); }) }) })
Interaction test
Dans le component, si on a une fonction tel que:
delete(hero: Hero): void { this.heroes = this.heroes.filter(h => h !== hero); this.heroService.deleteHero(hero).subscribe(); }
La troisième ligne, avec le subscribe(), n'est pas nécessairement testée si on appelle delete et qu'on valide que this.heroes a changé. C'est dû au fait que le subscribe() ne change pas l'état du component.
Donc on peut faire un test d'intégration avec une vérification d'appel:
it('should call deleteHero', () => {
mockHeroService.deleteHero.and.returnValue(of(true))
component.heroes = HEROES;
component.delete(HEROES[2]);
expect(mockHeroService.deleteHero).toHaveBeenCalledWith(HEROES[2]);
});
Shallow Integration Tests
Using NO_ERRORS_SCHEMA
La mention NO_ERRORS_SCHEMA permet d'isoler le composant sous test en faisant en sorte que les sous-composants n'ont pas besoin d'être connus.
import {NO_ERRORS_SCHEMA} from '@angular/core';
TestBed.configureTestingModule({
declarations: [WriteUsDialogComponent],
imports: [],
providers: [],
schemas: [NO_ERRORS_SCHEMA],
}).compileComponents();
L'effet pervers de ne pas avoir d'erreurs quand un sous-composant est _inconnu_, c'est que si on écrit <buttons></buttons>, il n'y aura pas de problème de relevé alors qu'il y en a véritablement un.
Testing Rendered HTML
component.client = {firstName: 'John', lastName: 'Doe'};
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('a').textContent).toContain('John');
NativeElement vs DebugElement
DebugElement est un wrapper sur NativeElement qui a plusieurs fonctionnalités semblable au NativeElement.
expect(fixture.debugElement.query(By.css('a')).nativeElement.textContent).toContain('John');
const de = fixture.debugElement.query(By.css('a')); // de = debugElement
expect(de.nativeElement.textContent).toContain('John');
DebugElement permet d'avoir accès aux directives, par exemple routerLink.
More complex Shallow Tests
Mocking an injected Service
Pour les tests de component qui ont besoin de services, on peut utiliser le TestBed.
describe('HeroComponent (shallow tests)', () => { let fixture: ComponentFixture<HeroComponent>; // le fixture est un wrapper sur le component à tester let mockHeroService; beforeEach(() => { HEROES = [ {id:1, name: 'SpiderDude', strength: 8}, {id:2, name: 'Wonderful Woman', strength: 24}, {id:3, name: 'SuperDude', strength: 55} ]; mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']); TestBed.configureTestingModule({ declarations: [HeroComponent], providers: [{provide: HeroService, useValue: mockHeroService}], schemas: [NO_ERRORS_SCHEMA], }); fixture = TestBed.createComponent(HeroComponent); }); it('should have the correct hero', () => { fixture.componentInstance.hero = {id: 1, name: 'SuperDude', strenght: 3}; expect(fixture,componentInstance. }); it('should set heroes correctly from the service', () => { mockHeroService.getHeroes.and.returnValue(of(HEROES)) fixture.detectChanges(); expect(fixture.componentInstance.heroes.length).toBe(3); }); });
Pour info, getHeroes() appelle getHeroes() du service qui est un observable:
getHeroes(): void { this.heroService.getHeroes() .subscribe(heroes => this.heroes = heroes); }
Mocking Child Components
Créer un mock de component:
@Component({ selector: 'app-hero', template: '<div></div>', }) class FakeHeroComponent { @Input() hero: Hero; // Pas obligé de typer ici vu que c'est un mock }
Ajouter FakeHeroComponent dans declarations et enlever le NO_ERRORS_SCHEMA du TestBed.
TestBed.configureTestingModule({ declarations: [ HeroesComponent, FakeHeroComponent ], providers: [ { provide: HeroService, useValue: mockHeroService } ], });
Dealing with Lists of Elements
it('should create one li element for each hero', () => { mockHeroService.getHeroes.and.returnValue(of(HEROES)) fixture.detectChanges(); expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(3); });
Deep Integration Tests
Creating a Deep Integration Test
Dans ces tests, nous voulons évaluer l'intégration entre deux components parent-enfant.
Le test de base, ressemble à ceci:
describe('HeroesComponent (deep tests)', () => { let fixture: ComponentFixture<HeroesComponent>; let mockHeroService; let HEROES; beforeEach(() => { mockHeroService = jasmine.createSpyObj(['getHeroes', 'addHero', 'deleteHero']); TestBed.configureTestingModule({ declarations: [ HeroesComponent, HeroComponent ], providers: [ { provide: HeroService, useValue: mockHeroService } ], schemas: [NO_ERRORS_SCHEMA] }) fixture = TestBed.createComponent(HeroesComponent); }); it('should be true', () => { expect(true).toBe(true); }); });
On inclus HeroesComponent et HeroComponent.
Finding Elements by Directive
Le component HeroComponent doit se répéter pour chaque héro trouvé.
Remarquez qu'on utilise By.directive().
it('should render each hero as a HeroComponent', () => { mockHeroService.getHeroes.and.returnValue(of(HEROES)); // run ngOnInit fixture.detectChanges(); const heroComponentDEs = fixture.debugElement.queryAll( By.directive(HeroComponent) ); expect(heroComponentDEs.length).toEqual(3); for (let i = 0; i < heroComponentDEs.length; i++) { expect(heroComponentDEs[i].componentInstance.hero).toEqual(HEROES[i]); } });
Integration Testing of Services
On veut tester l'intégration du service HeroService avec le HttpClient.
import { TestBed } from "@angular/core/testing"; import { HeroService } from "./hero.service"; import { MessageService } from "./message.service"; import { HttpClientTestingModule, HttpTestingController } from "@angular/common/http/testing"; describe('HeroService', () => { let mockMessageService; let httpTestingController: HttpTestingController; let service: HeroService; beforeEach(() => { mockMessageService = jasmine.createSpyObj(['add']); TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ HeroService, {provide: MessageService, useValue: mockMessageService} ] }); httpTestingController = TestBed.get(HttpTestingController); service = TestBed.get(HeroService); }); });
Implementing a Test with Mocked HTTP
Ce qu'on veut tester, c'est la méthode suivante dans le service:
getHero(id: number): Observable<Hero> { const url = `${this.heroesUrl}/${id}`; return this.http.get<Hero>(url).pipe( tap(_ => this.log(`fetched hero id=${id}`)), catchError(this.handleError<Hero>(`getHero id=${id}`)) ); }
Alors, un exemple de test serait :
it('should call get with the correct URL', () => { service.getHero(4).subscribe(); const req = httpTestingController.expectOne('api/heroes/4'); req.flush({id: 4, name: 'SuperDude', strength: 100}); httpTestingController.verify(); });
Obtenir l'instance de service
Pour obtenir une instance de service, on peut faire:
import { TestBed, inject } from '@angular/core/testing'; // ... it('should call get with the correct URL', inject([HeroService], (service: HeroService) => { // ... }));
