Hola a todos!
En esta publicación, me gustaría compartir mi enfoque para organizar y probar el código usando Redux Thunk en un proyecto React .
El camino fue largo y espinoso, así que intentaré demostrar el tren de pensamiento y motivación que condujo a la decisión final.
Descripción de la solicitud y enunciado del problema.
Primero, un pequeño contexto.
La siguiente figura muestra el diseño de una página típica en nuestro proyecto.

En orden:
- La tabla (No. 1) contiene datos que pueden ser muy diferentes (texto plano, enlaces, imágenes, etc.).
- El panel de clasificación (No. 2) establece la configuración de clasificación de datos en la tabla por columnas.
- El panel de filtrado (No. 3) establece varios filtros para las columnas de la tabla.
- El panel de columnas (No. 4) le permite configurar la visualización de las columnas de la tabla (mostrar / ocultar).
- El panel de plantillas (No. 5) le permite seleccionar plantillas de configuración creadas previamente. Las plantillas incluyen datos de los paneles No. 2, No. 3, No. 4, así como algunos otros datos, por ejemplo, la posición de las columnas, su tamaño, etc.
Los paneles se abren haciendo clic en los botones correspondientes.
Los datos sobre qué columnas en la tabla pueden ser, qué datos pueden estar en ellas, cómo deben mostrarse, qué valores pueden contener los filtros y otra información está contenida en los metadatos de la tabla, que se solicitan por separado de los datos al comienzo de la carga de la página.
Resulta que el estado actual de la tabla y los datos que contiene dependen de tres factores:
- Datos de los metadatos de la tabla.
- Configuraciones para la plantilla seleccionada actualmente.
- Configuraciones personalizadas (cualquier cambio relacionado con la plantilla seleccionada se guarda en una especie de "borrador", que se puede convertir en una nueva plantilla, actualizar la actual con la nueva configuración, o eliminarla y devolver la plantilla a su estado original).
Como se mencionó anteriormente, tal página es típica. Para cada una de esas páginas (o, más precisamente, para la tabla que contiene), se crea una entidad separada en el repositorio de Redux para la conveniencia de operar con sus datos y parámetros.
Para poder establecer conjuntos homogéneos de creadores de acciones y thunk y actualizar datos en una entidad específica, se utiliza el siguiente enfoque (una especie de fábrica):
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);
Donde:
prefix
: prefix
entidad en el repositorio de Redux. Es una cadena de la forma "CATS_", "MICE_", etc.getCurrentStore
: un selector que devuelve los datos actuales de la entidad desde el repositorio de Redux.entityModel
: una instancia de la clase de modelo de entidad. Por un lado, se accede a una API a través del modelo para crear una solicitud al servidor, por otro lado, se describe una lógica de procesamiento de datos compleja (o no).
Por lo tanto, esta fábrica le permite describir de manera flexible la administración de datos y parámetros de una entidad en particular en el repositorio de Redux y asociar esto con la tabla correspondiente a esta entidad.
Dado que hay muchos matices en la administración de este sistema, el thunk puede ser complejo, voluminoso, confuso y tener partes repetidas. Para simplificarlos, así como para reutilizar el código, los thunks complejos se dividen en otros más simples y se combinan en una composición. Como resultado de esto, ahora puede fetchTotalCounter
que un thunk llama a otro, que ya puede enviar applyFilter
ordinarias (como el paquete fetchTotalCounter
- fetchTotalCounter
del ejemplo anterior). Y cuando se tuvieron en cuenta todos los puntos principales, y se describieron todos los creadores de acciones y thunk necesarios, el archivo que contenía la función actionsCreator
tenía ~ 1200 líneas de código y se probó con gran chirrido. El archivo de prueba también tenía alrededor de 1200 líneas, pero la cobertura era en el mejor de 40-50%.
Aquí, el ejemplo, por supuesto, se simplifica enormemente, tanto en términos del número de thunk como de su lógica interna, pero esto será suficiente para demostrar el problema.
Presta atención a 2 tipos de thunk en el ejemplo anterior:
fetchTotalCounter
: fetchTotalCounter
despacho solo.applyFilter
: además del envío de sus applyFilterSuccess
( applyFilterSuccess
, applyFilterError
), también es otro thunk ( fetchTotalCounter
).
Volveremos a ellos un poco más tarde.
Todo esto se probó de la siguiente manera (el marco se usó para probar 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 = [
Como puede ver, no hay problemas al probar el primer tipo de thunk: solo necesita conectar el entityModel
modelo entityModel, pero el segundo tipo es más complicado: debe borrar los datos de toda la cadena de thunk llamado y los métodos de modelo correspondientes. De lo contrario, la prueba recaerá en la desestructuración de los datos ( {data: {payload}} ), y esto puede suceder explícita o implícitamente (fue tal que la prueba pasó con éxito, pero con una cuidadosa investigación se notó que en el segundo / tercero enlace de esta cadena hubo una caída en la prueba debido a la falta de datos bloqueados). También es malo que las pruebas unitarias de funciones individuales se conviertan en una especie de integración y se relacionen estrechamente.
Surge la pregunta: ¿por qué en la función applyFilter
verifica cómo se fetchTotalCounter
función fetchTotalCounter
si ya se han escrito pruebas detalladas separadas para ella? ¿Cómo puedo hacer que la prueba del segundo tipo de thunk sea más independiente? Sería genial tener la oportunidad de probar que thunk (en este caso fetchTotalCounter
) solo se llama con los parámetros correctos , y no habría necesidad de cuidar a los moks para que funcione correctamente.
¿Pero cómo hacerlo? Me viene a la mente la decisión obvia: conectar la función fetchData, que se llama en applyFilter
, o bloquear fetchTotalCounter
(ya que a menudo se llama a otro thunk directamente, y no a través de alguna otra función como fetchData
).
Probémoslo. Por ejemplo, cambiaremos solo un script exitoso.
- Opción número 1. Función de
fetchData
simulada:
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); }); }); });
Aquí, el método jest.spyOn
reemplaza aproximadamente (y tal vez exactamente) la siguiente implementación:
actions.fetchData = jest.fn(actions.fetchData);
Esto nos permite "monitorear" la función y comprender si se llamó y con qué parámetros.
Recibimos el siguiente error:
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", + }, ]
Extraño, ocultamos la función fetchData, fetchData
nuestra implementación
fetchData.mockResolvedValueOnce({ data: { payload } })
pero la función funciona exactamente igual que antes, es decir, ¡el simulacro no funcionó! Probémoslo de manera 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); }); }); });
Obtenemos exactamente el mismo error. Por alguna razón, nuestros mokas no reemplazan la implementación original de funciones.
Habiendo explorado este problema por mi cuenta y encontrando información en Internet, me di cuenta de que este problema existe no solo conmigo, y se resuelve (en mi opinión) de manera crucial. Además, los ejemplos descritos en estas fuentes son buenos hasta que se convierten en parte de algo que los conecta en un solo sistema (en nuestro caso, es una fábrica con parámetros).
En nuestro proyecto en la tubería de Jenkins hay una verificación de código de SonarQube, que requiere cubrir los archivos modificados (que están en la solicitud de fusión / extracción) > 60%
. Como la cobertura de esta fábrica, como se dijo anteriormente, no era satisfactoria, y la sola necesidad de cubrir dicho archivo solo causó depresión, algo tuvo que hacerse con él, de lo contrario, la entrega de nueva funcionalidad podría disminuir con el tiempo. Solo se guardó la cobertura de prueba de otros archivos (componentes, funciones) en la misma solicitud de fusión / extracción, para alcanzar el% de cobertura de la marca deseada, pero, de hecho, fue una solución alternativa, no una solución al problema. Y un buen momento, después de haber asignado un poco de tiempo en la carrera, comencé a pensar cómo se puede resolver este problema.
Un intento de resolver el problema número 1. Escuché algo sobre Redux-Saga ...
... y me dijeron que las pruebas se simplifican enormemente al usar este middleware.
De hecho, si mira la documentación , se sorprenderá de lo simple que se prueba el código. El jugo en sí radica en el hecho de que con este enfoque no hay ningún problema con el hecho de que una saga puede llamar a otra saga: podemos mojarnos y "escuchar" las funciones proporcionadas por el middleware ( put
, take
, etc.), y verifique que fueron llamados (y llamados con los parámetros correctos). Es decir, en este caso, la función no accede a otra función directamente, sino que se refiere a una función de la biblioteca, que luego llama a otras funciones / sagas necesarias.
"¿Por qué no probar este middleware?" Pensé y me puse a trabajar. Comenzó una historia técnica en Jira, creó varias tareas (desde la investigación hasta la implementación y la descripción de la arquitectura de todo este sistema), recibió el "visto bueno" y comenzó a hacer una copia mínima del sistema actual con un nuevo enfoque.
Al principio, todo salió bien. Siguiendo el consejo de uno de los desarrolladores, incluso fue posible crear una saga global para cargar datos y manejar errores con un nuevo enfoque. Sin embargo, en algún momento hubo problemas con las pruebas (que, por cierto, no se han resuelto hasta ahora). Pensé que esto podría destruir todas las pruebas disponibles actualmente y producir un montón de errores, así que decidí posponer el trabajo en esta tarea hasta que hubiera alguna solución al problema, y me puse manos a la obra.
Pasaron un mes o dos, no se encontró ninguna solución, y en algún momento, después de haber discutido con ellos. liderando (ausente) el progreso en esta tarea, decidieron abandonar la implementación de Redux-Saga en el proyecto, ya que para ese momento se había vuelto demasiado costoso en términos de costos laborales y la posible cantidad de errores. Así que finalmente decidimos usar Redux Thunk.
Un intento de resolver el problema número 2. Módulos Thunk
Puede ordenar todos los thunk en diferentes archivos, y en aquellos archivos donde un thunk llama a otro (importado), puede borrar esta importación usando el método jest.mock
o usando el mismo jest.spyOn
. Por lo tanto, lograremos la tarea anterior de verificar que se llamó a algún thunk externo con los parámetros necesarios, sin preocuparse por los moks. Además, sería mejor romper todos los thunk de acuerdo con su propósito funcional, para no mantenerlos a todos en un montón. Entonces se distinguieron tres de esas especies:
- Relacionado con trabajar con plantillas:
templates
. - Relacionado con trabajar con el filtro (ordenar, mostrar columnas) -
filter
. - Relacionado con el trabajo con la tabla (cargando nuevos datos al desplazarse, porque la tabla tiene un desplazamiento virtual, cargando metadatos, cargando datos por el contador de registros en la tabla, etc.) -
table
.
Se propuso la siguiente estructura de carpetas y archivos:
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 ejemplo de esta arquitectura está aquí .
En el archivo de prueba para applyFilter, puede ver que hemos alcanzado el objetivo por el que nos esforzamos: no puede escribir mokas para mantener el funcionamiento correcto de fetchData
/ fetchTotalCounter
. Pero a qué costo ...
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) }; };
Por la modularidad de las pruebas, tuve que pagar con duplicación de código y una dependencia muy fuerte de thunk entre sí. El más mínimo cambio en la cadena de llamadas conducirá a una refactorización considerable.
En el ejemplo anterior, se demostró el ejemplo de table
y filter
para mantener la coherencia de los ejemplos dados. De hecho, la refactorización se inició con templates
(ya que resultó ser más simple), y allí, además de la refactorización anterior, el concepto de trabajar con plantillas cambió ligeramente. Como suposición, se aceptó que solo puede haber un panel de plantillas en una página (como una tabla). En ese momento era solo eso, y esto omisión la suposición nos permitió simplificar un poco el código al eliminar el prefix
.
Después de que los cambios se vierten en la rama de desarrollo principal y se prueben, me fui de vacaciones con un alma tranquila para continuar transfiriendo el resto del código a un nuevo enfoque después de regresar.
Después de regresar de vacaciones, me sorprendió descubrir que mis cambios se habían revertido. Resultó que apareció una página en la que puede haber varias tablas independientes, es decir, la suposición hecha anteriormente rompió todo. Así que todo el trabajo se hizo en vano ...
Pues casi. De hecho, sería posible volver a hacer las mismas acciones (el beneficio de la solicitud de fusión / extracción no desapareció, pero permaneció en la historia), dejando el enfoque de la arquitectura de plantilla sin cambios y cambiando solo el enfoque para organizar thunk-s. Pero este enfoque todavía no inspiraba confianza debido a su coherencia y complejidad. No había ningún deseo de volver a él, aunque esto resolvió el problema indicado con las pruebas. Era necesario idear algo más, más simple y más confiable.
Un intento de resolver el problema número 3. El que busca encontrará
Al observar globalmente cómo se escriben las pruebas para thunk, noté con qué facilidad y sin problemas los métodos (de hecho, los campos de objetos) de entityModel
.
Entonces surgió la idea: ¿por qué no crear una clase cuyos métodos son creadores de acción y thunk? Los parámetros pasados a la fábrica se pasarán al constructor de esta clase y serán accesibles a través de this
. Puede hacer una pequeña optimización de inmediato haciendo una clase separada para creadores de acción y una separada para thunk, y luego heredar una de otra. Por lo tanto, estas clases funcionarán como una sola (al crear una instancia de la clase heredera), pero al mismo tiempo cada clase individualmente será más fácil de leer, comprender y evaluar.
Aquí hay un código que demuestra este enfoque.
Consideremos con más detalle cada uno de los archivos que han aparecido y cambiado.
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, }); }
- En el archivo
FilterActions.js
, heredamos de la clase FilterActionCreators
y definimos thunk applyFilter
como un método de esta clase. En este caso, los applyFilterSuccess
acciones applyFilterSuccess
y applyFilterError
estarán disponibles a través de this
:
import { FilterActionCreators } from '/FilterActionCreators';
- En el archivo principal con todos los
FilterActions
thunk y action, creamos una instancia de la clase FilterActions
, pasándole el objeto de configuración necesario. Al exportar funciones (al final de la función actionsCreator
), no olvide anular el método applyFilter
para pasarle la dependencia 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 }), }; };
- Las pruebas se han vuelto un poco más fáciles tanto en la implementación como en la lectura:
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 principio, en las pruebas, podría reemplazar la última verificación de esta manera:
- expect(applyFilterSuccess).toBeCalledWith(payload); + expect(dispatch).toBeCalledWith(applyFilterSuccess(payload)); - expect(applyFilterError).toBeCalledWith(error); + expect(dispatch).toBeCalledWith(applyFilterError(error));
Entonces no habría necesidad de jest.spyOn
con 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%.
Bono
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 . , .