Redux: Prueba unitaria de componentes inteligentes

Creado en 20 ago. 2015  ·  19Comentarios  ·  Fuente: reduxjs/redux

Estaba leyendo la sección de pruebas unitarias de la documentación y, aunque tiene un ejemplo de cómo probar un componente tonto, no se cubre la posibilidad de probar un "componente inteligente" (aquellos que usan el método connect ()). Resulta que la prueba unitaria de un componente inteligente es un poco más complicada debido al componente contenedor que crea connect (). Parte del problema es que envolver un componente a través de connect () requiere que haya un prop (o contexto) de 'tienda'.

Intenté intentar hacer esto y esperaba recibir algunos comentarios sobre si había una mejor manera de lograrlo. Y si lo que he hecho termina pareciendo razonable, pensé que presionaría un PR para agregar información sobre esto en la documentación de prueba unitaria.

Para comenzar, tomé el componente de ejemplo existente en la sección de prueba unitaria de los documentos y lo envolví en connect () para pasar datos de los creadores de acciones vinculadas al estado y al envío:

Header.js (componente inteligente)
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);

En el archivo de prueba unitaria también se ve similar al ejemplo.

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);
    });
  });
});

He simplificado un poco esta prueba para mostrar las partes relevantes, pero el punto principal del que no estoy seguro es el método createMockStore . Si intenta renderizar el componente Header sin ningún accesorio, Redux (o react-redux) arroja un error diciendo que el componente debe tener un store prop o contexto, ya que espera ser un hijo <Provider> componente. Como no quiero usar eso para mis pruebas unitarias, creé un método para simularlo y permitir que la prueba pase en el estado que quiere que se establezca en la tienda.

El beneficio que puedo ver de este enfoque es que me permite probar las funciones dentro de mi componente, pero también poder probar la funcionalidad de los métodos que estoy pasando a connect (). Podría escribir fácilmente otra afirmación aquí que haga algo como expect(output.props.numberOfTodos).toBe(3) que verifique que mi función mapStateToProps está haciendo lo que espero.

La principal desventaja es que tengo que burlarme de la tienda Redux, que no es tan compleja, pero parece que es parte de la lógica interna de Redux y podría cambiar. Obviamente, para mis pruebas unitarias, moví estos métodos a un archivo de utilidad de prueba unitaria general, por lo que si los métodos de la tienda cambiaran, solo tendría que modificar mi código en un lugar.

Pensamientos ¿Alguien más ha experimentado con pruebas unitarias de componentes inteligentes y ha encontrado una mejor manera de hacer las cosas?

discussion question

Comentario más útil

@ernieturner Creo que lo que quiso decir

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

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

Con esta exportación dual, su aplicación principal puede consumir el componente inteligente como antes con import Header from './header.js' mientras que sus pruebas pueden omitir redux y probar el comportamiento del componente central directamente con import {HeaderDumbComponent} from '../components/header.js'

Todos 19 comentarios

La alternativa es exportar también la clase Header (sin decorar), para que pueda importarse y probarse por separado.

Además, no necesita la llamada jsdomReact en cada archivo, al menos si está usando Mocha. Aquí está mi 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.');
  });
}

Se carga a través de la opción de línea de comando --require: mocha -r babelhook -r test/setup --recursive (babelhook es una llamada a require('babel-core/register') ).

Sí, podría exportar tanto la clase Header como la decorada, pero esperaba evitar tener que cambiar el código fuente solo para mis pruebas unitarias. Hacer eso me haría tener que cambiar todos los lugares que incluyen un componente decorado (por ejemplo, import {DecoratedHeader} from './components/Header' lugar de solo import Header from . / Components / Header`).

En cuanto a la configuración de jsdom, simplemente estaba copiando el ejemplo de los documentos como una forma de mostrar el caso de uso, no lo estoy usando como mi configuración principal, solo un ejemplo.

Acabo de notar que mi ejemplo de código anterior estaba incompleto. Para probar un componente inteligente directamente, debe tener algún método de utilidad que le devuelva el refs.wrappedInstance lugar de solo el resultado de la renderización, ya que eso le dará el componente decorado con conexión (actualizado en la función de configuración anterior) . Esto nuevamente hace que las pruebas unitarias dependan de los internos de Redux (en este caso, específicamente los internos de react-redux). Entonces funciona, pero se siente un poco frágil.

@ernieturner También ofrecemos getWrappedInstance() API pública para esto, por lo que puede confiar en ella si está preocupado por acceder a refs directamente. Y hay utilidades como react-test-tree para hacer esto transparente también.

@ernieturner Creo que lo que quiso decir

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

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

Con esta exportación dual, su aplicación principal puede consumir el componente inteligente como antes con import Header from './header.js' mientras que sus pruebas pueden omitir redux y probar el comportamiento del componente central directamente con import {HeaderDumbComponent} from '../components/header.js'

@ eugene1g @ghengeveld Esta es una muy buena solución al problema. ¡No dude en enviar un PR al documento "Pruebas de escritura" explicándolo!

@ eugene1g Eso es exactamente lo que quise decir.
@gaearon lo haré.

@ghengeveld Disculpas por el malentendido. Todavía estoy usando importaciones de estilo CommonJS, así que todavía estoy bastante oxidado con los módulos ES6. Parece una buena solución para exponer en los documentos. Supongo que también podría exponer los métodos mapStateToProps / mapDispatchToProps para permitir que se prueben sin tener que lidiar con burlarse de la tienda, por ejemplo

export class HeaderDumpComponent ...

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

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

Es muy posible que la mayoría de los métodos de mapas sean lo suficientemente simples como para no necesitar pruebas unitarias, pero tengo algunos de ellos que valdría la pena verificar, ya que son una parte crucial de la funcionalidad de mis componentes.

Gracias a todos por las sugerencias, si necesitan ayuda con la documentación, hágamelo saber.

Estamos probando el enfoque recomendado como se describe anteriormente en este momento, pero no nos sentimos del todo cómodos con él. Esto deja los parámetros pasados ​​a connect sin probar fuera de las pruebas de integración. Entiendo que una tienda simulada podría ser potencialmente frágil, pero creo que aquí es donde algo como redux-mock-store podría entrar, si pudiera admitir la prueba de componentes. ¿Cómo te sientes en esa dirección?

@carpeliam

¿Por qué no usar una tienda habitual e hidratarla con un estado inicial particular?
Solo necesita simular la tienda si desea probar qué acciones se están enviando.

@carpeliam

Consulte las pruebas unitarias para los ejemplos de counter y todomvc en este repositorio.

Teníamos un problema en el que estábamos renderizando un componente inteligente dentro de otro componente y no podíamos usar el renderizador superficial (el componente usaba los métodos componentDid *). Lo que hicimos fue stub (usando sinon) la función de conexión para devolver una función que devolvía un componente React simple.

Es frágil y esperamos pasar al renderizador superficial una vez que podamos migrar a React 0.14, pero este método nos desbloqueó en nuestras pruebas por el momento.

También estoy teniendo problemas con esto. Soy bastante nuevo tanto en React como en Redux por lo que es probable que esto sea lo que me frene. ¿Cómo pudiste evitarlo? @songawee

El método de exportación doble propuesto deja sin probar la función select (mapStoreToState). Para probarlo de forma independiente, también debe exportarse, otro cambio más en el nombre de la prueba.

Me interesaría encontrar una manera de hacer que shallowRenderer funcione con el componente envuelto de connect. Mi problema actual es que cuando uso el renderizador superficial, solo devuelve el componente Connect, lo cual es de esperar.

El método de exportación doble propuesto deja sin probar la función select (mapStoreToState). Para probarlo de forma independiente, también debe exportarse, otro cambio más en el nombre de la prueba.

No estrictamente "en nombre de la prueba". De hecho, le recomendamos que defina funciones de selector (que es lo que es mapStateToProps última instancia) junto con sus reductores, y que los pruebe juntos. Esta lógica es independiente de la interfaz de usuario y no tiene que estar incluida con ella. Eche un vistazo al ejemplo de shopping-cart para algunos selectores.

Entonces @gaearon , ¿estás sugiriendo obtener todos los datos del estado usando estas funciones de _selector_? ¿No introduce eso una gran cantidad de gastos generales innecesarios, ya que en la mayoría de los casos las personas simplemente leerán un montón de propiedades del estado y las asignarán a accesorios en los componentes?

Sí, el patrón general sugerido para Redux es usar funciones de selector prácticamente en todas partes, en lugar de acceder a state.some.nested.field directamente. Pueden ser funciones "simples" muy sencillas, pero normalmente se combinan utilizando la biblioteca Reselect, que proporciona capacidades de memorización.

¿Por qué eso crearía una sobrecarga adicional?

He estado haciendo la exportación doble como se describe aquí, pero leyendo la fuente connect me di cuenta de que el "Contenedor" mantiene una referencia al componente "tonto" "en la propiedad estática WrappedComponent :

Entonces en lugar 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
})

Puede evitar la exportación doble usando WrappedComponent donde necesita la versión "tonta":

// 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 parece ser una propiedad bastante estable dado que está en los documentos. ¿Aconsejaría no usarlo de esta manera por alguna razón?

Lo que hago es exportar el componente sin empaquetar y también exportar mapStateToProps y mapDispatchToProps para poder probar esas funciones. Aquí hay un ejemplo:

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" }
        ])
    });
});
¿Fue útil esta página
0 / 5 - 0 calificaciones

Temas relacionados

rui-ktei picture rui-ktei  ·  3Comentarios

timdorr picture timdorr  ·  3Comentarios

captbaritone picture captbaritone  ·  3Comentarios

jimbolla picture jimbolla  ·  3Comentarios

caojinli picture caojinli  ·  3Comentarios