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) => { // ... }));