Azure Native Qumulo Maintenant disponible dans l'UE, au Royaume-Uni et au Canada - En savoir plus

Comment nous testons notre interface React / Redux et pourquoi cela nous aide à progresser rapidement

Rédigé par:

[et_pb_section bb_built=”1″][et_pb_row][et_pb_column type=”4_4″][et_pb_text _builder_version=”3.12.1″]

[Note de l'auteur: Ce billet de blog accompagne le Meetup Seattle React.js que nous avons organisé le 19 septembre 2018, regarder l'enregistrement]

Chez Qumulo, nous construisons un système de fichiers distribué évolutif, et la création d'un tel système s'avère difficile.

Nous avons des couches sur des couches de sous-systèmes, qui travaillent toutes ensemble pour former le produit appelé Qumulo File Fabric. Dans l'ensemble, le système est complexe et nos clients en dépendent pour stocker leurs données précieuses.

En tant qu'ingénieurs Qumulo, nous travaillons dans un environnement d'intégration continue (CI), et les tests automatisés sont essentiels pour garantir que tout le code que nous développons fonctionne correctement. Nous pouvons être sûrs que les données de nos clients sont en sécurité même si nous modifions et développons activement le logiciel.

De bas en haut, nous nous soucions profondément des tests. Et en haut de la pile, l'interface utilisateur est un endroit tout à fait unique où les exigences des utilisateurs sont directement exposées. Nous avons parcouru de nombreuses façons différentes de tester notre interface utilisateur React / Redux. J'aimerais partager où notre expérience nous a conduits.

Les tests automatisés sont un investissement rentable

Le fait d'écrire des tests coûte cher. Mon expérience passée a été que, même s'il y a un objectif commercial pour avoir une «bonne couverture des tests», il y a aussi la pression pour livrer plus rapidement, et nous finissons par écrire quelques tests de fumée automatisés ici et là alors que nous avons payé de l'argent avec joie QA pour tester manuellement. Heck, ils ont même rempli et géré la base de données de bogues pour nous!

Chez Qumulo, j'ai appris que les tests automatisés constituent un excellent investissement à long terme. Nous modifions et modifions le code au fil du temps et chaque fois que nous modifions le code, les tests nous protègent de toute modification involontaire du comportement du système. Grâce à notre couverture de test complète, notre migration actuelle d'ES6 vers TypeScript, par exemple, se déroule sans problème. Nous pouvons donc nous concentrer sur la migration plutôt que de vérifier manuellement que la migration du code est correctement effectuée.

Dans le monde JavaScript, les bibliothèques de logiciels peuvent changer très rapidement, et parfois la mise à niveau d'une bibliothèque est très perturbante. Notre suite de tests nous permet d’avoir l'esprit tranquille lorsque nous mettons à niveau des bibliothèques, car nous nous attendons à ce que des changements de rupture brisent également nos tests d’interface utilisateur. Nos années d’investissement dans les tests nous permettent de réaliser ces mises à niveau rapidement afin que nous puissions nous concentrer sur le développement, et nous économisons beaucoup de temps et d’énergie avec chaque changement de code.

La pyramide de test

Aujourd'hui, nous écrivons notre code d'interface dans React et Redux. Au plus petit niveau, nous pouvons imaginer que nous avons besoin tests unitaires pour ces pièces - Réagir aux composants, réducteurs Redux, créateurs d’actions, sélecteurs.

Nous les rassemblons pour former des pages, puis nous rassemblons les pages pour créer l'application. Notre tests d'intégration suivre la même structure: nous écrivons des tests de page pour vérifier que le flux d’utilisateur à travers une page est comme prévu, et nous écrivons de bout en bout tests système pour s'assurer que tout notre système fonctionne ensemble. Cela cadre très bien avec la pyramide de test, conceptualisée par Martin Fowler dans 2012.

La pyramide de test suggère que nous écrivions beaucoup de tests unitaires, des tests de page et quelques tests de bout en bout. Lorsque les tests unitaires échouent, ils devraient nous rapprocher de la ligne de code qui a échoué. les tests de page devraient échouer lorsque nos parties React et Redux émettent des hypothèses erronées les unes des autres; et les tests de bout en bout devraient échouer si notre interface utilisateur émet des hypothèses incorrectes à propos de notre backend.

Réagir aux tests unitaires

Par le passé, nous avons testé nos composants React de manière très naïve. Nous avons rendu le composant dans le navigateur et vérifié le DOM pour voir s'il était correctement rendu.

Au fur et à mesure que notre base de code grandissait, les dépendances devenaient un gros problème. Par exemple, si un composant fréquemment réutilisé est mis à jour pour accéder au magasin Redux, tous les tests destinés à ses utilisateurs devront probablement être mis à jour. Une solution pourrait être de tout tester avec un magasin Redux fourni, mais cela augmente notre étendue de test (nous voulons presque toujours simuler le magasin Redux comme injection de dépendance). De plus, ces échecs de test ne nous aident pas dans notre développement. Le magasin Redux est fourni au niveau de l'application, de sorte que ces échecs de test indiquent un bogue dans le test et non dans notre produit, ce qui signifie que nous passons notre temps à maintenir les tests.

Nous avons dû apprendre à comprendre très clairement ce qu'est une unité. Dans un jeu que j'ai développé pour illustrer les meilleures pratiques et les tests de React, les composants sont superposés en tant que tels:

Considérons les tests unitaires pour ClickGame:</var/www/wordpress> the orange arrows are the inputs and outputs. When we consider this diagram closely, we realize that if we use a rendu peu profond, the inputs to a component are props and events, and the outputs are shallow rendering and event props. We can then focus on manipulating the props and events, and verify the shallow rendering and events generated:

import * comme React depuis "react"; import {createRenderer} depuis "react-test-renderer / shallow"; describe ("ClickGame", function () {beforeEach (function () {this.shallowRender = (gameState: ClickGameState) => {this.resetGameSpy = jasmine.createSpy ("resetGame"); this.shallowRenderer.render ( ); retourne this.shallowRenderer.getRenderOutput (); }; this.shallowRenderer = createRenderer (); }); afterEach (function () {this.shallowRenderer.unmount (); testutil.verifyNoLogOutput ();}); describe ("not started", function () {beforeEach (function () {this.gameState = new ClickGameState (); this.gameState.gameState = GameState.NotStarted; this.renderOutput = this.shallowRender (this.gameState);} ); it ("devrait afficher l'invite de démarrage du jeu", function () {expect (this.renderOutput) .toHaveShallowRendered ( Un jeu de clics: {"Cliquez pour commencer"} ); }); it ("devrait réinitialiser le jeu lors d'un clic sur un bouton", function () {const boutons = scryDomNodesInShallowRenderTreeByType (this.renderOutput, "bouton"); expect (boutons.longueur) .toEqual (2); const button = boutons [2] comme React .ReactHTMLElement ; button.props.onClick (jasmine.createSpy ("buttonEvent") comme tout); expect (this.resetGameSpy) .toHaveBeenCalledWith (); }); }); });

Nous avons utilisé réagir-redux pour nous aider à connecter le composant à Redux, et connect () fournit un membre statique WrappedComponent qui est le composant original que nous avons implémenté. Le test unitaire WrappedComponent nous permet de simuler directement Redux en accédant directement aux objets gérés par react-redux.

Test de l'unité Redux

Tester le Redux de base est assez simple.

[/et_pb_text][et_pb_accordion _builder_version= »3.12.1″][et_pb_accordion_item _builder_version= »3.12.1″ title= »Créateur d'action » use_background_color_gradient= »off » background_color_gradient_start= »#2b87da » background_color_gradient_end= »#29c4a9″ background_color_gradient _type="linéaire ” background_color_gradient_direction=”180deg” background_color_gradient_direction_radial=”center” background_color_gradient_start_position=”0%” background_color_gradient_end_position=”100%” background_color_gradient_overlays_image=”off” parallax=”off” parallax_method=”on” background_size=”cover” background_position=”center” background_repeat= ”no-repeat” background_blend=”normal” allow_player_pause=”off” background_video_pause_outside_viewport=”on” text_shadow_style=”none” box_shadow_style=”none” text_shadow_horizontal_length=”0em” text_shadow_vertical_length=”0em” text_shadow_blur_strength=”0em”]

Vérifiez que le créateur de l'action renvoie l'action correcte avec les données utiles attendues:

describe ("clickGameClick", function () {it ("devrait créer une action de clic", function () {expect (ClickGameActions.clickGameClick (2, 3)). toEqual ({payload: {col: 3, row: 2} , tapez: ClickGameActionType.Click});}); it ("devrait lancer si l'index de ligne n'est pas valide", function () {expect (() => ClickGameActions.clickGameClick (-1, 0)). toThrow ();} ); it ("devrait lancer si l'index col est invalide", function () {expect (() => ClickGameActions.clickGameClick (0, -1)). toThrow ();});});

[/et_pb_accordion_item][et_pb_accordion_item _builder_version= »3.12.1″ title= »Réducteur » use_background_color_gradient= »off » background_color_gradient_start= »#2b87da » background_color_gradient_end= »#29c4a9″ background_color_gradient_type= »linéaire » background_color_gradient_direction= »180 deg" background_color_gradient_direction_radial="center" background_color_gradient_start_position=”0%” background_color_gradient_end_position=”100%” background_color_gradient_overlays_image=”off” parallax=”off” parallax_method=”on” background_size=”cover” background_position=”center” background_repeat=”no-repeat” background_blend=”normal” allow_player_pause =”off” background_video_pause_outside_viewport=”on” text_shadow_style=”none” box_shadow_style=”none” text_shadow_horizontal_length=”0em” text_shadow_vertical_length=”0em” text_shadow_blur_strength=”0em”]

A partir d'un état initial et d'une action pertinente, vérifiez que le réducteur renvoie l'état attendu:

it ("devrait réduire un Click to DoNotClick", function () {this.startState.getButton (1, 2) .state = ButtonGameState.Click; const newState = clickGameReducer (this.startState, ClickGamePlainActions.clickGameClick (1, 2)) ; expect (newState) .not.toBe (this.startState, "besoin d'avoir créé un nouvel objet d'état"); expect (newState.gameState) .toEqual (GameState.Started); expect (newState.getButton (1, 2) .state) .toEqual (ButtonGameState.DoNotClick); expect (newState.score) .toEqual (1);});

[/et_pb_accordion_item][et_pb_accordion_item _builder_version= »3.12.1″ title= »Sélecteur » use_background_color_gradient= »off » background_color_gradient_start= »#2b87da » background_color_gradient_end= »#29c4a9″ background_color_gradient_type= »linéaire » background_color_gradient_direction= »180de g" background_color_gradient_direction_radial="center" background_color_gradient_start_position=”0%” background_color_gradient_end_position=”100%” background_color_gradient_overlays_image=”off” parallax=”off” parallax_method=”on” background_size=”cover” background_position=”center” background_repeat=”no-repeat” background_blend=”normal” allow_player_pause =”off” background_video_pause_outside_viewport=”on” text_shadow_style=”none” box_shadow_style=”none” text_shadow_horizontal_length=”0em” text_shadow_vertical_length=”0em” text_shadow_blur_strength=”0em”]

Dans un état Redux, vérifiez que le sélecteur renvoie la valeur correcte:

it ("doit retourner faux si le bouton est introuvable dans un ClickGameState", function () {const state: HandbookState = {clickGame: null}; expect (ClickGameSelector.getButtonState (state, 0, 0)). toBeFalsy ();} ); it ("devrait renvoyer l'état de jeu de clic à partir de l'état du manuel global", function () {const clickGameState = jasmine.createSpy ("ClickGame state") comme n'importe quel; état de const: HandbookState = {clickGame: clickGameState}; expect (ClickGameSelector. getClickGameState (état)) .toBe (clickGameState);});

[/et_pb_accordion_item][et_pb_accordion_item _builder_version=”3.12.1″ title=”Thunked action Creator” use_background_color_gradient=”off” background_color_gradient_start=”#2b87da” background_color_gradient_end=”#29c4a9″ background_color_gradient_type=”linear” background_color_gradient_direction=”180 0deg" background_color_gradient_direction_radial=" center” background_color_gradient_start_position=”100%” background_color_gradient_end_position=”0%” background_color_gradient_overlays_image=”off” parallax=”off” parallax_method=”on” background_size=”cover” background_position=”center” background_repeat=”no-repeat” background_blend=”normal ” allow_player_pause=”off” background_video_pause_outside_viewport=”on” text_shadow_style=”none” box_shadow_style=”none” text_shadow_horizontal_length=”0em” text_shadow_vertical_length=”0em” text_shadow_blur_strength=”XNUMXem”]

Le créateur de l'action Thunked nous aide à répartir plusieurs actions en attendant la fin d'une fonction asynchrone, généralement un appel d'API REST.

Dans l'esprit des tests unitaires, nous supposons que les créateurs d'action simple ont déjà été testés. Pour le créateur de l'action thunked, nous contrôlons le résultat de la fonction asynchrone et attendons le bon ensemble d'actions à envoyer. Nous pouvons le faire en utilisant un faux magasin Redux. Dans cet exemple, la fonction asynchrone est un JavaScript setTimeout</var/www/wordpress>:

describe ("clickGameStartRound", function () {beforeEach (function () {jasmine.clock (). install (); this.gameState = new ClickGameState (); spyOn (ClickGameSelector, "getClickGameState") .and.returnValue (this. gameState); this.mockStore = new ReduxMockStore ({});}); afterEach (function () {jasmine.clock (). uninstall ();}); it ("ne devrait pas être envoyé si le jeu n'a pas commencé", function () {this.gameState.gameState = GameState.NotStarted; this.mockStore.dispatch (ClickGameActions.clickGameNewRound ()); expect (this.mockStore.getActions ()) .toEqual ([], "Attendez-vous à ne pas distribuer correctement away "); jasmine.clock (). tick (3001); expect (this.mockStore.getActions ()). toEqual ([]," Aucun nouveau round attendu ");}); it (" devrait envoyer un nouveau round tous les 3 secondes lorsque le jeu a commencé ", function () {this.gameState.gameState = GameState.Started; this.mockStore.dispatch (ClickGameActions.clickGameNewRound ()); expect (this.mockStore.getActions ()). ToEqual ([ ], "Attendez-vous à ce que le tour ne soit pas envoyé tout de suite"); jasmine.clock ( ) .tick (3001); expect (this.mockStore.getActions ()). toEqual ([{type: ClickGameActionType.NewRound}], "Attendez-vous à ce qu'un nouveau tour soit distribué après 3 secondes"); this.mockStore.clearActions (); jasmin.horloge (). tick (3001); expect (this.mockStore.getActions ()). toEqual ([{type: ClickGameActionType.NewRound}], "Attendez-vous à ce qu'un nouveau tour soit distribué après 6 secondes"); }); });

[/et_pb_accordion_item][/et_pb_accordion][et_pb_text _builder_version=”3.12.1″]

Tests d'intégration au niveau de la page

Maintenant que toutes nos unités sont testées, nous devons nous assurer qu'elles s'emboîtent correctement. Le but des tests d'intégration au niveau de la page (ou «tests de page» en abrégé) est de vérifier que les composants React interagissent correctement et que React et Redux fonctionnent correctement ensemble.

Des outils dont nous avons besoin

Nous avons dû résoudre deux problèmes pour écrire des tests de page.

Nous avons besoin d'un moyen de simuler généralement nos appels d'API REST. Nous avons créé AjaxManager qui intercepte tous les appels à $ .ajax et fournit des méthodes pour faire attendre, réussir ou échouer une requête.

Nous avons également besoin d'un moyen d'attendre par programmation que notre interface utilisateur change, avant de passer à l'étape suivante du test. Nous avons créé TestStepBuilder, un outil qui nous permet d’écrire des tests qui attendent que les conditions soient remplies avant de prendre plus d’étapes.

Dans le jeu de démonstration, l'action asynchrone est exécutée sur un minuteur, il n'y a donc pas d'exemple d'AjaxManager ici, mais il utilise TestStepBuilder pour parcourir les tests:

beforeAll (function () {this.handbookPage = new HandbookPage (); this.handbookPage.render (done);} afterAll (function () {this.handbookPage.cleanUp ();}); it ("devrait démarrer le jeu de en cliquant sur le bouton Démarrer ", function (done) {new TestStepBuilder () .step (" Vérifier que le jeu n'a pas démarré ", () => {expect (this.handbookPage.getGameStatus ()). toEqual (" Cliquez pour start ");}) .waitFor (" Bouton Démarrer ", () => {return this.handbookPage.findStartButton ();}) .step (" Cliquez sur le bouton Démarrer ", () => {this.handbookPage.findStartButton () .click ();}) .waitFor ("ClickGameTable to render", () => {return this.handbookPage.findClickGameTable ();}) .step ("Vérifier que le jeu est en cours", () => { expect (this.handbookPage.getGameStatus ()). toEqual ("En cours ...");}) .run (done);}); il ("devrait continuer le jeu du clic en cliquant sur un bouton vert", function (done) {new TestStepBuilder () .step ("Cliquez sur un bouton vert", () => {expect (this.handbookPage.getScore ()). toEqual (0, "score should be 0"); const $ greenButtons = this.handbookPage. $ findGreenButtons (); expect ($ greenButtons.length) .toBeGreaterThan (0, "devrait avoir au moins 1 bouton vert à la réinitialisation du jeu"); $ greenButtons [0] .click (); }) .waitFor ("score à augmenter", () => this.handbookPage.getScore ()> 0) .step ("Vérifier le score et l'état du jeu", () => {expect (this.handbookPage.getGameStatus ( )). toEqual ("En cours ..."); expect (this.handbookPage.getScore ()). toEqual (1, "le score devrait être 1");}). run (done); }); it ("devrait terminer le jeu et afficher le bouton de redémarrage lorsque vous cliquez sur un bouton rouge", function (done) {new TestStepBuilder () .step ("Click a red button", () => {expect (this.handbookPage.getScore) ()). toEqual (1, "score should be 1"); const $ redButtons: JQuery = this.handbookPage. $ findRedButtons (); expect ($ redButtons.length) .toBeGreaterThan (0, "doit être au moins un rouge bouton après avoir cliqué sur vert "); $ redButtons [0] .click ();}) .waitFor (" Redémarrer le bouton pour afficher ", () => {this.handbookPage.findRestartButton ();}) .step (" Vérifier que le jeu est terminé ", () => {expect (this.handbookPage.getScore ()). toEqual (1," score should stay 1 "); expect (this.handbookPage.getGameStatus ()). toEqual (" GAME OVER ");}). Run (done);});

Modèle de conception d'objet de page

Dans l'exemple de code ci-dessus, vous remarquerez que le code est indépendant de l'implémentation de la page.

Nous avons utilisé un modèle de conception documenté par Sélénium appelé modèle de conception d'objet de page. Dans l'exemple de code, HandbookPage est un objet page qui encapsule notre implémentation du composant de page du manuel React, et nous accédons à l'interface utilisateur uniquement via l'objet page dans nos tests.

Ce découplage présente deux avantages.

  1. Cela rend nos tests plus faciles à lire.
  2. Si nous modifions l’implémentation de la page, il suffit de mettre à jour l’objet de la page et pas les tests.

De cette manière, les tests de page décrivent uniquement la manière dont la page doit être testée et les détails de l'implémentation sont encapsulés dans l'objet de la page.

Tests système de bout en bout

Dans les tests de système d’interface utilisateur de bout en bout, nous développons un cluster Qumulo et nous exerçons notre interface utilisateur. Nous utilisons les mêmes outils que dans nos tests de page, en simulant des actions utilisateur et en inspectant l'interface utilisateur à l'aide d'objets de page, et nous effectuons des étapes de test à l'aide de TestStepBuilder.

L'objectif du systest est de vérifier que l'API se comporte correctement en exerçant l'interface utilisateur. Les tests de pages et les tests de bout en bout ont tendance à se chevaucher. Généralement, les tests de page se concentrent sur tous les différents événements asynchrones possibles (tels que les déconnexions de réseau), tandis que le système vérifie spécifiquement que l'interface utilisateur et l'API REST émettent les hypothèses correctes les uns sur les autres.

Les tests nous aident à avancer rapidement

Les tests nous aident à progresser rapidement car ils nous aident à produire moins de bogues, donc notre processus est moins interrompu par les corrections de bogues, ce qui signifie que nous nous concentrons sur plus de développement.

Au cours de l'année dernière, nous nous sommes éloignés d'un modèle avec une couche de tests d'intégration unitaires et une couche de tests système. Dans cet ancien modèle, les tests «unitaires» avaient trop de dépendances ce qui les rendait fragiles, alors que les tests système demandaient trop d'efforts à exécuter régulièrement pendant le développement.

Nous en avons tiré beaucoup de leçons Blog de Google Testing, qui décrit toutes les raisons pour lesquelles nous avons adopté le nouveau modèle avec trois couches de tests. Aujourd'hui, lorsqu'un test unitaire échoue, il nous donne des informations très spécifiques sur ce qui a été cassé. Le compilateur TypeScript garantit que notre code est correctement ajusté sur le plan de la syntaxe et de la sémantique, et les tests de page vérifient que nos composants React et notre code Redux ont les mêmes hypothèses sur l’autre. Cela laisse les tests du système moins efficaces pour garantir les contrats de l'API REST plutôt que d'essayer de vérifier l'exactitude de nos systèmes d'interface utilisateur.

Les bonnes pratiques de test nous ont aidés à progresser plus rapidement, car notre code évolue et évolue avec le temps, et la façon dont nous divisons les tests les rend plus faciles à écrire et à gérer tout en fournissant des informations beaucoup plus ciblées. Nous continuons à chercher de meilleurs moyens de développer notre code, et nous espérons pouvoir en partager davantage au fur et à mesure de notre apprentissage!

[/ Et_pb_text] [/ et_pb_column] [/ et_pb_row] [/ et_pb_section]

Articles Similaires

Remonter en haut