Marble testing

Introduction

Les tests représentent une partie importante d’un projet et permettent de vérifier le bon fonctionnement de chacune des parties de celui-ci. En effet, les tests  permettent d’éviter les régressions et les impacts lors de corrections ou de réalisation de nouvelles fonctionnalités. De plus, les tests font également office de “documentation”. Cependant, les tests unitaires ne sont pas évidents à prendre en main, surtout pour les parties asynchrones de notre code.

Le marble testing est une méthode de réalisation de test unitaire qui permet notamment de tester notre code RxJS asynchrone de manière synchrone.

Pour rappel, RxJS est une bibliothèque disponible pour plusieurs frameworks et langages (Angular, Java, Go, …). RxJS permet une gestion de données avec des observables. Un observable est un flux de données auquel on va pouvoir s’abonner (subscribe). On peut se représenter un observable comme un tuyau dans lequel transitent des données (string, boolean, objet, …), et nous pouvons donc les récupérer en nous abonnant à l’observable. Pour plus d’informations sur ce sujet, je vous renvoie vers mon autre article de blog Ippon qui rentre plus en détail dans le concept de RxJS : https://blog.ippon.fr/2021/10/25/introduction-a-rxjs/

Comment fonctionne un test front ?

Dans cet article, nous souhaitons tester une application Angular à l’aide de Jasmine (https://jasmine.github.io/).

Les tests front sont nommés de la manière suivante : mon-composant.component.spec.ts ou mon-composant.service.spec.ts

Le nom du test doit impérativement se terminer par .spec.ts, sinon il ne sera pas exécuté.

Pour générer un nouveau composant avec un test, vous pouvez exécuter la commande suivante :

ng generate component mon-composant

Cette commande aura pour conséquence de générer un nouveau dossier mon-composant avec des fichiers ts, html, sass ainsi que spec.ts

$ ng generate component mon-composant
CREATE src/app/mon-composant/mon-composant.component.html (28 bytes)
CREATE src/app/mon-composant/mon-composant.component.spec.ts (669 bytes)
CREATE src/app/mon-composant/mon-composant.component.ts (303 bytes)    
CREATE src/app/mon-composant/mon-composant.component.sass (0 bytes)    
UPDATE src/app/app.module.ts (34206 bytes)

Vous pouvez également créer un service avec la commande suivante :

ng generate service mon-composant

Ce qui aura pour effet de générer mon-composant.service.ts. Ces deux commandes peuvent être abrégées en :

ng g c mon-composant
ng g s mon-composant

Pour lancer les tests en local, vous pouvez utiliser la commande suivante :

npm run test

Comment fonctionne le marble testing ?

Le fonctionnement du marble testing est simple à comprendre. L’idée est d’utiliser les représentations visuelles de flux de données d’observables (Marble diagram) pour représenter nos flux attendus.

Le marble diagram est donc la représentation d’un flux de données sous forme de “billes” (marbles) qui passeraient dans un “tuyau” à intervalles réguliers ou irréguliers.

Voici un exemple très parlant : https://rxmarbles.com/

Opérateur merge : https://rxmarbles.com/#merge

Cette documentation regroupe la plupart des opérateurs RxJS et permet de déplacer l’ordre des billes pour mieux comprendre comment fonctionnent ces opérateurs.

Le marble testing reprend donc le principe du marble diagram qui permet de nous assurer que notre observable se comporte comme prévu. Lorsqu’on réalise un test marble, on veut donc reproduire le plus fidèlement possible le cas que l’on veut tester. Pour cela, RxJS nous met à disposition des outils comme les “hot observables”, les “cold observables”. Nous pourrons également utiliser des “mocks” pour simuler des parties.

On peut finalement imaginer que le marble testing, c’est comme faire passer des billes dans notre machine (qui est notre test), qui va faire avancer nos billes et créer le scénario de notre souhait.

Wintergatan - Marble Machine (music instrument using 2000 marbles)

RxSandbox

Pour réaliser nos tests marbles, nous utilisons RxSandbox (github.com/kwonoj/rx-sandbox).

RxSandbox est une bibliothèque mettant à disposition des outils de tests pour RxJS, basée sur le diagramme de billes.

Dans RxSandbox, la syntaxe marble est une chaîne de caractères représentant des événements qui se produisent dans le temps virtuel, appelés “frame”.

L’un des avantages de RxSandbox par rapport à TestScheduler de RxJS est la possibilité d’intercepter plusieurs données dans une même période de temps avec la syntaxe marble  <( ) .

Marble syntax

  • -  : Une unité de temps (frame), par défaut 1.
  • |  : Complétion d’un observable avec succès, signal le déclenchement de complete().
  • #  : Un erreur terminant l’observable, signale le déclenchement de   error().
  • (espace) : Pas d’opération, un espace ne fait rien mais permet d’aligner les “marbles” pour plus de lisibilité.
  • a  : N’importe quels caractères pour définir une donnée représentant une valeur émise par next().
  • ( )  : Lorsque plusieurs événements doivent être isolés dans la même frame de manière synchrone, des parenthèses sont utilisées pour regrouper ces événements. Vous pouvez regrouper les valeurs émises, une complétion ou une erreur de cette manière. La position de l'initiale  (  détermine le moment où ses valeurs sont émises.
  • ^  : (Observables chauds seulement) Indique le moment où les observables testés seront abonnés à l'observable chaud. C'est la “frame zéro” pour cet observable, chaque frame avant  ^  sera négative.
  • !  : (Pour les tests d’abonnements) Affiche le moment auquel les observables testés seront désabonnés.
  • ...n...  : (n est un nombre) Allongement de la période de temps. Pour les cas de test sur une longue période de temps d’un observable, permet de raccourcir le diagramme au lieu de répéter les  - .

Quelques exemples :

const never = `------`; // Observable.never() regardless of number of `-`
const empty = `|`;      // Observable.empty();
const error = `#`;      // Observable.throw(`#`);
const obs1 = `----a----`;
//`           01234    `, emits `a` on frame 4
const obs2 = `----a---|`;
//`           012345678`, emits `a` on frame 4, completes on 8
const obs2 = `-a-^-b--|`;
//`              012345`, emits `b` on frame 2, completes on 5 - hot observable only
const obs3 = `--(abc)-|`;
//`           012222234, emits `a`,`b`,`c` on frame 2, completes on 4
const obs4 = `----(a|)`;
//`           01234444, emits `a` and completes on frame 4
const obs5 = ` - --a- -|`;
//`            0 1234 56, emits `a` on frame 3, completes on frame 6
const obs6 = `-a...10...b-|`
//`           -a----------b-|
//`           0123456789…………14, emits `a` on frame 1, emits `b` on frame 12,    completes on frame 14
Exemples de marble testing

Subscription marble syntax

Il est important aussi de tester à quel moment notre observable est subscribe et à quel moment il est unsubscribe. RxSandbox met donc à notre disposition des outils permettant de tester la subscription d’un observable :

  • -  : Une unité de temps (frame), par défaut 1.
  • ^ : Indique lorsqu’un abonnement à lieu.
  • !  : Indique lorsqu’un désabonnement a lieu.
  • (espace) : Pas d’opération, un espace ne fait rien mais permet d’aligner les “marbles” pour plus de lisibilité.
  • ...n...  : (n est un nombre) Allongement de la période de temps. Pour les cas de test sur une longue période de temps d’un observable, permet de raccourcir le diagramme au lieu de répéter les  - .

Quelques exemples:

const sub1 = `-----`; // no subscription
const sub2 = `--^--`;
//`           012`, subscription happened on frame 2, not unsubscribed
const sub3 = `--^--!-`;
//`           012345, subscription happened on frame 2, unsubscribed on frame 5
Exemples de subscription marble testing

Structure d’un test

La base d’un test

Un fichier .spec.ts se compose de la manière suivante :

describe('MonCompsant', () => {
…
    it('Doit être créé', () => {
        expect(component).toBeTruthy();
    });
    it('Doit retourner toto et lala lorsque monObservable reçoit toto et lala', () => {
        …
    });
}
Un exemple de test

describe représente le bloc que l’on souhaite tester (il peut par exemple avoir le nom du fichier que l’on teste).

it représente le test. Un bloc describe peut contenir plusieurs tests.

describe et it font partie de la syntaxe Jasmine.

Pour faciliter la rédaction de test, vous pouvez remplacer describe par fdescribe, ce qui va exécuter uniquement les tests contenus dans votre bloc describe et non tous les tests du projet. Vous pouvez faire de même avec fit pour exécuter que quelques tests. N’oubliez pas de retirer le “f” de fdescribe ou fit avant de commit vos tests.

fit et fdescribe permettent également de regrouper des tests qui sont liés.

Les noms des tests doivent être les plus explicites possible quitte à ce qu’ils soient longs. Il existe plusieurs conventions de nommage de test. Par exemple, it should when given … (doit … lorsque … reçoit …).

Fixture et services

Certains tests doivent simuler un composant et des services pour être menés à bien. Voici un exemple de test émulant un composant et ses services :

describe('MonComponent', () => {
    let component: MonComponent;
    let fixture: ComponentFixture<MonComponent>;
    const eventSubject = new Subject<NavigationStart>();
    
    beforeEach(async () => {
        const SerializerMock = {
        	serialize: tree => of(null),
        };
        await TestBed.configureTestingModule({
            imports: [
                WordingModule.withConfig({ wordings: Wording })],
                HttpClientTestingModule,
                RouterTestingModule
            declarations: [MonComponent],
            providers: [
            	{ provide: UrlSerializer, useValue: SerializerMock },
            ],
            schemas: [CUSTOM_ELEMENTS_SCHEMA],
        }).compileComponents();
    });

    beforeEach(() => {
        fixture = TestBed.createComponent(MonComponent);
        component = fixture.componentInstance;
        fixture.detectChanges();
    });

    afterEach(() => {
        fixture.destroy();
    });
});
Un exemple de test avec un mock de composant

En tout premier, nous déclarons la variable component qui sera notre composant avec lequel nous allons interagir au cours de nos tests et la variable fixture va nous permettre d’instancier notre composant.

Ensuite, nous avons deux beforeEach. Le premier sera celui sur lequel nous allons nous pencher, le second restera identique la plupart du temps. Plus sur ce sujet : why component generated by ng cli has the spec file with 2 beforeeach method? - Stack Overflow

Dans le beforeEach asynchrone, nous allons fournir à configureTestingModule un tableau providers. providers contiendra la liste des dépendances de notre composant. Pour l’exemple, voici le constructeur de MonComponent :

constructor(private readonly monService: MonService) {}
Constructeur du composant que l'on souhaite tester

Notre constructeur de composant dépend donc de MonService, allons voir maintenant le constructeur de MonService :

constructor(
    readonly private monAutreService: MonAutreService,
    readonly private http: HttpClient,
    readonly private router: Router,
    readonly private serializer: UrlSerializer,
    readonly private clientService: ClientService,
) {
	super(monService);
}
Constructeur du service que l'on souhaite mocker

Nous devons donc mocker UrlSerializer car ce dernier est un import “externe” au projet et Angular ne nous met pas à disposition un mock comme pour router (RouterTestingModule) ou HttpClient (HttpClientTestingModule). A contrario par exemple de monAutreService qui est interne au projet, Angular y a donc accès pour les tests.

Pour l’exemple, UrlSerializer contient une méthode serialize qui permet de créer des queryParams.

L’objectif est de mocker serializer comme ceci :

const SerializerMock = {
	serialize: tree => of(null),
};
Méthode utilisée par Serialize

On mock la méthode serialize pour ne pas avoir d’erreur (nous n'utilisons pas cette méthode dans notre test)

providers: [
	{ provide: UrlSerializer, useValue: SerializerMock },
],
Providers de notre composant

On peut ensuite inclure notre mock dans les providers de notre test.

Mock d’appel HTTP

Si l’on veut tester une fonctionnalité dans son ensemble pour reproduire un cas d’erreur, il peut arriver que l’on doive mocker un appel HTTP. Prenons un exemple, je souhaite tester un observable :

// Affichage du bouton pour un utilisateur administrateur
    readonly afficherBouton: Observable<boolean> = this.monService.infos.pipe(
    switchMap(this.monService.getUser),
    map((user: User) => this.isUserAdmin(user)),
    shareReplay({ bufferSize: 1, refCount: true }),
);
Exemple d'un observable à simuler à l'aide d'un subject

Cependant, cet observable dépend d’un appel HTTP contenu dans un service :

// Récupère l’utilisateur actuel
getUser = (reference: string): Observable<User> =>
	this.http.get(`${this.addr}/${this.endpoint}/${reference}`, httpOptions) as Observable<User>;

Exemple d'appel HTTP

Nous devons donc réussir à mocker cet appel. Pour se faire, nous allons devoir récupérer notre service comme ceci :

const monService = TestBed.inject(MonService);
spyOn(monService, 'getUser')
    .withArgs('admin')
    .and.returnValue(retourAdminUser$)
    .withArgs('toto')
    .and.returnValue(retourUser$);
Exemple de mock d'un appel HTTP

TestBed.inject nous permet de récupérer notre service. On va pouvoir ensuite mocker la méthode getUser à l’aide de spyOn. On peut définir des valeurs de retour en fonction des valeurs d’arrivée :

  • Lorsque getUser reçoit ‘admin’, il renvoie notre objet retourAdminUser$.
  • Lorsque getUser reçoit ‘toto’, il renvoie notre objet retourUser$.

Rédaction de test

Pour notre test, on va donc utiliser RxSandbox. On peut importer RxSandbox comme ceci :

const { getMessages, cold, flush, e, s } = rxSandbox.create();
Déclaration de méthodes avec RxSandbox

Pour bien comprendre ce que font getMessages, cold, flush, e, et s je vous redirige vers https://github.com/kwonoj/rx-sandbox

Prenons un exemple, nous avons un composant simple qui utilise le service suivant :

import { Injectable } from '@angular/core';
@Injectable({
	providedIn: 'root',
})
export class MonService {
    const siDonneesDisponible: Subject<boolean>();
    const siErreur: Subject<boolean>();
    const siAfficherBouton$: Observable<boolean> = combineLatest([
        this.siDonneesDisponible,
        this.siErreur
    ]).map(([siDonneesDisponible, siErreur]: [boolean, boolean]) => siDonneesDisponible && siErreur);
    constructor() { }
}
Exemple de Service avec un observable à tester

Nous avons l’observable siAfficherBouton$ qui dépend de 2 subjects : siDonneesDisponible et siErreur. Ces 2 subjects sont des flux de données booléens.

Nous voulons donc tester notre observable siAfficherBouton$ avec différents cas :

  • siAfficherBouton$ lorsque siDonneesDisponible à true et siErreur à true
  • siAfficherBouton$ lorsque siDonneesDisponible à true et siErreur à false
  • siAfficherBouton$ lorsque siDonneesDisponible à false et siErreur à true
  • siAfficherBouton$ lorsque siDonneesDisponible à false et siErreur à false

On doit donc envoyer notre donnée de test via les subjects de notre service. Pour ce faire, on peut utiliser un cold observable comme ceci :

cold('ttff', { t: true, f: false }).subscribe(
	component.monService.siDonneesDisponible, // ← Subject à Mocker
);
cold('tftf', { t: true, f: false }).subscribe(
	component.monService.siErreur, // ← Subject à Mocker
);
Exemple d'utilisation de cold observables

Cela peut être retranscrit en :

cold('ttff', { t: true, f: false }).subscribe(
	map((siDonneesDisponible: boolean) => component.monService.siDonneesDisponible.next(siDonneesDisponible))
);
cold('tftf', { t: true, f: false }).subscribe(
	map((siErreur: boolean) => component.monService.siErreur.next(siErreur))
);
Reformulation de cold observable

A chaque valeur émise par notre cold observable, on va émettre au travers du subject de notre service.

On ne peut pas mocker d’observable de notre component, on ne peut que mocker des subjects avec la façon de faire ci-dessus.

Une fois que toute notre donnée est bien initialisée à l’aide des subjects et qu’on peut reproduire le cas qui nous intéresse, on peut ensuite s’assurer du comportement de l’observable que l’on veut tester. Par exemple :

const expected = e('tfff', {
    t: true,
    f: false,
});
const messages = getMessages(component.monService.siAfficherBouton$);
flush();
expect(messages).toEqual(expected);
Exemple de marble testing

On s’attend à ce que l’observable component.monService.siAfficherBouton$ soit égal à 'tfff'. flush() permet de passer en synchrone (faire transiter toutes nos données en asynchrone).

Voici un exemple de test en entier :

it('siAfficherBouton$ doit retourner tfff (t=true, f=false) quand siDonneesDisponible retourne ttff et siErreur retourne tftf', () => {
    const { getMessages, cold, flush, e, s } = rxSandbox.create();
    //                                 0123
    const changeSiDonneesDisponible = 'ttff'; // émet true sur la frame 0 et 1 puis false sur la frame 2 et 3
    const changeSiErreur = '           tftf'; // émet true sur la frame 0 et 2 et false sur la frame 1 et 3
    
    cold(changeSiDonneesDisponible , { t: true, f: false}).subscribe(component.monService.siDonneesDisponible);
    cold(changeSiErreur , { t: true, f: false}).subscribe(component.monService.siErreur);
    const expected = e('tfff', {
        t: true,
        f: false,
    });
    const messages = getMessages(component.monService.siAfficherBouton$);
    flush();
    expect(messages).toEqual(expected);
});
Un test (it) en entier

Conclusion

Au travers de cet article, nous aurons vu à quoi sert un test front, comment fonctionne RxJS en marble diagram et comment tester nos observables avec du marble testing.

Sur un projet Angular, les tests de logique sur RxJS sont parfois mis de côté. Cependant, ces mêmes tests représentent une majeure partie des cas de régressions que l'on peut constater du fait de leur potentielle complexité.