Redux: Teste de unidade de componentes inteligentes

Criado em 20 ago. 2015  ·  19Comentários  ·  Fonte: reduxjs/redux

Eu estava lendo a seção de teste de unidade da documentação e embora tenha um exemplo de como testar um componente burro, ser capaz de testar a unidade de um "componente inteligente" (aqueles que usam o método connect ()) não é coberto. Acontece que o teste de unidade de um componente inteligente é um pouco mais complicado por causa do componente de invólucro criado por connect (). Parte do problema é que envolver um componente por meio de connect () requer que haja uma propriedade de 'armazenamento' (ou contexto).

Tentei fazer isso e esperava obter um pequeno feedback sobre se há uma maneira melhor de fazer isso. E se o que eu fiz acabar parecendo razoável, achei melhor fazer um PR para adicionar algumas informações sobre isso na documentação de teste de unidade.

Para começar, peguei o componente de exemplo existente na seção de teste de unidade dos documentos e envolvi-o em connect () para passar os dados dos criadores de estado e ação de despacho:

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

No arquivo de teste de unidade, também é semelhante ao exemplo.

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

Simplifiquei este teste um pouco para mostrar apenas as partes relevantes, mas o ponto principal sobre o qual não tenho certeza é o método createMockStore . Se você tentar renderizar o componente Header sem quaisquer props, um erro é lançado por Redux (ou react-redux) dizendo que o componente deve ter um store prop ou contexto, uma vez que espera ser um filho do <Provider> componente. Como não quero usar isso para meus testes de unidade, criei um método para simular isso e permitir que o teste passe no estado que deseja definir na loja.

O benefício que posso ver dessa abordagem é que ela me permite testar as funções dentro do meu componente, mas também ser capaz de testar a funcionalidade dos métodos que estou passando para o connect (). Eu poderia facilmente escrever outra declaração aqui que faz algo como expect(output.props.numberOfTodos).toBe(3) que verifica se minha função mapStateToProps está fazendo o que eu espero.

A principal desvantagem disso é que estou tendo que zombar da loja Redux, que não é tão complexa, mas parece que faz parte da lógica interna do Redux e pode mudar. Obviamente, para meus testes de unidade, movi esses métodos para um arquivo de utilitário de teste de unidade geral, de modo que, se os métodos de armazenamento mudassem, eu só teria que modificar meu código em um lugar.

Pensamentos? Alguém mais experimentou componentes inteligentes de teste de unidade e encontrou uma maneira melhor de fazer as coisas?

discussion question

Comentários muito úteis

@ernieturner Acho que @ghengeveld significa que se você usar módulos ES6 em todos os lugares, o componente Header decorado ainda pode ser exportado como padrão e um componente React simples pode ser uma exportação nomeada adicional. Por exemplo -

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

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

Com esta exportação dupla, seu aplicativo principal pode consumir o componente inteligente como antes com import Header from './header.js' enquanto seus testes podem ignorar o redux e testar o comportamento do componente principal diretamente com import {HeaderDumbComponent} from '../components/header.js'

Todos 19 comentários

A alternativa é também exportar a classe Header (não decorada), para que ela possa ser importada e testada separadamente.

Além disso, você não precisa da chamada jsdomReact em todos os arquivos, pelo menos se estiver usando o Mocha. Aqui está meu 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.');
  });
}

É carregado por meio da opção de linha de comando --require: mocha -r babelhook -r test/setup --recursive (babelhook é uma chamada para require('babel-core/register') ).

Sim, eu poderia exportar a classe Header e a decorada, mas esperava evitar ter que alterar o código-fonte apenas para meus testes de unidade. Fazendo isso, eu teria que mudar todos os lugares que incluem um componente decorado (por exemplo, import {DecoratedHeader} from './components/Header' vez de apenas import Header from . / Components / Header`).

Quanto à configuração do jsdom, eu estava simplesmente copiando o exemplo dos documentos como uma forma de mostrar o caso de uso, não estou usando isso como minha configuração principal, apenas um exemplo.

Acabei de notar que meu exemplo de código acima estava incompleto. Para testar um componente inteligente diretamente, é necessário que algum método utilitário retorne refs.wrappedInstance vez de apenas o resultado da renderização, pois isso fornecerá o componente decorado com conexão (atualizado na função de configuração acima) . Isso mais uma vez faz com que os testes de unidade dependam dos internos do Redux (neste caso, especificamente dos internos do react-redux). Então funciona, mas parece um pouco frágil.

@ernieturner Também oferecemos getWrappedInstance() API pública para isso, então você pode confiar nela se estiver preocupado em acessar refs diretamente. E existem utilitários como react-test-tree para tornar isso transparente também.

@ernieturner Acho que @ghengeveld significa que se você usar módulos ES6 em todos os lugares, o componente Header decorado ainda pode ser exportado como padrão e um componente React simples pode ser uma exportação nomeada adicional. Por exemplo -

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

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

Com esta exportação dupla, seu aplicativo principal pode consumir o componente inteligente como antes com import Header from './header.js' enquanto seus testes podem ignorar o redux e testar o comportamento do componente principal diretamente com import {HeaderDumbComponent} from '../components/header.js'

@ eugene1g @ghengeveld Esta é realmente uma boa solução para o problema. Sinta-se à vontade para enviar um PR para o doc "Writing tests" explicando isso!

@ eugene1g Isso é exatamente o que eu quis dizer.
@gaearon vou fazer isso.

@ghengeveld Desculpas pelo mal-entendido. Ainda estou usando as importações de estilo CommonJS, então ainda estou muito enferrujado nos módulos ES6. Isso parece uma boa solução para expor nos documentos. Suponho que você também possa expor os métodos mapStateToProps / mapDispatchToProps para permitir que eles sejam testados sem ter que lidar com a simulação da loja, por exemplo

export class HeaderDumpComponent ...

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

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

É bem possível que a maioria dos métodos de mapa sejam simples o suficiente para não necessitarem de testes de unidade, mas tenho alguns deles que valem a pena verificar, já que são uma parte crucial da funcionalidade de meus componentes.

Obrigado a todos pelas sugestões, se precisarem de alguma ajuda com a documentação, me avisem.

Estamos tentando a abordagem recomendada conforme descrito acima agora, mas não nos sentimos totalmente confortáveis ​​com ela. Isso deixa os parâmetros passados ​​para connect não testados fora dos testes de integração. Eu entendo que uma loja fictícia pode ser potencialmente frágil, mas eu sinto que é aqui que algo como uma loja fictícia redux poderia aparecer, se pudesse suportar o teste de componentes. Como você se sente sobre essa direção?

@carpeliam

Por que não usar um depósito regular e hidratar com um determinado estado inicial?
Você só precisa simular a loja se quiser testar quais ações estão sendo despachadas.

@carpeliam

Consulte os testes de unidade para counter e todomvc exemplos neste repo.

Estávamos tendo um problema onde estávamos renderizando um componente inteligente dentro de outro componente e não podíamos usar o renderizador superficial (o componente usava métodos componentDid *). O que fizemos foi stub (usando sinon) a função de conexão para retornar uma função que retornou um componente React simples.

É frágil e esperamos passar para o renderizador raso assim que pudermos migrar para o React 0.14, mas esse método nos desbloqueou em nossos testes por enquanto.

Também estou tendo problemas com isso. Sou bastante novo no React e no Redux, então é isso que provavelmente está me segurando. Como você conseguiu contornar isso? @songawee

O método de exportação dupla proposto deixa a função select (mapStoreToState) não testada. Para testá-lo de forma independente, ele também precisa ser exportado, mais uma mudança no nome de teste.

Eu estaria interessado em encontrar uma maneira de fazer o shallowRenderer funcionar com o componente empacotado do Connect. Meu problema atual é que, ao usar um renderizador raso, ele apenas retorna o componente Connect, o que é esperado.

O método de exportação dupla proposto deixa a função select (mapStoreToState) não testada. Para testá-lo de forma independente, ele também precisa ser exportado, mais uma mudança no nome de teste.

Não estritamente “em nome do teste”. Na verdade, encorajamos você a definir as funções do seletor (que é o que mapStateToProps última análise é) ao lado de seus redutores e testá-los juntos. Essa lógica é independente da IU e não precisa ser agrupada com ela. Dê uma olhada no exemplo de shopping-cart para alguns seletores.

Então, @gaearon , você está sugerindo obter todos os dados do estado usando as funções deste _seletor_? Isso não introduz muita sobrecarga desnecessária, já que na maioria dos casos as pessoas irão apenas ler um monte de propriedades do estado e atribuí-las a props nos componentes?

Sim, o padrão geral sugerido para Redux é usar funções seletoras em quase todos os lugares, ao invés de acessar state.some.nested.field diretamente. Elas podem ser funções "simples" muito simples, mas são mais comumente colocadas juntas usando a biblioteca Reselect, que fornece recursos de memoização.

Por que isso criaria uma sobrecarga extra?

Tenho feito a exportação dupla conforme descrito aqui, mas lendo a fonte connect , percebi que o "Container" mantém uma referência ao componente "burro" "na propriedade estática WrappedComponent :

Então, em vez 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
})

Você pode evitar a exportação dupla usando WrappedComponent onde você precisa da versão "burra":

// 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 uma propriedade bastante estável, visto que está nos documentos. Você não aconselharia usá-lo dessa forma por algum motivo?

O que faço é exportar o componente desembrulhado e também exportar mapStateToProps e mapDispatchToProps para que eu possa testar a unidade dessas funções. Aqui está um exemplo:

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" }
        ])
    });
});
Esta página foi útil?
0 / 5 - 0 avaliações

Questões relacionadas

ilearnio picture ilearnio  ·  3Comentários

jbri7357 picture jbri7357  ·  3Comentários

ms88privat picture ms88privat  ·  3Comentários

rui-ktei picture rui-ktei  ·  3Comentários

ramakay picture ramakay  ·  3Comentários