Description de l'approche pour organiser et tester le code Ă  l'aide de Redux Thunk

Bonjour Ă  tous!


Dans cet article, je voudrais partager mon approche de l'organisation et du test de code Ă  l'aide de Redux Thunk dans un projet React .


Le chemin a été long et épineux, je vais donc essayer de montrer le cheminement des pensées et de la motivation qui a conduit à la décision finale.


Description de l'application et énoncé du problème


Tout d'abord, un peu de contexte.


La figure ci-dessous montre la mise en page d'une page typique de notre projet.



Pour:


  • Le tableau (n ° 1) contient des donnĂ©es qui peuvent ĂŞtre très diffĂ©rentes (texte brut, liens, images, etc.).
  • Le panneau de tri (n ° 2) dĂ©finit les paramètres de tri des donnĂ©es dans le tableau par colonnes.
  • Le panneau de filtrage (n ° 3) dĂ©finit diffĂ©rents filtres en fonction des colonnes du tableau.
  • Le panneau des colonnes (n ° 4) vous permet de rĂ©gler l'affichage des colonnes du tableau (afficher / masquer).
  • Le panneau de modèles (n ° 5) vous permet de sĂ©lectionner des modèles de paramètres crĂ©Ă©s prĂ©cĂ©demment. Les modèles incluent les donnĂ©es des panneaux n ° 2, n ° 3, n ° 4, ainsi que d'autres donnĂ©es, par exemple, la position des colonnes, leur taille, etc.

Les panneaux s'ouvrent en cliquant sur les boutons correspondants.


Les données sur les colonnes d'une table peuvent être en général, les données qu'elles peuvent contenir, comment elles doivent être affichées, les valeurs que les filtres peuvent contenir et d'autres informations sont contenues dans les métadonnées de la table, qui sont demandées séparément des données elles-mêmes au début du chargement de la page.


Il s'avère que l'état actuel de la table et les données qu'elle contient dépendent de trois facteurs:


  • DonnĂ©es des mĂ©tadonnĂ©es de la table.
  • Paramètres du modèle actuellement sĂ©lectionnĂ©.
  • Paramètres utilisateur (toutes les modifications concernant le modèle sĂ©lectionnĂ© sont enregistrĂ©es dans une sorte de «brouillon», qui peut ĂŞtre converti en un nouveau modèle, ou mettre Ă  jour le modèle actuel avec de nouveaux paramètres, ou les supprimer et remettre le modèle Ă  son Ă©tat d'origine).

Comme mentionné ci-dessus, une telle page est typique. Pour chacune de ces pages (et plus précisément, pour la table qu'elle contient), une entité distincte est créée dans le référentiel Redux pour la commodité de fonctionner avec ses données et paramètres.


Afin de pouvoir définir des ensembles homogènes de créateurs de thunk et d'action et mettre à jour les données sur une entité spécifique, l' approche suivante est utilisée (une sorte d'usine):


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

OĂą:


  • prefix - prefix entitĂ© dans le rĂ©fĂ©rentiel Redux. Il s'agit d'une chaĂ®ne de la forme "CATS_", "MICE_", etc.
  • getCurrentStore - un sĂ©lecteur qui renvoie les donnĂ©es actuelles sur l'entitĂ© Ă  partir du rĂ©fĂ©rentiel Redux.
  • entityModel - Une instance de la classe de modèle d'entitĂ©. D'une part, une API est accessible via le modèle pour crĂ©er une demande au serveur, d'autre part, une logique de traitement de donnĂ©es complexe (ou pas) est dĂ©crite.

Ainsi, cette fabrique vous permet de décrire de manière flexible la gestion des données et des paramètres d'une entité particulière dans le référentiel Redux et de l'associer à la table correspondant à cette entité.


Puisqu'il y a beaucoup de nuances dans la gestion de ce système, le thunk peut être complexe, volumineux, déroutant et avoir des parties répétitives. Pour les simplifier, ainsi que pour réutiliser le code, les thunks complexes sont décomposés en plus simples et combinés en une composition. À la suite de cela, il peut maintenant fetchTotalCounter qu'un thunk en appelle un autre, qui peut déjà envoyer des applyFilter ordinaires (comme le bundle applyFilter - fetchTotalCounter de l'exemple ci-dessus). Et lorsque tous les points principaux ont été pris en compte, et tous les créateurs de thunk et d'action nécessaires ont été décrits, le fichier contenant la fonction actionsCreator avait ~ 1200 lignes de code et a été testé avec un grand grincement. Le fichier de test contenait également environ 1 200 lignes, mais la couverture était au mieux de 40 à 50%.


Ici, l'exemple, bien sûr, est grandement simplifié, à la fois en termes de nombre de thunk et de leur logique interne, mais cela suffira à démontrer le problème.


Faites attention Ă  2 types de thunk dans l'exemple ci-dessus:


  • fetchTotalCounter - fetchTotalCounter rĂ©partition uniquement.
  • applyFilter - en plus de l'envoi des applyFilter appartiennent ( applyFilterSuccess , applyFilterError ), dispatch-it est aussi un autre fetchTotalCounter ( fetchTotalCounter ).
    Nous y reviendrons un peu plus tard.

Tout cela a été testé comme suit (le framework a été utilisé pour tester 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); }); }); }); }); 

Comme vous pouvez le voir, il n'y a aucun problème avec le test du premier type de thunk - il vous suffit de raccorder la entityModel modèle entityModel, mais le deuxième type est plus compliqué - vous devez effacer les données pour toute la chaîne du thunk appelé et les méthodes de modèle correspondantes. Sinon, le test tombera sur la perturbation des données ( {data: {payload}} ), et cela peut se produire de manière explicite ou implicite (c'était tel que le test a réussi, mais avec des recherches minutieuses, il a été remarqué que dans le deuxième / troisième maillon de cette chaîne, le test a chuté en raison du manque de données verrouillées). Il est également mauvais que les tests unitaires des fonctions individuelles se transforment en une sorte d'intégration et deviennent étroitement liés.


La question se pose: pourquoi dans la fonction applyFilter vérifier comment la fonction fetchTotalCounter si des tests détaillés séparés ont déjà été écrits pour elle? Comment rendre le test du deuxième type de thunk plus indépendant? Ce serait génial d'avoir l'occasion de tester que le thunk (dans ce cas fetchTotalCounter ) est juste appelé avec les bons paramètres , et il ne serait pas nécessaire de prendre soin des moks pour qu'il fonctionne correctement.


Mais comment faire? La décision évidente vient à l'esprit: accrocher la fonction fetchData, qui est appelée dans applyFilter , ou verrouiller le fetchTotalCounter (car souvent un autre thunk est appelé directement, et non via une autre fonction comme fetchData ).


Essayons. Par exemple, nous ne changerons qu'un script réussi.


  • NumĂ©ro d'option 1. Fonction fetchData simulĂ©e:

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

Ici, la méthode jest.spyOn remplace à peu près (et peut-être exactement) l'implémentation suivante:


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

Cela nous permet de "surveiller" la fonction et de comprendre si elle a été appelée et avec quels paramètres.


Nous obtenons l'erreur suivante:


 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", + }, ] 

Étrange, nous avons en quelque sorte caché la fonction fetchData, fetchData notre implémentation


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

mais la fonction fonctionne exactement comme avant, c'est-à-dire que la maquette n'a pas fonctionné! Essayons différemment.



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

Nous obtenons exactement la même erreur. Pour une raison quelconque, nos mokas ne remplacent pas l'implémentation d'origine des fonctions.


Après avoir exploré ce problème par moi-même et trouvé des informations sur Internet, je me suis rendu compte que ce problème existe non seulement avec moi, et qu'il est résolu (à mon avis) de manière assez cruelle. De plus, les exemples décrits dans ces sources sont bons jusqu'à ce qu'ils deviennent une partie de quelque chose qui les relie en un seul système (dans notre cas, c'est une usine avec des paramètres).


Sur notre projet dans Jenkins pipline, il y a une vérification de code de SonarQube, qui nécessite de couvrir les fichiers modifiés (qui sont dans la demande de fusion / extraction) > 60% . Comme la couverture de cette usine, comme cela a été dit précédemment, n'était pas satisfaisante et que le besoin même de couvrir un tel fichier ne provoquait qu'une dépression, nous devions y remédier, sinon la livraison de nouvelles fonctionnalités pourrait ralentir avec le temps. Seule la couverture de test d'autres fichiers (composants, fonctions) dans la même demande de fusion / extraction a été enregistrée, afin d'atteindre le% de couverture jusqu'à la marque souhaitée, mais, en fait, c'était une solution de contournement, pas une solution au problème. Et un beau moment, après avoir alloué un peu de temps au sprint, j'ai commencé à réfléchir à la manière de résoudre ce problème.


Une tentative de résoudre le problème numéro 1. J'ai entendu parler de Redux-Saga ...


... et ils m'ont dit que les tests sont grandement simplifiés lors de l'utilisation de ce middleware.


En effet, si vous regardez la documentation , vous êtes surpris de la simplicité du test du code. Le jus lui-même réside dans le fait qu'avec cette approche, il n'y a aucun problème avec le fait qu'une saga peut appeler une autre saga - nous pouvons nous mouiller et «écouter» les fonctions fournies par le middleware ( put , take , etc.), et vérifiez qu'ils ont été appelés (et appelés avec les paramètres corrects). Autrement dit, dans ce cas, la fonction n'accède pas directement à une autre fonction, mais fait référence à une fonction de la bibliothèque, qui appelle ensuite d'autres fonctions / sagas nécessaires.


"Pourquoi ne pas essayer ce middleware?" J'ai pensé et je me suis mis au travail. Il a commencé une histoire technique à Jira, y a créé plusieurs tâches (de la recherche à la mise en œuvre et à la description de l'architecture de l'ensemble du système), a reçu le «feu vert» et a commencé à faire une copie minimale du système actuel avec une nouvelle approche.


Au début, tout s'est bien passé. Sur les conseils d'un des développeurs, il a même été possible de créer une saga globale pour le chargement des données et le traitement des erreurs sur une nouvelle approche. Cependant, à un moment donné, il y a eu des problèmes de test (qui, soit dit en passant, n'ont pas été résolus jusqu'à présent). Je pensais que cela pourrait détruire tous les tests disponibles pour le moment et produire un tas de bugs, alors j'ai décidé de reporter le travail sur cette tâche jusqu'à ce qu'il y ait une solution au problème, et je suis passé aux tâches du produit.


Un mois ou deux se sont écoulés, aucune solution n'a été trouvée et, à un moment donné, après en avoir discuté avec eux. menant des progrès (absents) dans cette tâche, ils ont décidé d'abandonner la mise en œuvre de Redux-Saga dans le projet, car à ce moment-là, il était devenu trop cher en termes de coûts de main-d'œuvre et de nombre possible de bugs. Nous avons donc finalement décidé d'utiliser Redux Thunk.


Une tentative de résoudre le problème numéro 2. Modules Thunk


Vous pouvez trier tous les thunk dans des fichiers différents, et dans les fichiers où un thunk en appelle un autre (importé), vous pouvez effacer cette importation en utilisant la méthode jest.mock ou en utilisant le même jest.spyOn . Ainsi, nous accomplirons la tâche ci-dessus de vérifier qu'un certain thunk externe a été appelé avec les paramètres nécessaires, sans se soucier des moks pour cela. De plus, il serait préférable de casser tout le thunk selon leur fonction, afin de ne pas les garder tous en un seul tas. Ainsi, trois de ces espèces ont été distinguées:


  • LiĂ© Ă  l'utilisation de modèles - templates .
  • LiĂ© Ă  l'utilisation du filtre (tri, affichage des colonnes) - filter .
  • LiĂ© Ă  l'utilisation de la table (chargement de nouvelles donnĂ©es lors du dĂ©filement, car la table a un dĂ©filement virtuel, chargement des mĂ©tadonnĂ©es, chargement des donnĂ©es par le compteur d'enregistrements dans la table, etc.) - table .

La structure de dossiers et de fichiers suivante a été proposée:


 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) 

Un exemple de cette architecture est ici .


Dans le fichier de test pour applyFilter, vous pouvez voir que nous avons atteint l'objectif que nous visions - vous ne pouvez pas Ă©crire de mokas pour maintenir le bon fonctionnement de fetchData / fetchTotalCounter . Mais Ă  quel prix ...



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

Pour la modularité des tests, j'ai dû payer avec une duplication de code et une très forte dépendance des thunk les uns par rapport aux autres. Le moindre changement dans la chaîne d'appel entraînera une refactorisation importante.


Dans l'exemple ci-dessus, l'exemple de table et de filter été démontré afin de maintenir la cohérence des exemples donnés. En fait, le refactoring a été lancé avec des templates (car il s'est avéré être plus simple), et là, en plus du refactoring ci-dessus, le concept de travail avec des modèles a été légèrement modifié. À titre d'hypothèse, il a été admis qu'il ne peut y avoir qu'un seul panneau de modèles sur une page (comme un tableau). A cette époque, c'était juste ça, et cela omission l'hypothèse nous a permis de simplifier un peu le code en supprimant le prefix .
Après que les modifications aient été versées dans la branche principale de développement et testées, je suis parti en vacances avec une âme calme afin de continuer à transférer le reste du code vers une nouvelle approche après mon retour.


À mon retour de vacances, j'ai été surpris de constater que mes modifications avaient été annulées. Il s'est avéré qu'une page est apparue sur laquelle il peut y avoir plusieurs tables indépendantes, c'est-à-dire que l'hypothèse faite plus tôt a tout cassé. Donc tout le travail a été fait en vain ...


Enfin presque. En fait, il serait possible de refaire toutes les mêmes actions (l'avantage de la demande de fusion / extraction n'a pas disparu, mais est resté dans l'historique), en laissant l'approche de l'architecture du modèle inchangée, et en changeant uniquement l'approche d'organisation des thunk-s. Mais cette approche n'inspire toujours pas confiance en raison de sa cohérence et de sa complexité. Il n'y avait aucun désir d'y revenir, bien que cela ait résolu le problème indiqué avec les tests. Il fallait trouver autre chose, plus simple et plus fiable.


Une tentative de résoudre le problème numéro 3. Celui qui cherche trouvera


En regardant globalement comment les tests sont écrits pour le thunk, j'ai remarqué à quel point les méthodes (en fait, les champs d'objet) de entityModel facilement et sans problème entityModel .


Puis l'idée est venue: pourquoi ne pas créer une classe dont les méthodes sont des créateurs de thunk et d'action? Les paramètres passés à la fabrique seront transmis au constructeur de cette classe et seront accessibles par this biais. Vous pouvez immédiatement effectuer une petite optimisation en créant une classe distincte pour les créateurs d'action et une classe distincte pour Thunk, puis héritez l'une de l'autre. Ainsi, ces classes fonctionneront comme une seule (lors de la création d'une instance de la classe héritière), mais en même temps, chaque classe individuellement sera plus facile à lire, à comprendre et à tester.


Voici un code illustrant cette approche.


Examinons plus en détail chacun des fichiers qui sont apparus et modifiés.



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

  • Dans le fichier FilterActions.js , FilterActions.js FilterActionCreators classe FilterActionCreators et dĂ©finissons le thunk applyFilter comme mĂ©thode de cette classe. Dans ce cas, les applyFilterSuccess action applyFilterSuccess et applyFilterError y seront disponibles via 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)); } }; }; }; } 

  • Dans le fichier principal avec tous les FilterActions thunk et d'action, nous crĂ©ons une instance de la classe FilterActions , en lui passant l'objet de configuration nĂ©cessaire. Lors de l'exportation de fonctions (Ă  la toute fin de la fonction actionsCreator ), n'oubliez pas de remplacer la mĂ©thode applyFilter pour lui passer la dĂ©pendance 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 }), }; }; 

  • Les tests sont devenus un peu plus faciles tant en mise en Ĺ“uvre qu'en lecture:

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

En principe, dans les tests, vous pouvez remplacer le dernier contrôle de cette façon:


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

Il ne serait alors pas nécessaire de les tamponner avec 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/fr469371/


All Articles