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) => { 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 }; } 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);
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 = [
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';
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';
- 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, - }; };
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);
, 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; };
export class ExtendedActions { constructor(config) { this.getCurrentStore = config.getCurrentStore; this.entityModel = config.entityModel; } setDependencies = dependencies => { this.dependencies = dependencies; };
, , :
- , .
- .
- , , thunk- .
- , , thunk-/action creator- 99-100%.
action creator- ( filter
, templates
, table
), reducer- - , , actionsCreator
- , reducer- ~400-500 .
:
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; } } }; };
:
reducerConfigurator
- action type-, «». action type case, null ().reducerConfigurator
- , null , reducerConfigurator
- !null . , reducerConfigurator
- case, reducerConfigurator
-.- ,
reducerConfigurator
- case- action type-, ( reducer-).
, actionsCreator
-, , , , .
, !
, Redux Thunk.
, Redux Thunk . , .