Intermédiaire

Tests unitaires avec Angular (partie 2)

Dans la première partie, nous avons abordé les différents cas de tests des services Angular. Ces services vont être consommés par les objets destinés à gérer notre UI : pipes, directives et composants.

Les principes vus dans ce précédent article seront utilisés dans ce cadre. Nous allons découvrir les outils spécifiques permettant de tester unitairement nos objets graphiques, allant jusqu’à faire des tests d’intégration avec les templates.

Les exemples présentés dans cet article sont consultables sur ce github.

I. Tester un pipe

Un pipe implémente une interface PipeTransform. Il comprend obligatoirement une méthode transform() qui prend en paramètres la valeur à transformer, ainsi que les paramètres de transformation désirés, et retourne la valeur transformée :

@Pipe({
	name: 'uppercase'
})
export class UppercasePipe implements PipeTransform {
	transform( value: string ): string {
		return value.toUpperCase();
	}
}

Une première méthode de test, qui satisfait le cas particulier des pipes, est d’instancier et injecter notre classe comme un service :

describe( 'UpperCasePipe', () => {    
	let pipe;

	beforeEach(async(() => {
		TestBed.configureTestingModule({
			providers: [ UpperCasePipe ]
		});
	}));

	beforeEach(inject([UpperCasePipe], (u: UpperCasePipe) => {
		pipe = u;
	}));
});

Nous exécuterons dans le cadre de notre test la méthode transform() avec un paramètre et évaluerons le résultat attendu :

it('should return an uppercase string', () => {
	const result = pipe.transform('hello');
	expect(result).toBe('HELLO');
});

La deuxième solution de test est d’utiliser le pipe dans un composant parent et d’évaluer le résultat attendu au niveau du DOM. Cette méthode sera également utilisée ci-après pour tester des composants de type UI.

II. Rappel : Feature component vs Shared component

Par bonne pratique, nous utiliserons nos composants de deux manières bien distinctes dans le cadre de notre application. Chacun de ces composants implique une manière spécifique de les tester.

Les feature components seront nos composants applicatifs, représentant une fonctionnalité spécifique. Leur rôle sera de récupérer des données de services injectés, de distribuer ces données à leurs composants enfants, qui seront les shared components. Ils récupéreront également les events émis par les enfants afin de les traiter.

Les shared components quant à eux auront pour rôle d’assurer l’interface graphique avec l’utilisateur. Ils communiquent avec leur parent par @Input() pour récupérer une information, ou par @Output() pour émettre un événement utilisateur. Le fait de ne pas dépendre d’un service va nous permettre de facilement les partager au sein de nos différents modules, voire de les inclure dans une bibliothèque de composants spécifiques. Ce sont eux, enfin, auxquels on appliquera une stratégie de détection de changement par référence, afin d’optimiser la durée de traitement de zone.js.

III. Tester un feature component

Prenons pour exemple un component qui s’abonnera à un observable d’un service extérieur, et qui communiquera un changement d’état à ce même service :

import { Component, OnInit } from '@angular/core';
import { DataService } from './data.service';

@Component({
	selector: 'app-feature',
	template: `
		<article (click)="change('Autre valeur')">{{ message }}</article>
	`
})
export class FeatureComponent implements OnInit {

	public message: string = 'Valeur';

	constructor( private dataService: DataService) {}
	
	public change = evt => {
		this.dataService.change(evt);
	}

	ngOnInit() {
		this.dataService.getMessage().subscribe(message => {
			this.message = message;
		});
	}
}

Notre test va avoir les étapes suivantes :

  1. Importer les objets nécessaires au test,
  2. Mocker le service et retourner une valeur observable,
  3. Déclarer le composant dans le module de test,
  4. Instancier et récupérer l’instance du component,
  5. Provoquer la détection du changement du composant,
  6. Tester la valeur du message,
  7. Tester l’exécution de la méthode change.

Les imports :

import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { TestBed, async, ComponentFixture } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Observable } from 'rxjs/Observable';
import { DataService } from './data.service';
import { FeatureComponent } from './feature.component';
  • DebugElement nous permet d’accéder à des méthodes de manipulation du DOM.
  • ComponentFixture nous permet de récupérer l’instance de notre composant instancié, de provoquer la détection du changement avec la méthode detectChanges(), et d’accéder à sa propriété debugElement.
  • By comprend une méthode css() permettant de définir un sélecteur HTML.

Définissons notre mock :

const spyDataService = jasmine.createSpyObj(
	'spyDataService', ['change', 'getMessage']
);

spyDataService.getMessage.and.returnValue(Observable.of('Hello'));

Configurons notre module de test :

beforeEach(async(() => {
	TestBed.configureTestingModule({
		declarations: [ FeatureComponent ],
		providers: [
			{
				provide: DataService,
				useValue: spyDataService
			}
		],
		schemas: [ NO_ERRORS_SCHEMA ]
	});
}));

Nous allons ensuite instancier notre composant et récupérer son instance afin d’avoir accès à la méthode de rafraichissement de la zone, aux méthodes et propriétés publiques de notre composant, et à la méthode qui nous permettra d’accéder au DOM.

let fixture: ComponentFixture<FeatureComponent>;
let comp: FeatureComponent;

beforeEach(async(() => {
	fixture = TestBed.createComponent(FeatureComponent);
	comp = fixture.componentInstance;
}));

Notre premier test consiste à provoquer la détection du changement avec la méthode detectChanges() de notre fixture, puis à appeler la méthode change() accessible via l’instance du composant :

it('should change data', () => {
	fixture.detectChanges();
	comp.change('Envoi');
	expect(spyDataService.change).toHaveBeenCalledWith('Envoi');
});

Pour notre 2e test, nous allons vérifier que la méthode getMessage() a bien été appelée, puis vérifier que la valeur retournée par notre spy est bien égale à « Hello » :

it('should get a new message', () => {
	fixture.detectChanges();
	expect(spyDataService.getMessage).toHaveBeenCalled();
	expect(comp.message).toBe('Hello');
});

Enfin, afin de bien vérifier que le DOM a été correctement mis à jour, nous allons récupérer le contenu de la balise article et vérifier qu’elle contient « Hello » :

it('should have a Hello value', () => {
	fixture.detectChanges();
	const element: DebugElement = fixture.debugElement;
	const el = element.query(By.css('article'));
	expect(el.nativeElement.innerHTML.trim()).toBe('Hello');
});

La méthode query() de notre debugElement permet de récupérer un élément unique. La méthode queryAll() permet de récupérer un tableau d’éléments.

IV. Tester un shared component

Pour les shared components, nous aurons à nous assurer du bon fonctionnement des @Input() et @Output().
L’accès au DOM nous permettra de vérifier son bon fonctionnement. Pour des raisons pratiques, il est plus confortable de déclarer un composant parent fake qui va contenir l’élément à tester. L’interaction complète des échanges entre les éléments parent et enfant sera plus simple à contrôler, et le cycle de vie du composant enfant sera automatisé grâce à notre méthode detectChanges().

Voici un shared component qui prend une donnée en entrée et envoie un event emitter à son parent :

@Component({
	selector: 'app-shared',
	template: '<article (click)="send('envoi')">{{ entree }}</article>',
	changeDetection: ChangeDetectionStrategy.OnPush
})
export class SharedComponent {
	@Input() entree: string;
	@Output() envoi = new EventEmitter();
	
	send = valeur => {
		this.envoi.emit(valeur);
	}
}

Pour le test, en plus de des annotations @Component et @ViewChild, nous utiliserons les mêmes imports que notre feature component.

Définissons notre composant parent. Il aura en template le SharedComponent à tester. Le shared component sera déclaré comme @ViewChild afin d’accéder à ses propriétés :

@Component({
	selector: 'app-parent',
	template: `
		<app-shared [entree]="entree" (envoi)="envoi($event)">
		</app-shared>
	`
})
export class ParentComponent {
	@ViewChild(SharedComponent) child;
	entree = 'Hello';
	envoi = evt => {};
}

Pour notre TestBed, nous allons déclarer les deux composants utilisés, et instancier le composant parent :

let fixture: ComponentFixture<ParentComponent>;
let comp: ParentComponent;

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

beforeEach(async(() => {
	fixture = TestBed.createComponent(ParentComponent);
	comp = fixture.componentInstance;
}));

Voici notre test d’input, qui vérifie à la fois le contenu de la variable entree, et le contenu de la balise article :

it('should have a Hello message', () => {
	fixture.detectChanges();
	expect(comp.child.entree).toBe('Hello');
	const el = fixture.debugElement.query(By.css('article'));
	expect(el.nativeElement.innerHTML).toBe('Hello');
});

Pour le test d’output, nous allons programmatiquement cliquer sur article, mettre un spy sur la méthode envoi() de notre composant parent et nous assurer qu’elle a bien été appelée :

it('should send the event', () => {
	spyOn(comp, 'envoi');
	fixture.detectChanges();
	const el = fixture.debugElement.query(By.css('article'));

	el.nativeElement.click();
	expect(comp.envoi).toHaveBeenCalledWith('envoi');
});

V. Tester une directive

Pour les directives, l’approche sera similaire aux shared components, à savoir utiliser un ParentComponent qui va nous permettre de contrôler son bon fonctionnement.

Voici une directive de type attribut dont le rôle est de changer la couleur de fond de son element en fonction d’un paramètre d’entrée color :

@Directive({
    selector: '[appColor]'
})

export class ColorDirective  implements On Changes {
    @Input() color: string = null;
    constructor(private el: ElementRef) { }

    ngOn Changes() {
            this.el.nativeElement.style.backgroundColor = this.color;
    }
}

Définissons notre parent component, qui utilise la directive :

@Component({
	selector: 'app-parent',
	template: '<div [appColor]="color">Hello</div>'
})
export class ParentComponent {
	public color: string;
}

Définissons notre TestBed :

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

beforeEach(async(() => {
	fixture = TestBed.createComponent(ParentComponent);
	comp = fixture.componentInstance;
}));

Nous n’avons plus qu’à affecter un code couleur à la variable publique « color » de ParentComponent, et vérifier dans le HTML que le background est bien à cette valeur :

it('should change its background color, () => {
	comp.color = 'white';
	fixture.detectChanges();
	const el = fixture.debugElement.query(By.css('div'));
	expect(el.nativeElement.style.backgroundColor).toBe('white');
});

Conclusion

Angular fournit tous les outils pour tester en profondeur nos applications. Services, composants, directives et pipes sont facilement manipulables afin de répondre à tous les cas courants de tests. Avoir un taux de couverture dépassant allègrement les 95% devient évident et à portée de main.

Je vous souhaite de réaliser de belles applications, bien testées, votre code n’en sera que mieux optimisé et fiable.

© SOAT
Toute reproduction interdite sans autorisation de l’auteur

Nombre de vue : 1277

AJOUTER UN COMMENTAIRE