Cómo probamos nuestra UI React / Redux, y por qué nos ayuda a movernos rápidamente

[Nota del autor: esta publicación de blog acompaña a la reunión de Seattle React.js que organizamos el 19 de septiembre de 2018, ver la grabación]

En Qumulo, construimos un sistema de archivos distribuidos de escalamiento horizontal, y resulta que construir un sistema de este tipo es difícil.

Tenemos capas sobre capas de subsistemas, todos trabajando juntos para formar el producto que llamamos Qumulo File Fabric. En general, el sistema es complejo y nuestros clientes dependen de él para almacenar sus datos valiosos.

Como ingenieros de Qumulo, trabajamos en un entorno de integración continua (CI), y las pruebas automatizadas son esenciales para garantizar que todo el código que desarrollamos funcione correctamente. Podemos estar seguros de que los datos de nuestros clientes están seguros incluso cuando cambiamos y desarrollamos activamente el software.

De abajo hacia arriba, nos preocupamos profundamente por las pruebas. Y en la parte superior de la pila, la interfaz de usuario es un lugar muy exclusivo donde los requisitos del usuario se exponen directamente. Hemos repetido muchas formas diferentes de probar nuestra interfaz de usuario de React / Redux. Me gustaría compartir a dónde nos llevó nuestra experiencia.

Las pruebas automatizadas son una inversión digna

El acto de escribir pruebas es caro. Mi experiencia anterior ha sido que, a pesar de que existe un objetivo comercial para tener una "buena cobertura de pruebas", también existe la presión de entregar más rápidamente, y terminamos escribiendo algunas pruebas de humo automatizadas aquí y allá, ya que pagamos dinero feliz Control de calidad para probar manualmente. ¡Diablos, incluso poblaron y administraron la base de datos de errores por nosotros!

En Qumulo, aprendí que las pruebas automatizadas son una gran inversión para el largo plazo. Cambiamos y refactorizamos el código a lo largo del tiempo, y cada vez que cambiamos el código, las pruebas nos protegen de cambiar inadvertidamente el comportamiento del sistema. Con nuestra cobertura de prueba completa, nuestra migración actual de ES6 a TypeScript como ejemplo se ha desarrollado sin problemas, por lo que podemos enfocarnos en la migración en lugar de verificar manualmente que la migración del código se realiza correctamente.

En el mundo de JavaScript, las bibliotecas de software pueden cambiar muy rápidamente, y algunas veces la actualización de una biblioteca es muy perjudicial. Nuestro conjunto de pruebas nos permite tener tranquilidad cuando actualizamos las bibliotecas, porque esperamos que los cambios de última hora también rompan nuestras pruebas de interfaz de usuario. Nuestros años de inversión en pruebas nos permiten realizar estas actualizaciones rápidamente para poder concentrarnos en el desarrollo y ahorrar mucho tiempo y energía con cada cambio de código.

La piramide de prueba

Hoy, escribimos nuestro código UI en React y Redux. En el nivel más pequeño, podemos imaginar que necesitamos pruebas unitarias para estas piezas - Reaccionar componentes, Redux reductores, creadores de acción, selectores.

Los juntamos para formar páginas, luego juntamos las páginas para construir la aplicación. Nuestro pruebas de integración siga la misma estructura: escribimos pruebas de página para verificar que el flujo de usuarios a través de una página sea el esperado, y escribimos de extremo a extremo pruebas del sistema para asegurarnos de que todo nuestro sistema funciona en conjunto. Esto encaja muy bien con La pirámide de prueba, conceptualizada por Martin Fowler en 2012..

La pirámide de pruebas sugiere que escribamos muchas pruebas unitarias, algunas pruebas de página y algunas pruebas de extremo a extremo. Cuando las pruebas de unidad fallan, deberían acercarnos mucho a la línea de código que falló; las pruebas de página deben fallar cuando nuestras partes React y Redux están haciendo suposiciones incorrectas entre sí; y las pruebas de extremo a extremo deberían fallar si nuestra interfaz de usuario está haciendo suposiciones incorrectas sobre nuestro backend.

Reaccionar unidad de prueba

En el pasado, probamos nuestros componentes React de una manera muy ingenua. Representamos el componente en el navegador y verificamos el DOM para ver si se procesó correctamente.

A medida que nuestro código base creció, las dependencias se convirtieron en un gran problema. Por ejemplo, si un componente reutilizado comúnmente se actualiza para acceder al almacén de Redux, es probable que todas las pruebas para sus usuarios deban actualizarse. Una solución podría ser probar todo con una tienda Redux provista, pero eso aumenta nuestro alcance de prueba (casi siempre queremos burlarnos de la tienda Redux como inyección de dependencia). Además, estas fallas de prueba no nos ayudan en nuestro desarrollo. La tienda Redux se proporciona a nivel de la aplicación, por lo que estas fallas de prueba apuntan a un error en la prueba y no en nuestro producto, lo que significa que dedicamos nuestro tiempo a realizar pruebas.

Tuvimos que aprender a tener muy claro qué es una unidad. En un juego que desarrollé para ilustrar las mejores prácticas y pruebas de React, los componentes están en capas como tales:

Consideremos las pruebas unitarias para 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.

  1. It makes our tests easier to read.
  2. 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!