Descrição da abordagem para organizar e testar código usando o Redux Thunk

Olá pessoal!


Neste post, gostaria de compartilhar minha abordagem de organização e teste de código usando o Redux Thunk em um projeto React .


O caminho para isso foi longo e espinhoso, então tentarei demonstrar a linha de pensamento e motivação que levou à decisão final.


Descrição do aplicativo e declaração do problema


Primeiro, um pouco de contexto.


A figura abaixo mostra o layout de uma página típica em nosso projeto.



Em ordem:


  • A tabela (Nº 1) contém dados que podem ser muito diferentes (texto sem formatação, links, figuras, etc.).
  • O painel de classificação (Nº 2) define as configurações de classificação de dados na tabela por colunas.
  • O painel de filtragem (Nº 3) define vários filtros de acordo com as colunas da tabela.
  • O painel da coluna (Nº 4) permite definir a exibição das colunas da tabela (mostrar / ocultar).
  • O painel de modelos (Nº 5) permite selecionar modelos de configurações criados anteriormente. Os modelos incluem dados dos painéis nº 2, nº 3 e nº 4, além de outros dados, por exemplo, a posição das colunas, seu tamanho, etc.

Os painéis são abertos clicando nos botões correspondentes.


Dados sobre quais colunas em uma tabela podem ser em geral, quais dados podem estar nelas, como devem ser exibidas, quais filtros de valores podem conter e outras informações estão contidas nos metadados da tabela, solicitados separadamente dos dados no início do carregamento da página.


Acontece que o estado atual da tabela e os dados nela dependem de três fatores:


  • Dados dos metadados da tabela.
  • Configurações para o modelo selecionado no momento.
  • Configurações personalizadas (quaisquer alterações relacionadas ao modelo selecionado são salvas em uma espécie de "rascunho", que pode ser transformado em um novo modelo, atualize o atual com novas configurações ou exclua-as e retorne o modelo ao seu estado original).

Como mencionado acima, essa página é típica. Para cada uma dessas páginas (ou, mais precisamente, para a tabela), uma entidade separada é criada no repositório Redux para a conveniência de operar com seus dados e parâmetros.


Para poder definir conjuntos homogêneos de criadores de thunk e ação e atualizar dados em uma entidade específica, é utilizada a seguinte abordagem (um tipo de fábrica):


export const actionsCreator = (prefix, getCurrentStore, entityModel) => { /* --- ACTIONS BLOCK --- */ function fetchTotalCounterStart() { return { type: `${prefix}FETCH_TOTAL_COUNTER_START` }; } function fetchTotalCounterSuccess(payload) { return { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload }; } function fetchTotalCounterError(error) { return { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error }; } function applyFilterSuccess(payload) { return { type: `${prefix}APPLY_FILTER_SUCCESS`, payload }; } function applyFilterError(error) { return { type: `${prefix}APPLY_FILTER_ERROR`, error }; } /* --- THUNKS BLOCK --- */ function fetchTotalCounter(filter) { return async dispatch => { dispatch(fetchTotalCounterStart()); try { const { data: { payload } } = await entityModel.fetchTotalCounter(filter); dispatch(fetchTotalCounterSuccess(payload)); } catch (error) { dispatch(fetchTotalCounterError(error)); } }; } function fetchData(filter, dispatch) { dispatch(fetchTotalCounter(filter)); return entityModel.fetchData(filter); } function applyFilter(newFilter) { return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = getCurrentStore(store); // 'getFilter' comes from selectors. const filter = newFilter || getFilter(currentStore); const { data: { payload } } = await fetchData(filter, dispatch); dispatch(applyFilterSuccess(payload)); } catch (error) { dispatch(applyFilterError(error)); } }; } return { fetchTotalCounterStart, fetchTotalCounterSuccess, fetchTotalCounterError, applyFilterSuccess, applyFilterError, fetchTotalCounter, fetchData, applyFilter, }; }; 

Onde:


  • prefix - prefix entidade no repositório Redux. É uma sequência no formato "CATS_", "MICE_" etc.
  • getCurrentStore - um seletor que retorna os dados atuais na entidade do repositório Redux.
  • entityModel - Uma instância da classe de modelo de entidade. Por um lado, uma API é acessada através do modelo para criar uma solicitação ao servidor, por outro lado, é descrita alguma lógica complexa (ou não) de processamento de dados.

Portanto, esta fábrica permite descrever de forma flexível o gerenciamento de dados e parâmetros de uma entidade específica no repositório Redux e associá-lo à tabela correspondente a essa entidade.


Como existem muitas nuances no gerenciamento desse sistema, o thunk pode ser complexo, volumoso, confuso e ter partes repetidas. Para simplificá-los e reutilizar o código, os thunks complexos são divididos em mais simples e combinados em uma composição. Como resultado, agora pode acontecer que um thunk chame outro, que já pode despachar applyFilter comuns (como o pacote fetchTotalCounter - fetchTotalCounter do exemplo acima). E quando todos os pontos principais foram levados em consideração e todos os criadores de ações e thunk necessários foram descritos, o arquivo que contém a função actionsCreator tinha ~ 1200 linhas de código e foi testado com grande ruído. O arquivo de teste também tinha cerca de 1200 linhas, mas a cobertura foi na melhor das hipóteses 40-50%.


Aqui, é claro, o exemplo é bastante simplificado, tanto em termos do número de thunk quanto de sua lógica interna, mas isso será suficiente para demonstrar o problema.


Preste atenção aos 2 tipos de conversão no exemplo acima:


  • fetchTotalCounter - envia apenas fetchTotalCounter .
  • applyFilter - além do envio de suas applyFilterSuccess ( applyFilterSuccess , applyFilterError ), ele também é outro thunk ( fetchTotalCounter ).
    Voltaremos a eles um pouco mais tarde.

Tudo isso foi testado da seguinte forma (a estrutura foi usada para testar o Jest ):


 import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { actionsCreator } from '../actions'; describe('actionsCreator', () => { const defaultState = {}; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); const prefix = 'TEST_'; const getCurrentStore = () => defaultState; const entityModel = { fetchTotalCounter: jest.fn(), fetchData: jest.fn(), }; let actions; beforeEach(() => { actions = actionsCreator(prefix, getCurrentStore, entityModel); }); describe('fetchTotalCounter', () => { it('should dispatch correct actions on success', () => { const filter = {}; const payload = 0; const store = mockStore(defaultState); entityModel.fetchTotalCounter.mockResolvedValueOnce({ data: { payload }, }); const expectedActions = [ { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload, } ]; return store.dispatch(actions.fetchTotalCounter(filter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); it('should dispatch correct actions on error', () => { const filter = {}; const error = {}; const store = mockStore(defaultState); entityModel.fetchTotalCounter.mockRejectedValueOnce(error); const expectedActions = [ { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error, } ]; return store.dispatch(actions.fetchTotalCounter(filter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); }); describe('applyFilter', () => { it('should dispatch correct actions on success', () => { const payload = {}; const counter = 0; const newFilter = {}; const store = mockStore(defaultState); entityModel.fetchData.mockResolvedValueOnce({ data: { payload } }); entityModel.fetchTotalCounter.mockResolvedValueOnce({ data: { payload: counter }, }); const expectedActions = [ // fetchTotalCounter actions { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload: counter, }, // applyFilter actions { type: `${prefix}APPLY_FILTER_SUCCESS`, payload, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); it('should dispatch correct actions on error', () => { const error = {}; const counter = 0; const newFilter = {}; const store = mockStore(defaultState); entityModel.fetchData.mockRejectedValueOnce(error); entityModel.fetchTotalCounter.mockResolvedValueOnce({ data: { payload: counter }, }); const expectedActions = [ // fetchTotalCounter actions { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload: counter, }, // applyFilter actions { type: `${prefix}APPLY_FILTER_ERROR`, error, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); }); }); }); }); 

Como você pode ver, não há problemas em testar o primeiro tipo de thunk - você só precisa conectar o entityModel modelo entityModel, mas o segundo tipo é mais complicado - é necessário limpar os dados para toda a cadeia de thunk e os métodos de modelo correspondentes. Caso contrário, o teste cairá sobre a interrupção dos dados ( {data: {payload}} ), e isso pode acontecer de forma explícita ou implícita (foi tal que o teste passou com êxito, mas com uma pesquisa cuidadosa, foi observado que no segundo / terceiro No link desta cadeia houve uma queda no teste devido à falta de dados bloqueados). Também é ruim que testes unitários de funções individuais se transformem em um tipo de integração e se tornem intimamente relacionados.


Surge a pergunta: por que, na função applyFilter , verifique como a função fetchTotalCounter se fetchTotalCounter se testes detalhados já foram escritos para ela? Como posso tornar o teste do segundo tipo de thunk mais independente? Seria ótimo ter a oportunidade de testar se o thunk (neste caso, fetchTotalCounter ) é chamado apenas com os parâmetros corretos e não seria necessário cuidar dos moks para que funcionassem corretamente.


Mas como fazer isso? A decisão óbvia vem à mente: ligar a função fetchData, que é chamada em applyFilter , ou bloquear o fetchTotalCounter (já que muitas vezes outro thunk é chamado diretamente, e não por meio de outra função como fetchData ).


Vamos tentar. Por exemplo, alteraremos apenas um script bem-sucedido.



 describe('applyFilter', () => { it('should dispatch correct actions on success', () => { const payload = {}; - const counter = 0; const newFilter = {}; const store = mockStore(defaultState); - entityModel.fetchData.mockResolvedValueOnce({ data: { payload } }); - entityModel.fetchTotalCounter.mockResolvedValueOnce({ - data: { payload: counter }, - }); + const fetchData = jest.spyOn(actions, 'fetchData'); + // or fetchData.mockImplementationOnce(Promise.resolve({ data: { payload } })); + fetchData.mockResolvedValueOnce({ data: { payload } }); const expectedActions = [ - // Total counter actions - { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, - { - type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, - payload: counter, - }, - // apply filter actions { type: `${prefix}APPLY_FILTER_SUCCESS`, payload, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); + expect(actions.fetchTotalCounter).toBeCalledWith(newFilter); }); }); }); 

Aqui, o método jest.spyOn substitui aproximadamente (e talvez exatamente) a seguinte implementação:


 actions.fetchData = jest.fn(actions.fetchData); 

Isso nos permite "monitorar" a função e entender se ela foi chamada e com quais parâmetros.


Temos o seguinte erro:


 Difference: - Expected + Received Array [ Object { - "payload": Object {}, - "type": "TEST_APPLY_FILTER_SUCCESS", + "type": "TEST_FETCH_TOTAL_COUNTER_START", }, + Object { + "error": [TypeError: Cannot read property 'data' of undefined], + "type": "TEST_FETCH_TOTAL_COUNTER_ERROR", + }, + Object { + "error": [TypeError: Cannot read property 'data' of undefined], + "type": "TEST_APPLY_FILTER_ERROR", + }, ] 

Estranho, meio que ocultamos a função fetchData, fetchData nossa implementação


 fetchData.mockResolvedValueOnce({ data: { payload } }) 

mas a função funciona exatamente da mesma maneira que antes, ou seja, o mock não funcionou! Vamos tentar de forma diferente.



 describe('applyFilter', () => { it('should dispatch correct actions on success', () => { const payload = {}; - const counter = 0; const newFilter = {}; const store = mockStore(defaultState); entityModel.fetchData.mockResolvedValueOnce({ data: { payload } }); - entityModel.fetchTotalCounter.mockResolvedValueOnce({ - data: { payload: counter }, - }); + const fetchTotalCounter = jest.spyOn(actions, 'fetchTotalCounter'; + fetchTotalCounter.mockImplementation(() => {}); const expectedActions = [ - // Total counter actions - { type: `${prefix}FETCH_TOTAL_COUNTER_START` }, - { - type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, - payload: counter, - }, - // apply filter actions { type: `${prefix}APPLY_FILTER_SUCCESS`, payload, } ]; return store.dispatch(actions.applyFilter(newFilter)).then(() => { expect(store.getActions()).toEqual(expectedActions); + expect(actions.fetchTotalCounter).toBeCalledWith(newFilter); }); }); }); 

Temos exatamente o mesmo erro. Por alguma razão, nossos mokas não substituem a implementação original de funções.


Tendo pesquisado esse problema por conta própria e encontrado algumas informações na Internet, percebi que esse problema não existe apenas comigo, e foi resolvido (na minha opinião) de maneira bastante crua. Além disso, os exemplos descritos nessas fontes são bons até que se tornem parte de algo que os conecta em um único sistema (no nosso caso, é uma fábrica com parâmetros).


Em nosso projeto no Jenkins pipline, há uma verificação de código do SonarQube, que requer a cobertura de arquivos modificados (que estão na solicitação de mesclagem / recepção) > 60% . Como a cobertura desta fábrica, como foi dito anteriormente, era insatisfatória e a própria necessidade de cobrir esse arquivo causava apenas depressão, tivemos que fazer algo a respeito, caso contrário, a entrega de novas funcionalidades poderia diminuir com o tempo. Somente a cobertura de teste de outros arquivos (componentes, funções) na mesma solicitação de mesclagem / recepção foi salva, para alcançar a% de cobertura até a marca desejada, mas, na verdade, era uma solução alternativa, não uma solução para o problema. E um bom momento, tendo alocado um pouco de tempo no sprint, comecei a pensar em como esse problema pode ser resolvido.


Uma tentativa de resolver o problema número 1. Eu ouvi algo sobre Redux-Saga ...


... e eles me disseram que o teste é bastante simplificado ao usar esse middleware.


De fato, se você olhar para a documentação , ficará surpreso com a simplicidade do teste do código. O suco em si reside no fato de que, com essa abordagem, não há problema algum com o fato de que uma saga pode chamar outra saga - podemos nos molhar e "ouvir" as funções fornecidas pelo middleware ( put , take , etc.) e verifique se eles foram chamados (e chamados com os parâmetros corretos). Ou seja, nesse caso, a função não acessa outra função diretamente, mas refere-se a uma função da biblioteca, que somente então chama outras funções / sagas necessárias.


"Por que não experimentar este middleware?" Eu pensei e comecei a trabalhar. Ele iniciou uma história técnica em Jira, criou várias tarefas (desde a pesquisa até a implementação e descrição da arquitetura de todo esse sistema), recebeu o “aval” e começou a fazer uma cópia mínima do sistema atual com uma nova abordagem.


No começo, tudo correu bem. Seguindo o conselho de um dos desenvolvedores, foi possível criar uma saga global para carregar dados e tratamento de erros em uma nova abordagem. No entanto, em algum momento, houve problemas com os testes (que, aliás, não foram resolvidos até o momento). Eu pensei que isso poderia destruir todos os testes disponíveis no momento e produzir muitos bugs, então decidi adiar o trabalho nessa tarefa até que houvesse alguma solução para o problema e comecei a realizar tarefas produtivas.


Um ou dois meses se passaram, nenhuma solução foi encontrada e, em algum momento, discutida com elas. liderando (ausente) o progresso nessa tarefa, eles decidiram abandonar a implementação do Redux-Saga no projeto, já que naquela época havia se tornado muito caro em termos de custos de mão-de-obra e do possível número de bugs. Então finalmente decidimos usar o Redux Thunk.


Uma tentativa de resolver o problema número 2. Módulos Thunk


É possível classificar todos os thunk em arquivos diferentes e, nos arquivos em que um thunk chama outro (importado), você pode limpar essa importação usando o método jest.mock ou usando o mesmo jest.spyOn . Assim, realizaremos a tarefa acima, de verificar se algum thunk externo foi chamado com os parâmetros necessários, sem nos preocuparmos com os moks. Além disso, seria melhor interromper todo o thunk de acordo com sua finalidade funcional, para não mantê-los todos em uma pilha. Então, três dessas espécies foram distinguidas:


  • Relacionado ao trabalho com modelos - templates .
  • Relacionado ao trabalho com o filtro (classificação, exibição de colunas) - filter .
  • Relacionado ao trabalho com a tabela (carregar novos dados durante a rolagem, uma vez que a tabela possui uma rolagem virtual, carregar metadados, carregar dados pelo contador de registros da tabela, etc.) - table .

A seguinte estrutura de pastas e arquivos foi proposta:


 src/ |-- store/ | |-- filter/ | | |-- actions/ | | | |-- thunks/ | | | | |-- __tests__/ | | | | | |-- applyFilter.test.js | | | | |-- applyFilter.js | | | |-- actionCreators.js | | | |-- index.js | |-- table/ | | |-- actions/ | | | |-- thunks/ | | | | |-- __tests__/ | | | | | |-- fetchData.test.js | | | | | |-- fetchTotalCounter.test.js | | | | |-- fetchData.js | | | | |-- fetchTotalCounter.js | | | |-- actionCreators.js | | | |-- index.js (main file with actionsCreator) 

Um exemplo dessa arquitetura está aqui .


No arquivo de teste para applyFilter, você pode ver que alcançamos a meta pela qual estávamos nos esforçando - não é possível escrever mokas para manter a operação correta de fetchData / fetchTotalCounter . Mas a que custo ...



 import { applyFilterSuccess, applyFilterError } from '../'; import { fetchData } from '../../../table/actions'; // selector const getFilter = store => store.filter; export function applyFilter(prefix, getCurrentStore, entityModel) { return newFilter => { return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = getCurrentStore(store); const filter = newFilter || getFilter(currentStore); const { data: { payload } } = await fetchData(prefix, entityModel)(filter, dispatch); dispatch(applyFilterSuccess(prefix)(payload)); } catch (error) { dispatch(applyFilterError(prefix)(error)); } }; }; } 


 import * as filterActions from './filter/actions'; import * as tableActions from './table/actions'; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { return { fetchTotalCounterStart: tableActions.fetchTotalCounterStart(prefix), fetchTotalCounterSuccess: tableActions.fetchTotalCounterSuccess(prefix), fetchTotalCounterError: tableActions.fetchTotalCounterError(prefix), applyFilterSuccess: filterActions.applyFilterSuccess(prefix), applyFilterError: filterActions.applyFilterError(prefix), fetchTotalCounter: tableActions.fetchTotalCounter(prefix, entityModel), fetchData: tableActions.fetchData(prefix, entityModel), applyFilter: filterActions.applyFilter(prefix, getCurrentStore, entityModel) }; }; 

Tivemos que pagar pela modularidade dos testes com duplicação de código e uma dependência muito forte de thunk entre si. A menor alteração na cadeia de chamadas levará a uma refatoração pesada.


No exemplo acima, o exemplo de table e filter foi demonstrado para manter a consistência dos exemplos fornecidos. De fato, a refatoração foi iniciada com templates (como acabou sendo mais simples) e, além da refatoração acima, o conceito de trabalhar com modelos foi ligeiramente alterado. Como suposição, foi aceito que só pode haver um painel de modelos em uma página (como uma tabela). Naquela época, era apenas isso, e isso omissão a suposição nos permitiu simplificar um pouco o código, eliminando o prefix .
Depois que as alterações foram lançadas no ramo principal de desenvolvimento e testadas, saí de férias com uma alma calma para continuar transferindo o restante do código para uma nova abordagem depois de retornar.


Depois de voltar de férias, fiquei surpreso ao descobrir que minhas alterações foram revertidas. Aconteceu que apareceu uma página na qual podem existir várias tabelas independentes, ou seja, a suposição feita anteriormente quebrou tudo. Então todo o trabalho foi feito em vão ...


Bem, quase. De fato, seria possível refazer todas as mesmas ações (o benefício da solicitação de mesclagem / recepção não desapareceu em lugar algum, mas permaneceu no histórico), mantendo a abordagem da arquitetura do modelo inalterada e alterando apenas a abordagem da organização de thunk-s. Mas essa abordagem ainda não inspirou confiança por causa de sua coerência e complexidade. Não havia desejo de retornar a ele, embora isso resolvesse o problema indicado com o teste. Era necessário inventar outra coisa, mais simples e mais confiável.


Uma tentativa de resolver o problema número 3. Quem procura encontrará


Examinando globalmente como os testes são escritos para thunk, notei como os métodos (de fato, campos de objetos) do entityModel fáceis e sem problemas.


Então surgiu a ideia: por que não criar uma classe cujos métodos são criadores de thunk e action? Os parâmetros passados ​​para a fábrica serão passados ​​para o construtor desta classe e serão acessíveis através this . Você pode fazer imediatamente uma pequena otimização criando uma classe separada para criadores de ação e outra separada para thunk e depois herdar uma da outra. Portanto, essas classes funcionarão como uma (ao criar uma instância da classe herdeira), mas ao mesmo tempo, cada classe individualmente será mais fácil de ler, entender e testar.


Aqui está um código que demonstra essa abordagem.


Vamos considerar com mais detalhes cada um dos arquivos que apareceram e foram alterados.



 export class FilterActionCreators { constructor(config) { this.prefix = config.prefix; } applyFilterSuccess = payload => ({ type: `${this.prefix}APPLY_FILTER_SUCCESS`, payload, }); applyFilterError = error => ({ type: `${this.prefix}APPLY_FILTER_ERROR`, error, }); } 

  • No arquivo FilterActions.js , herdamos a classe FilterActionCreators e definimos thunk applyFilter como um método dessa classe. Nesse caso, os applyFilterSuccess da ação applyFilterSuccess e applyFilterError estarão disponíveis nele por meio this :

 import { FilterActionCreators } from '/FilterActionCreators'; // selector const getFilter = store => store.filter; export class FilterActions extends FilterActionCreators { constructor(config) { super(config); this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } applyFilter = ({ fetchData }) => { return newFilter => { return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = this.getCurrentStore(store); const filter = newFilter || getFilter(currentStore); const { data: { payload } } = await fetchData(filter, dispatch); // Comes from FilterActionCreators dispatch(this.applyFilterSuccess(payload)); } catch (error) { // Comes from FilterActionCreators dispatch(this.applyFilterError(error)); } }; }; }; } 

  • No arquivo principal, com todos os FilterActions thunk e action, criamos uma instância da classe FilterActions , passando o objeto de configuração necessário. Ao exportar funções (no final da função actionsCreator ), não se esqueça de substituir o método applyFilter para passar a dependência fetchData para fetchData :

 + import { FilterActions } from './filter/actions/FilterActions'; - // selector - const getFilter = store => store.filter; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { + const config = { prefix, getCurrentStore, entityModel }; + const filterActions = new FilterActions(config); /* --- ACTIONS BLOCK --- */ function fetchTotalCounterStart() { return { type: `${prefix}FETCH_TOTAL_COUNTER_START` }; } function fetchTotalCounterSuccess(payload) { return { type: `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`, payload }; } function fetchTotalCounterError(error) { return { type: `${prefix}FETCH_TOTAL_COUNTER_ERROR`, error }; } - function applyFilterSuccess(payload) { - return { type: `${prefix}APPLY_FILTER_SUCCESS`, payload }; - } - - function applyFilterError(error) { - return { type: `${prefix}APPLY_FILTER_ERROR`, error }; - } /* --- THUNKS BLOCK --- */ function fetchTotalCounter(filter) { return async dispatch => { dispatch(fetchTotalCounterStart()); try { const { data: { payload } } = await entityModel.fetchTotalCounter(filter); dispatch(fetchTotalCounterSuccess(payload)); } catch (error) { dispatch(fetchTotalCounterError(error)); } }; } function fetchData(filter, dispatch) { dispatch(fetchTotalCounter(filter)); return entityModel.fetchData(filter); } - function applyFilter(newFilter) { - return async (dispatch, getStore) => { - try { - const store = getStore(); - const currentStore = getCurrentStore(store); - // 'getFilter' comes from selectors. - const filter = newFilter || getFilter(currentStore); - const { data: { payload } } = await fetchData(filter, dispatch); - - dispatch(applyFilterSuccess(payload)); - } catch (error) { - dispatch(applyFilterError(error)); - } - }; - } return { fetchTotalCounterStart, fetchTotalCounterSuccess, fetchTotalCounterError, - applyFilterSuccess, - applyFilterError, fetchTotalCounter, fetchData, - applyFilter + ...filterActions, + applyFilter: filterActions.applyFilter({ fetchData }), }; }; 

  • Os testes tornaram-se um pouco mais fáceis na implementação e na leitura:

 import { FilterActions } from '../FilterActions'; describe('FilterActions', () => { const prefix = 'TEST_'; const getCurrentStore = store => store; const entityModel = {}; const config = { prefix, getCurrentStore, entityModel }; const actions = new FilterActions(config); const dispatch = jest.fn(); beforeEach(() => { dispatch.mockClear(); }); describe('applyFilter', () => { const getStore = () => ({}); const newFilter = {}; it('should dispatch correct actions on success', async () => { const payload = {}; const fetchData = jest.fn().mockResolvedValueOnce({ data: { payload } }); const applyFilterSuccess = jest.spyOn(actions, 'applyFilterSuccess'); await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); expect(fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterSuccess).toBeCalledWith(payload); }); it('should dispatch correct actions on error', async () => { const error = {}; const fetchData = jest.fn().mockRejectedValueOnce(error); const applyFilterError = jest.spyOn(actions, 'applyFilterError'); await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); expect(fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterError).toBeCalledWith(error); }); }); }); 

Em princípio, nos testes, você pode substituir a última verificação desta maneira:


 - expect(applyFilterSuccess).toBeCalledWith(payload); + expect(dispatch).toBeCalledWith(applyFilterSuccess(payload)); - expect(applyFilterError).toBeCalledWith(error); + expect(dispatch).toBeCalledWith(applyFilterError(error)); 

Então não haveria necessidade de dab-los com jest.spyOn . , , . thunk, . , ...


, , , -: , thunk- action creator- , , . , . actionsCreator - , :


 import { FilterActions } from './filter/actions/FilterActions'; import { TemplatesActions } from './templates/actions/TemplatesActions'; import { TableActions } from './table/actions/TableActions'; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { const config = { prefix, getCurrentStore, entityModel }; const filterActions = new FilterActions(config); const templatesActions = new TemplatesActions(config); const tableActions = new TableActions(config); return { ...filterActions, ...templatesActions, ...tableActions, }; }; 

. filterActions templatesActions tableActions , , , filterActions ? , . . - , , .


. , back-end ( Java), . , Java/Spring , . - ?


:


  • thunk- setDependencies , — dependencies :

 export class FilterActions extends FilterActionCreators { constructor(config) { super(config); this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } + setDependencies = dependencies => { + this.dependencies = dependencies; + }; 

  • :

 import { FilterActions } from './filter/actions/FilterActions'; import { TemplatesActions } from './templates/actions/TemplatesActions'; import { TableActions } from './table/actions/TableActions'; export const actionsCreator = (prefix, getCurrentStore, entityModel) => { const config = { prefix, getCurrentStore, entityModel }; const filterActions = new FilterActions(config); const templatesActions = new TemplatesActions(config); const tableActions = new TableActions(config); + const actions = { + ...filterActions, + ...templatesActions, + ...tableActions, + }; + + filterActions.setDependencies(actions); + templatesActions.setDependencies(actions); + tableActions.setDependencies(actions); + return actions; - return { - ...filterActions, - ...templatesActions, - ...tableActions, - }; }; 

  • this.dependencies :

 applyFilter = newFilter => { const { fetchData } = this.dependencies; return async (dispatch, getStore) => { try { const store = getStore(); const currentStore = this.getCurrentStore(store); const filter = newFilter || getFilter(currentStore); const { data: { payload } } = await fetchData(filter, dispatch); // Comes from FilterActionCreators dispatch(this.applyFilterSuccess(payload)); } catch (error) { // Comes from FilterActionCreators dispatch(this.applyFilterError(error)); } }; }; 

, applyFilter , - this.dependencies . , .


  • :

 import { FilterActions } from '../FilterActions'; describe('FilterActions', () => { const prefix = 'TEST_'; const getCurrentStore = store => store; const entityModel = {}; + const dependencies = { + fetchData: jest.fn(), + }; const config = { prefix, getCurrentStore, entityModel }; const actions = new FilterActions(config); + actions.setDependencies(dependencies); const dispatch = jest.fn(); beforeEach(() => { dispatch.mockClear(); }); describe('applyFilter', () => { const getStore = () => ({}); const newFilter = {}; it('should dispatch correct actions on success', async () => { const payload = {}; - const fetchData = jest.fn().mockResolvedValueOnce({ data: { payload } }); + dependencies.fetchData.mockResolvedValueOnce({ data: { payload } }); const applyFilterSuccess = jest.spyOn(actions, 'applyFilterSuccess'); - await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); + await actions.applyFilter(newFilter)(dispatch, getStore); - expect(fetchData).toBeCalledWith(newFilter, dispatch); + expect(dependencies.fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterSuccess).toBeCalledWith(payload); }); it('should dispatch correct actions on error', async () => { const error = {}; - const fetchData = jest.fn().mockRejectedValueOnce(error); + dependencies.fetchData.mockRejectedValueOnce(error); const applyFilterError = jest.spyOn(actions, 'applyFilterError'); - await actions.applyFilter({ fetchData })(newFilter)(dispatch, getStore); + await actions.applyFilter(newFilter)(dispatch, getStore); - expect(fetchData).toBeCalledWith(newFilter, dispatch); + expect(dependencies.fetchData).toBeCalledWith(newFilter, dispatch); expect(applyFilterError).toBeCalledWith(error); }); }); }); 

.


, , :


  • :

 import { FilterActions } from './filter/actions/FilterActions'; import { TemplatesActions } from './templates/actions/TemplatesActions'; import { TableActions } from './table/actions/TableActions'; - export const actionsCreator = (prefix, getCurrentStore, entityModel) => { + export const actionsCreator = (prefix, getCurrentStore, entityModel, ExtendedActions) => { const config = { prefix, getCurrentStore, entityModel }; const filterActions = new FilterActions(config); const templatesActions = new TemplatesActions(config); const tableActions = new TableActions(config); + const extendedActions = ExtendedActions ? new ExtendedActions(config) : undefined; const actions = { ...filterActions, ...templatesActions, ...tableActions, + ...extendedActions, }; filterActions.setDependencies(actions); templatesActions.setDependencies(actions); tableActions.setDependencies(actions); + if (extendedActions) { + extendedActions.setDependencies(actions); + } return actions; }; 

  • ExtendedActions , :

 export class ExtendedActions { constructor(config) { this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } setDependencies = dependencies => { this.dependencies = dependencies; }; // methods to re-define } 

, , :


  • , .
  • .
  • , , thunk- .
  • , , thunk-/action creator- 99-100%.


action creator- ( filter , templates , table ), reducer- - , , actionsCreator - , reducer- ~400-500 .


:


  • reducer-:

 import isNull from 'lodash/isNull'; import { getDefaultState } from '../getDefaultState'; import { templatesReducerConfigurator } from 'src/store/templates/reducers/templatesReducerConfigurator'; import { filterReducerConfigurator } from 'src/store/filter/reducers/filterReducerConfigurator'; import { tableReducerConfigurator } from 'src/store/table/reducers/tableReducerConfigurator'; export const createTableReducer = ( prefix, initialState = getDefaultState(), entityModel, ) => { const config = { prefix, initialState, entityModel }; const templatesReducer = templatesReducerConfigurator(config); const filterReducer = filterReducerConfigurator(config); const tableReducer = tableReducerConfigurator(config); return (state = initialState, action) => { const templatesState = templatesReducer(state, action); if (!isNull(templatesState)) { return templatesState; } const filterState = filterReducer(state, action); if (!isNull(filterState)) { return filterState; } const tableState = tableReducer(state, action); if (!isNull(tableState)) { return tableState; } return state; }; }; 

  • tableReducerConfigurator ( ):

 export const tableReducerConfigurator = ({ prefix, entityModel }) => { return (state, action) => { switch (action.type) { case `${prefix}FETCH_TOTAL_COUNTER_START`: { return { ...state, isLoading: true, error: null, }; } case `${prefix}FETCH_TOTAL_COUNTER_SUCCESS`: { return { ...state, isLoading: false, counter: action.payload, }; } case `${prefix}FETCH_TOTAL_COUNTER_ERROR`: { return { ...state, isLoading: false, error: action.error, }; } default: { return null; } } }; }; 

:


  1. reducerConfigurator - action type-, «». action type case, null ().
  2. reducerConfigurator - , null , reducerConfigurator - !null . , reducerConfigurator - case, reducerConfigurator -.
  3. , reducerConfigurator - case- action type-, ( reducer-).

, actionsCreator -, , , , .


, !
, Redux Thunk.


, Redux Thunk . , .

Source: https://habr.com/ru/post/pt469371/


All Articles