[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: the orange arrows are the inputs and outputs. When we consider this diagram closely, we realize that if we use a shallow renderer, 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 * as React from "react";
import { createRenderer } from "react-test-renderer/shallow";
describe("ClickGame", function() {
beforeEach(function() {
this.shallowRender = (gameState: ClickGameState) => {
this.resetGameSpy = jasmine.createSpy("resetGame");
this.shallowRenderer.render(
<ClickGame.WrappedComponent
gameState={gameState}
resetGame={this.resetGameSpy}
/>
);
return 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("should render start game prompt", function() {
expect(this.renderOutput).toHaveShallowRendered(
<h2>A game of clicks: {"Click to start"}</h2>
);
});
it("should reset game on button click", function() {
const buttons = scryDomNodesInShallowRenderTreeByType(
this.renderOutput,
"button"
);
expect(buttons.length).toEqual(1);
const button = buttons[0] as
React.ReactHTMLElement<HTMLButtonElement>;
button.props.onClick(jasmine.createSpy("buttonEvent") as any);
expect(this.resetGameSpy).toHaveBeenCalledWith();
});
});
});
We used react-redux to help us connect the component to Redux, and connect() provides a static member WrappedComponent which is the original component we implemented. Unit testing WrappedComponent directly allows us to mock out Redux by directly accessing the props managed by react-redux.
Redux unit testing
Testing the basic Redux is pretty straightforward.
Action Creator
Verify the action creator returns the correct action with expected payload:
describe("clickGameClick", function() {
it("should create a click action", function() {
expect(ClickGameActions.clickGameClick(2, 3)).toEqual({
payload: {
col: 3,
row: 2
},
type: ClickGameActionType.Click
});
});
it("should throw if row index is invalid", function() {
expect(() => ClickGameActions.clickGameClick(-1, 0)).toThrow();
});
it("should throw if col index is invalid", function() {
expect(() => ClickGameActions.clickGameClick(0, -1)).toThrow();
});
});
Reducer
Given an initial state and a relevant action, verify reducer returns the expected state:
it("should reduce a 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,
"need to have created a new state object"
);
expect(newState.gameState).toEqual(GameState.Started);
expect(newState.getButton(1, 2).state).toEqual(ButtonGameState.DoNotClick);
expect(newState.score).toEqual(1);
});
Selector
Given a Redux state, verify selector returns the right value:
it("should return falsy if button cannot be found in a ClickGameState", function() {
const state: HandbookState = {
clickGame: null
};
expect(ClickGameSelector.getButtonState(state, 0, 0)).toBeFalsy();
});
it("should return the click game state from the global handbook state", function() {
const clickGameState = jasmine.createSpy("ClickGame state") as any;
const state: HandbookState = {
clickGame: clickGameState
};
expect(ClickGameSelector.getClickGameState(state))
.toBe(clickGameState);
});
Thunked action Creator
Thunked action creator helps us dispatch multiple actions as we wait for an asynchronous function to complete, usually a REST API call.
In the spirit of unit testing, we assume the plain action creators are already tested. For the thunked action creator, we control the result of the asynchronous function and expect the correct set of actions to be dispatched. We can do this using a mock Redux store. In this example, the asynchronous function is a JavaScript setTimeout:
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("should not dispatch if the game has not started", function() {
this.gameState.gameState = GameState.NotStarted;
this.mockStore.dispatch(ClickGameActions.clickGameNewRound());
expect(this.mockStore.getActions())
.toEqual(
[],
"Expect round to not dispatch right away"
);
jasmine.clock().tick(3001);
expect(this.mockStore.getActions()).toEqual([], "Expected no new rounds");
});
it("should dispatch new round every 3 seconds when the game has started", function() {
this.gameState.gameState = GameState.Started;
this.mockStore.dispatch(ClickGameActions.clickGameNewRound());
expect(this.mockStore.getActions()).toEqual(
[], "Expect round to not dispatch right away");
jasmine.clock().tick(3001);
expect(this.mockStore.getActions()).toEqual(
[
{
type: ClickGameActionType.NewRound
}
],
"Expect a new round to be dispatched after 3 seconds"
);
this.mockStore.clearActions();
jasmine.clock().tick(3001);
expect(this.mockStore.getActions()).toEqual(
[
{
type: ClickGameActionType.NewRound
}
],
"Expect a new round to be dispatched after 6 seconds"
);
});
});
Page-level integration tests
Now that all of our units are tested, we need to make sure they fit together correctly. The goal of the page-level integration tests (or "page tests" for short), are to verify that React components are interacting correctly, and that React and Redux are working together correctly.

Tools we need
There were two problems we need to solve to write page tests.
We need a way to generally mock out our REST API calls. We created the AjaxManager which intercepts all calls to $.ajax and provides methods to make a request wait, succeed, or fail.
We also need a way to programmatically wait for our UI to change before taking the next step in the test. We created TestStepBuilder, which is a tool that that allows us to write tests that wait for conditions to be met before taking more steps.
In the demo game, the asynchronous action is taken on a timer, so there is no example of the AjaxManager here, but it makes use of the TestStepBuilder to step through the tests:
beforeAll(function() {
this.handbookPage = new HandbookPage();
this.handbookPage.render(done);
}
afterAll(function() {
this.handbookPage.cleanUp();
});
it("should start the game of clicking when click on the start button", function(done) {
new TestStepBuilder()
.step("Verify game has not started", () => {
expect(this.handbookPage.getGameStatus()).toEqual("Click to start");
})
.waitFor("Start button", () => {
return this.handbookPage.findStartButton();
})
.step("Click the start button", () => {
this.handbookPage.findStartButton().click();
})
.waitFor("ClickGameTable to render", () => {
return this.handbookPage.findClickGameTable();
})
.step("Verify game is in progress", () => {
expect(this.handbookPage.getGameStatus()).toEqual("In progress...");
})
.run(done);
});
it("should continue the game of clicking when click on a green button", function(done) {
new TestStepBuilder()
.step("Click a green button", () => {
expect(this.handbookPage.getScore()).toEqual(0, "score should be 0");
const $greenButtons = this.handbookPage.$findGreenButtons();
expect($greenButtons.length).toBeGreaterThan(
0,
"should have at least 1 green button at game reset"
);
$greenButtons[0].click();
})
.waitFor("score to go up", () => this.handbookPage.getScore() > 0)
.step("Verify score and game status", () => {
expect(this.handbookPage.getGameStatus()).toEqual("In progress...");
expect(this.handbookPage.getScore()).toEqual(1, "score should be 1");
}).run(done);
});
it("should end the game and show restart button when click on a red button", 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,
"should be at least one red button after green was clicked"
);
$redButtons[0].click();
})
.waitFor("Restart button to show", () => {
this.handbookPage.findRestartButton();
})
.step("Verify that the game is over", () => {
expect(this.handbookPage.getScore()).toEqual(1, "score should stay 1");
expect(this.handbookPage.getGameStatus()).toEqual("GAME OVER");
}).run(done);
});
Page object design pattern
In the sample code above, you will notice that the code is agnostic to the page implementation.
We made use of a design pattern documented by Selenium called page object design pattern. In the sample code, HandbookPage is a page object which wraps our implementation of the React handbook page component, and we access the UI only via the page object in our tests.
This decoupling has two advantages.
- It makes our tests easier to read.
- If we ever change the implementation of the page, we only need to update the page object and not the tests.
This way the page tests only describe how the page should be tested, and the implementation details are encapsulated in the page object.
End-to-end system tests
In end-to-end UI system tests, we spin up a Qumulo cluster and exercise our UI. We use the same tools as in our page tests, simulating user actions and inspecting the UI using page objects, and work through test steps using TestStepBuilder.
The goal of the systest is to verify that the API is behaving correctly by exercising the UI. There tends to be a lot of overlap between the page tests and end-to-end tests. Usually, the page tests focus on all the different possible asynchronous events (such as network disconnects), whereas the systest specifically checks that the UI and REST API are making the correct assumptions about one another.
Tests help us move fast
Tests help us move fast because they help us produce less bugs, so our workflow gets interrupted less by bug fixes, which means we focus on more development.
Over the last year, we moved away from a model with a layer of unit-ish integration tests and a layer of system tests. In this old model, the "unit" tests had too many dependencies which makes them fragile, while the system tests took too much effort to run regularly during development.
We learned a lot of lessons from this Google Testing Blog post, which describes all the reasons why we moved to the new model with three layers of tests. Today when a unit test fails, it gives us very specific information on what was broken. The TypeScript compiler ensures that our code is syntactically and semantically fitting together correctly, and the page tests check that our React components and Redux code have the correct assumptions about each other. This leaves the system tests to have a much smaller job of ensuring the REST API contracts rather than trying to verify correctness in our UI systems.
Good testing practices have helped us move faster because we are confident in our code as it grows and evolves over time, and the way we break up the tests today makes them easier to write and maintain while giving us much more targeted information when tests fail. We continue to drive for better ways to develop our code, and we hope to share more as we continue our learning!