Redux: Test unitaire de composants intelligents

Créé le 20 août 2015  ·  19Commentaires  ·  Source: reduxjs/redux

J'étais en train de lire la section de test unitaire de la documentation et, bien qu'elle contienne un exemple de test d'un composant stupide, la possibilité de tester un "composant intelligent" (ceux qui utilisent la méthode connect ()) n'est pas couverte. Il s'avère que le test unitaire d'un composant intelligent est un peu plus compliqué à cause du composant wrapper créé par connect (). Une partie du problème est que l'encapsulation d'un composant via connect () nécessite qu'il y ait un prop (ou contexte) 'store'.

J'ai essayé de faire cela et j'espérais avoir un petit retour sur s'il y avait une meilleure façon de le faire. Et si ce que j'ai fait finit par paraître raisonnable, j'ai pensé que je ferais un PR pour ajouter des informations à ce sujet dans la documentation des tests unitaires.

Pour commencer, j'ai pris l'exemple de composant existant dans la section de test unitaire de la documentation et je l'ai enveloppé dans connect () pour transmettre les données des créateurs d'action liés à l'état et à la répartition:

Header.js (composant intelligent)
import React, { PropTypes, Component } from 'react';
import TodoTextInput from './TodoTextInput';
import TodoActions from '../actions/TodoActions';
import connect from 'redux-react';

class Header extends Component {
  handleSave(text) {
    if (text.length !== 0) {
      this.props.addTodo(text);
    }
  }

  render() {
    return (
      <header className='header'>
          <h1>{this.props.numberOfTodos + " Todos"}</h1>
          <TodoTextInput newTodo={true}
                         onSave={this.handleSave.bind(this)}
                         placeholder='What needs to be done?' />
      </header>
    );
  }
}

export default connect(
  (state) =>  {numberOfTodos: state.todos.length},
  TodoActions
)(Header);

Dans le fichier de test unitaire, il ressemble également à l'exemple.

Header.test.js
import expect from 'expect';
import jsdomReact from '../jsdomReact';
import React from 'react/addons';
import Header from '../../components/Header';
import TodoTextInput from '../../components/TodoTextInput';
const { TestUtils } = React.addons;

/**
 * Mock out the top level Redux store with all the required 
 * methods and have it return the provided state by default.
 * <strong i="13">@param</strong> {Object} state State to populate in store
 * <strong i="14">@return</strong> {Object} Mock store
 */
function createMockStore(state) {
  return {
    subscribe: () => {},
    dispatch: () => {},
    getState: () => {
      return {...state};
    }
  };
}

/**
 * Render the Header component with a mock store populated
 * with the provided state
 * <strong i="15">@param</strong> {Object} storeState State to populate in mock store
 * <strong i="16">@return</strong> {Object} Rendered output from component
 */
function setup(storeState) {
  let renderer = TestUtils.createRenderer();
  renderer.render(<Header store={createMockStore(storeState)} />);
  var output = renderer.getRenderedOutput();
  return output.refs.wrappedInstance();
}

describe('components', () => {
  jsdomReact();

  describe('Header', () => {
    it('should call call addTodo if length of text is greater than 0', () => {
      const output = setup({
        todos: [1, 2, 3]
      });
      var addTodoSpy = expect.spyOn(output.props, 'addTodo');

      let input = output.props.children[1];
      input.props.onSave('');
      expect(addTodoSpy.calls.length).toBe(0);
      input.props.onSave('Use Redux');
      expect(addTodoSpy.calls.length).toBe(1);
    });
  });
});

J'ai un peu simplifié ce test pour ne montrer que les parties pertinentes, mais le point principal dont je ne suis pas sûr est la méthode createMockStore . Si vous essayez de rendre le composant Header sans aucun accessoire, une erreur est renvoyée par Redux (ou react-redux) indiquant que le composant doit avoir un prop ou un contexte store car il s'attend à être un enfant du <Provider> composant. Comme je ne veux pas l'utiliser pour mes tests unitaires, j'ai créé une méthode pour le simuler et permettre au test de passer dans l'état qu'il souhaite définir dans le magasin.

L'avantage que je peux voir de cette approche est qu'elle me permet de tester les fonctions au sein de mon composant, mais aussi de pouvoir tester la fonctionnalité des méthodes que je passe dans connect (). Je pourrais facilement écrire ici une autre assertion qui fait quelque chose comme expect(output.props.numberOfTodos).toBe(3) qui vérifie que ma fonction mapStateToProps fait ce que j'attends.

Le principal inconvénient est que je dois me moquer du magasin Redux, qui n'est pas si complexe, mais j'ai l'impression que cela fait partie de la logique interne de Redux et pourrait changer. Évidemment, pour mes tests unitaires, j'ai déplacé ces méthodes dans un fichier d'utilitaire de test unitaire général, donc si les méthodes de stockage changeaient, je n'aurais à modifier mon code qu'à un seul endroit.

Pensées? Quelqu'un d'autre a-t-il expérimenté des tests unitaires de composants intelligents et trouvé une meilleure façon de faire les choses?

discussion question

Commentaire le plus utile

@ernieturner Je pense que @ghengeveld voulait dire que si vous utilisez des modules ES6 partout, le composant Header décoré pourrait toujours être exporté par défaut, et un composant React simple pourrait être une exportation nommée supplémentaire. Par exemple -

//header.js
export class HeaderDumbComponent {
  render() {
    return <header><div>...</div></header>;
  }
}

export default connect(
  (state) =>  {numberOfTodos: state.todos.length},
  TodoActions
)(HeaderDumbComponent);

Avec cette double exportation, votre application principale peut consommer le composant intelligent comme auparavant avec import Header from './header.js' tandis que vos tests peuvent contourner le redux et tester le comportement du composant principal directement avec import {HeaderDumbComponent} from '../components/header.js'

Tous les 19 commentaires

L'alternative consiste à exporter également la classe d'en-tête (non décorée), afin qu'elle puisse être importée et testée séparément.

De plus, vous n'avez pas besoin de l'appel jsdomReact dans chaque fichier, du moins si vous utilisez Mocha. Voici mon setup.js:

import jsdom from 'jsdom';
import ExecutionEnvironment from 'react/lib/ExecutionEnvironment';

if (!global.document || !global.window) {
  global.document = jsdom.jsdom('<!doctype html><html><body></body></html>');
  global.window = document.defaultView;
  global.navigator = window.navigator;

  ExecutionEnvironment.canUseDOM = true;

  window.addEventListener('load', () => {
    console.log('JSDom setup completed: document, window and navigator are now on global scope.');
  });
}

Il est chargé via l'option de ligne de commande --require: mocha -r babelhook -r test/setup --recursive (babelhook est un appel à require('babel-core/register') ).

Oui, je pourrais exporter à la fois la classe Header et celle décorée, mais j'espérais éviter d'avoir à changer le code source juste pour mes tests unitaires. Faire cela me ferait alors devoir changer tous les endroits qui incluent un composant décoré (par exemple import {DecoratedHeader} from './components/Header' au lieu de seulement import Header from . / Components / Header`).

En ce qui concerne la configuration de jsdom, je copiais simplement l'exemple de la documentation afin de montrer le cas d'utilisation, je ne l'utilise pas comme configuration principale, juste un exemple.

Je viens de remarquer que mon exemple de code ci-dessus était incomplet. Afin de tester directement un composant intelligent, vous devez demander à une méthode utilitaire de vous renvoyer le refs.wrappedInstance au lieu du résultat du rendu, car cela vous donnera le composant décoré par la connexion (mis à jour dans la fonction de configuration ci-dessus) . Cela rend encore un peu les tests unitaires dépendants des internes Redux (dans ce cas, en particulier les internes react-redux). Donc ça marche, mais ça semble un peu fragile.

@ernieturner Nous proposons également getWrappedInstance() API publique pour cela afin que vous puissiez vous y fier si vous craignez d'accéder directement à refs . Et il existe également des utilitaires tels que react-test-tree pour rendre cela transparent.

@ernieturner Je pense que @ghengeveld voulait dire que si vous utilisez des modules ES6 partout, le composant Header décoré pourrait toujours être exporté par défaut, et un composant React simple pourrait être une exportation nommée supplémentaire. Par exemple -

//header.js
export class HeaderDumbComponent {
  render() {
    return <header><div>...</div></header>;
  }
}

export default connect(
  (state) =>  {numberOfTodos: state.todos.length},
  TodoActions
)(HeaderDumbComponent);

Avec cette double exportation, votre application principale peut consommer le composant intelligent comme auparavant avec import Header from './header.js' tandis que vos tests peuvent contourner le redux et tester le comportement du composant principal directement avec import {HeaderDumbComponent} from '../components/header.js'

@ eugene1g @ghengeveld C'est en fait une très bonne solution au problème. N'hésitez pas à envoyer un PR au doc ​​"Tests d'écriture" pour l'expliquer!

@ eugene1g C'est exactement ce que je voulais dire.
@gaearon je vais faire ça.

@ghengeveld Toutes mes excuses pour le malentendu. J'utilise toujours des importations de style CommonJS, donc je suis encore assez rouillé sur les modules ES6. Cela semble être une bonne solution à exposer dans la documentation. Je suppose que vous pouvez également exposer les méthodes mapStateToProps / mapDispatchToProps pour permettre à celles-ci d'être testées sans avoir à se moquer du magasin, par exemple

export class HeaderDumpComponent ...

export function mapStateToProps(state) ... 
export function mapDispatchToProps(dispatch) ...

export default connect(mapStateToProps, mapDispatchToProps)(HeaderDumpComponent);

Il est fort possible que la plupart des méthodes de carte soient suffisamment simples pour ne pas nécessiter de test unitaire, mais j'en ai quelques-unes qui mériteraient d'être vérifiées car elles sont une partie cruciale de la fonctionnalité de mes composants.

Merci pour les suggestions à tous, si vous avez besoin d'aide avec la documentation, faites-le moi savoir.

Nous essayons actuellement l'approche recommandée décrite ci-dessus, mais nous ne nous sentons pas tout à fait à l'aise avec elle. Cela laisse les paramètres passés à connect non testés en dehors des tests d'intégration. Je comprends qu'un magasin fictif pourrait être potentiellement fragile, mais j'ai l'impression que c'est là que quelque chose comme redux-mock-store pourrait intervenir, s'il pouvait prendre en charge les tests de composants. Que pensez-vous de cette direction?

@carpeliam

Pourquoi ne pas simplement utiliser un magasin régulier et l'hydrater avec un état initial particulier?
Vous n'avez besoin de simuler le magasin que si vous souhaitez tester quelles actions sont distribuées.

@carpeliam

Veuillez consulter les tests unitaires pour les exemples counter et todomvc dans ce dépôt.

Nous avions un problème où nous rendions un composant intelligent à l'intérieur d'un autre composant et ne pouvions pas utiliser le rendu superficiel (le composant utilisait les méthodes componentDid *). Ce que nous avons fait, c'est stub (en utilisant sinon) la fonction connect pour renvoyer une fonction qui retournait un simple composant React.

C'est fragile et nous espérons passer au moteur de rendu peu profond une fois que nous pourrons migrer vers React 0.14, mais cette méthode nous a débloqué sur nos tests pour le moment.

J'ai aussi des problèmes avec ça. Je suis plutôt nouveau dans React et Redux, donc c'est probablement ce qui me retient. Comment avez-vous pu le contourner? @songawee

La méthode d'exportation double proposée laisse la fonction select (mapStoreToState) non testée. Afin de le tester indépendamment, il doit également être exporté, un autre changement dans le nom du test.

Je serais intéressé de trouver un moyen de faire en sorte que shallowRenderer fonctionne avec le composant enveloppé de connect. Mon problème actuel est que lorsque vous utilisez un moteur de rendu peu profond, il ne renvoie que le composant Connect, ce qui est normal.

La méthode d'exportation double proposée laisse la fonction select (mapStoreToState) non testée. Afin de le tester indépendamment, il doit également être exporté, un autre changement dans le nom du test.

Pas strictement «au nom du test». En fait, nous vous encourageons à définir des fonctions de sélection (ce qui est finalement mapStateToProps ) à côté de vos réducteurs, et à les tester ensemble. Cette logique est indépendante de l'interface utilisateur et ne doit pas être associée à celle-ci. Jetez un œil à l'exemple shopping-cart pour certains sélecteurs.

Alors @gaearon ,

Oui, le modèle général suggéré pour Redux est d'utiliser les fonctions de sélection un peu partout, plutôt que d'accéder directement à state.some.nested.field . Il peut s'agir de fonctions «simples» très simples, mais elles sont le plus souvent assemblées à l'aide de la bibliothèque Reselect, qui fournit des capacités de mémorisation.

Pourquoi cela créerait-il des frais généraux supplémentaires?

J'ai fait la double exportation comme décrit ici mais en lisant la source connect j'ai réalisé que le "Container" gardait une référence au composant "dumb" "dans la propriété statique WrappedComponent :

Donc au lieu de:

// header.js
export const HeaderDumbComponent = (props) => <header><div>...</div></header>
export default connect(mapStateToProps)(HeaderDumbComponent)


// header.spec.js
import { HeaderDumbComponent } from './header'

it('renders', () => {
  expect(<HeaderDumbComponent />).to.not.be.null
})

Vous pouvez éviter la double exportation en utilisant la WrappedComponent où vous avez besoin de la version "stupide":

// header.js
const HeaderDumbComponent = (props) => <header><div>...</div></header>
export default connect(mapStateToProps)(HeaderDumbComponent)


// header.spec.js
import Header from './header'

it('renders', () => {
  expect(<Header.WrappedComponent />).to.not.be.null
})

@gaearon - WrappedComponent semble être une propriété assez stable étant donné qu'elle est dans la documentation. Recommanderiez-vous de ne pas l'utiliser de cette manière pour quelque raison que ce soit?

Ce que je fais est d'exporter le composant déballé, et également d'exporter mapStateToProps et mapDispatchToProps afin que je puisse tester ces fonctions. Voici un exemple:

import {ManageCoursePage, mapStateToProps, mapDispatchToProps} from './ManageCoursePage';
describe("mapStateToProps", () => {
    function setup(courses, authors, id) {
        const state = {
            authors: authors
        };

        const ownProps = {
            params: {
                id: id
            }
        };

        return mapStateToProps(state, ownProps);
    }

    it('sets the course when a course id is set', () => {
        const courses = [ { id: "1", foo: "bar" }, { id: "2", foo: "notbar" } ];
        const authors = [];
        const id = "1";

        const props = setup(courses, authors, id);

        expect(props.course).toBe(courses[0]);
    });

    it('sets the course when a course id is not set', () => {
        const courses = [ { id: "1", foo: "bar" }, { id: "2", foo: "notbar" } ];
        const authors = [];

        const props = setup(courses, authors, null);

        expect(props.course).toEqual({});
    });

    it('sets the course when a course id is set that is not present in the courses list', () => {
        const courses = [ { id: "1", foo: "bar" }, { id: "2", foo: "notbar" } ];
        const authors = [];
        const id = "42";

        const props = setup(courses, authors, null);

        expect(props.course).toEqual({});
    });

    it('sets the authors formatted for a drop down list', () => {
        const courses = [];
        const authors = [ { id: "1", name: "John" }, { id: "2", name: "Jill" }];

        const props = setup(courses, authors, null);

        expect(props.authors).toEqual([
            { value: "1", text: "John" },
            { value: "2", text: "Jill" }
        ])
    });
});
Cette page vous a été utile?
0 / 5 - 0 notes

Questions connexes

elado picture elado  ·  3Commentaires

timdorr picture timdorr  ·  3Commentaires

benoneal picture benoneal  ·  3Commentaires

cloudfroster picture cloudfroster  ·  3Commentaires

vslinko picture vslinko  ·  3Commentaires