Redux: Testes de integração com baixo esforço e alto retorno

Aumentando a confiança em seus testes e sua implementação!

Simular seu usário, aumentando sua confiança — Imagem criada em https://carbon.now.sh
Para testar sua aplicação Redux, você pode renderiza-la em Node.js, simulando iterações do usuário e verificando mudanças do estado na sua marcação HTML. Esses testes são relativamente fáceis de escrever, rápidos de rodar e te dão uma confiança maior na sua aplicação. — Maciek Pekala

Escrever testes eficientes para um software, é um ato muitas vezes complicado de se balancear. Ou falar “eficiente”, eu não estou falando sobre velocidade de execução ou consumo de recursos, mas ao invés disso, estou me referindo ao valor entre o esforço colocado para escrever o teste e o valor que eles realmente fornecem para a aplicação.

Esse não é um problema novo ou desconhecido. Muitas pessoas inteligentes refletiram sobre isso no passado e estabeleceram diretrizes que podem ajudar os desenvolvedores a lidar com isso. Acredito bastante na pirâmide de testes, que sugere o número relativo entre diferentes tipos de testes para uma suíte de testes saudável, onde os testes de unidade são a base, cobrindo cada parte do código individualmente.

Testes unitários e Redux

A estrutura incentivada pelo Redux torna a escrita de testes unitários muito fácil. Você pode testar diferentes blocos da aplicação (reducers, action creators, containers etc) de forma isolada e testá-los como qualquer outra função pura — enviamos dados, confirmamos o retorno sem a necessidade de criar mocks. O guia de testes na documentação do Redux lista a abordagem de testes unitários para cada um desses blocos.

Seguindo este guia, você pode obter uma cobertura completa de testes unitários por meio de tediosos testes. Copiando e colocando testes de reducers, action creators e etc… Mas, assim que todo esse trabalho é feito, a pirâmide de testes ataca novamente. Como são apenas testes unitários, a suíte de testes ainda não responde à uma pergunta fundamental — o aplicativo realmente funciona?

Escalando a pirâmide

Existem várias maneiras de expandir as camadas superiores da pirâmide de teste no contexto de uma aplicação web. A camada superior de ponta a ponta (e2e) pode ser implementada usando o Selenium, por exemplo, com webdriver.io. Esses testes são independentes de tecnologia, portanto, eles ainda serão válidos mesmo se você portar seu aplicativo para usar uma estrutura diferente. No entanto, demoram muito tempo para serem escritos e executados, são difíceis de debugar e muitas vezes falham sem algum problema real (flaky). Normalmente, um projeto pode, e deve, fornecer um número relativamente pequeno deles.

E quanto à camada entre os testes unit e e2e? Em geral, eles são chamados de testes de integração, pois eles verificam como diferentes módulos no aplicativo funcionam juntos. O espectro de testes de integração é amplo. Por exemplo, podemos dizer que, se um teste para reducers usa action creators para despachar ações, ele já é mais do que um teste de unidade. Um outro extremo nesse espectro, o teste e2e pode ser visto como o caso mais extremo de um teste de integração.

Alguém poderia tentar encontrar o ponto ideal para testes de integração no Redux. No mundo ideal, eles devem ser rápidos o suficiente para serem executados como parte do fluxo de desenvolvimento, usando a mesma infraestrutura de teste que os testes unitários e devem dar um nível decente de confiança de que toda a parte da aplicação, gerenciada pelo Redux, funciona como esperado.

Encontrando os limites

Descobrir onde colocar os limites dos testes é um bom ponto de partida. A estrutura da maioria das aplicações pode ser representada da seguinte forma:

Estrutura típica de uma aplicação web
Estrutura típica de uma aplicação web

Algumas partes do sistema, nós iremos precisamos criar mocks para obter as características desejadas dos testes. Começando do topo, o fator mais limitante é o navegador. Iniciar uma instância do navegador (mesmo sem interface, headless) para executar os testes leva um tempo considerável, comparado a execução de algum código em Node.js. E na parte inferior, não esperamos que as solicitações reais sejam realizadas, já que a camada de rede também é uma interface claramente definida e razoavelmente fácil de criar um mock.

Criando mocks para as extremidades da aplicação

Vamos assumir que nossa aplicação usa React e Redux. Com isso, temos uma maneira simples de escrever nossa aplicação e que nos permita executá-la em Node.js durante os testes (ou até mesmo em produção, caso você precise renderizar no lado do servidor — server-side rendering). Isso significa que podemos usar a excelente estruturas do Jest para executar nossos testes e podemos utilizar a biblioteca enzyme, para renderizar partes ou até mesmo nossa aplicação inteira, nos permitindo interagir com ela sem a necessidade de um ambiente real de navegador.

Enzyme fornece uma função mount, que podemos usar para renderizar e interagir com qualquer componente React, ou no nosso exemplo, uma aplicação Redux completa. Para reduzir o boilerplate de cada teste, vamos escrever uma função utilitária para renderizar o aplicativo com um determinado estado, retornando o wrapper criado pelo enzyme e nossa store criada pelo Redux.

Iremos criar __helpers__/renderAppWithState.js:

import { Provider } from 'react-redux';
import { mount } from 'enzyme';
import MyApp from './containers/MyApp';
import createStore from './createStore';
export default function renderAppWithState(state) {
const store = createStore(state);
const wrapper = mount(
<Provider store={store}>
<MyApp />
</Provider>
);
return [store, wrapper];
}

E podemos utilizá-lo da seguinte maneira:

import renderAppWithState from '...'

const [, wrapper] = renderAppWithState({ foo: 'bar' });

wrapper
.find('input')
.simulate('change', { target: { value: 'hello' } });

A execução de testes em Node.js também nos permite algumas soluções para limpar a camada de rede entre cada teste, por exemplo, a biblioteca nock. Com nock, a declaração de respostas para requisições de rede fica clara, bem como erros para testes específicos. Podemos criar um mock para uma requisição GET da seguinte maneira:

import nock from 'nock';

nock('https://example.com/api')
.get('/12345')
.reply(200, { foo: 'bar' });

// a próxima requisição para a URL acima, de qualquer
// parte do nosso código, será bem sucedida com uma
// resposta `{ foo: 'bar' }`

Com esta configuração, podemos executar os testes de integração com a conveniência e velocidade de um teste de unidade. Tudo o que resta é a implementação…

Mocks para as extremidades e testes de integração em Redux
Mocks para as extremidades e testes de integração em Redux

O que devemos testar?

Testes de integração retornam uma confiança maior no funcionamento correcto da aplicação. São testes que assumem a perspectiva do usuário. O objetivo é verificar que, uma vez que o usuário interage com a aplicação, clicando nos botões, preenchendo os formulários, etc., o aplicativo responde corretamente ou realiza os efeitos colaterais (side-effects) conforme o esperado.

Vamos considerar um cenário de envio de um formulário. Iremos renderizar a aplicação com os dados já preenchidos e simulamos que o usuário clique no botão enviar. Iremos nos certificar de que a requisição para a API retorne uma chamada bem-sucedida.

describe('Submitting the form', () => {
const [, wrapper] = renderAppWithState({ name: 'John Doe' });
const submitButton = wrapper.find('[type="submit"]');
  it('sends the data and shows the confirmation page', () => {
nock('https://myapp.com/api').post('/12345').reply(200);
submitButton.simulate('click');
// podemos verificar o resultado a partir daqui
});
});

Quando devemos testar?

Antes de mergulhar na implementação das asserções, há mais um problema a ser abordado: quando devemos executá-las? Em um caso simples, quando todas as alterações na aplicação acontecem de forma síncrona, você pode executar as asserções logo após simular a ação do usuário. No entanto, seu aplicativo provavelmente usará Promises para lidar com parte assíncrona do código, por exemplo, as requisições de rede. Mesmo que uma requisição tenha um mock para ser resolvida de forma síncrona, o retorno de sucesso será executado estritamente após qualquer código que ficar abaixo da linha submitButton.simulate('click'). Precisamos esperar que nosso aplicativo seja "concluído" antes de começarmos as asserções.

Jest oferece várias maneiras de trabalhar com código assíncrono, elas requerem um controle direto para a cadeia de promessas, promise chain, (que não temos neste exemplo) ou exigem temporizadores de simulação, mocking timers, (que não funcionam com código baseado em promessa). Você poderia usar setTimeout(() => {}, 0), mas isso nos força a usar o recurso de retorno de chamada assíncrono do Jest, deixando o código de testes menos elegante.

No entanto, há uma boa solução para esse problema na forma de uma função utilitária de uma linha que cria uma promessa resolvida no próximo tick do loop de eventos. Podemos usá-la com o suporte interno do Jest para retornar uma promessa no teste:

const flushAllPromises = () => new Promise(resolve => setImmediate(resolve));

it('runs some promise based code', () => {
triggerSomethingPromiseBased();
return flushAllPromises().then(() => {
// podemos verificar o resultado a partir daqui
});
});

Uma solução elegante para executar asserções para código baseado em Promise

Como devemos testar?

Quais opções existem para verificar se a aplicação respondeu corretamente à interação do usuário?

Marcação! (o HTML), você pode inspecionar a marcação da página para verificar se a UI foi modificada corretamente, por exemplo, usando Jest snapshots. Para o teste a seguir funcionar, você precisará configurar um serializador no Jest, por exemplo, usando a biblioteca enzyme-to-json.

...
expect(wrapper).toMatchSnapshot();
submitButton.simulate('click');

return flushAllPromises().then(() => {
expect(wrapper).toMatchSnapshot();
});
...

Este tipo de asserção é incrivelmente fácil de escrever, mas os testes que as utilizam tendem a ser bastante desfocados. O snapshot da marcação da aplicação, provavelmente será alterado com frequência, fazendo com que seus testes, aparentemente não relacionados, falhem. Eles também não documentam o comportamento esperado, apenas verificam o resultado.

Estado! (ou state), podemos verificar a modificação no estado da aplicação. É fácil em uma aplicação com Redux, já que o armazenamento é centralizado. Isso pode ser mais complicado se o estado é distribuído. Também podemos utilizar o snapshot aqui, mas eu prefiro ser explícito, utilizando objetos literais.

...
const [store, wrapper] = renderAppWithState({ name: 'John Doe' });
...
expect(store.getState()).toEqual({
name: 'John Doe',
confirmationVisible: false,
});
submitButton.simulate('click');
return flushAllPromises().then(() => {
expect(store.getState()).toEqual({
name: 'John Doe',
confirmationVisible: true,
});
});

Esse tipo de asserção é menos centrada no usuário, já que o estado da store fica “sob o capô” da sua aplicação. No entanto, testes como esse serão menos suscetíveis a falhas causadas por alterações orientadas a marcação.

Efeitos colaterais (ou side-effects), dependendo da sua aplicação, pode haver outros efeitos colaterais que você deve verificar (por exemplo, requisições de rede, alterações para localStorage). Você pode, por exemplo, usar o método isDone do nock, para verificar se os mocks criados, foram consumidos.

Ações despachadas (ou dispatched actions), essa abordagem aproveita um dos recursos mais fortes do Redux, o log de ações é serializável. Podemos usá-lo para confirmar a sequência das ações enviadas para nossa store, por exemplo, com a ajuda da útil biblioteca redux-mock-store. Primeiro, o renderAppWithState precisa ser modificado para usar uma versão com um mock da store do Redux, para expor um método getActions.

...
// renderAppWithState usa redux-mock-state
// para criar a store
const [store, wrapper] = renderAppWithState({ name: 'John Doe' });
...
expect(store.getState()).toEqual({
name: 'John Doe',
confirmationVisible: false,
});
submitButton.simulate('click');
return flushAllPromises().then(() => {
expect(store.getActions()).toEqual([
{ type: 'SUBMIT_FORM_START' },
{ type: 'SUBMIT_FORM_SUCCESS' },
]);
});

Este tipo de asserções é útil especialmente para fluxos assíncronos mais complexos. Ele também fornece uma visão geral clara do comportamento esperado da aplicação no cenário testado, servindo como documentação.

Como encontrar o balanço

A introdução deste tipo de testes de integração não deve significar pular testes unitários. A maioria das peças e, especialmente, as partes lógicas pesadas da aplicação (como reducers ou selectors em Redux) ainda precisam ser completamente testadas em unidade. A pirâmide ainda se aplica! No entanto, os testes de integração são uma adição válida à caixa de ferramentas de teste. Elas devem ajudar a criar um conjunto de testes saudável que cause o mínimo de dor possível e permita implementações mais confiáveis.

O assunto de teste de software é um dos mais opinativos no nosso setor. Ao rever este artigo, um dos meus colegas apontou para um artigo intitulado “Integrated tests are a scam”. Alguns dos pontos que o autor faz são válidos, mas as coisas não são tão em preto e branco na minha opinião. O que você acha?

Créditos