Usando Redux Toolkit y Redux Saga para crear una aplicación con React - Parte II
Continuando con la serie de artículos sobre Redux Toolkit y Redux Saga nos concentraremos ahora en la creación del proyecto, la estructura inicial y casos de uso iniciales.
En la primera parte de esta serie se describió el modelo de flujo de datos dentro de una aplicación en React obtenido mediante el uso de estas librerías.
Ahora podremos observar en código cómo se realiza la implementación para su uso en la aplicación de cursos escolares mencionada en el post anterior.
En la tercera parte se observa cómo se implementan acciones que requieren interacción del usario y sus correspondientes tests.
El código completo utilizado en la serie reside en Gitlab: https://gitlab.com/raymundo.vr/curseca
Inicializando el proyecto
El proyecto ha sido creado mediante create-react-app con la plantilla para typescript.
Adicionalmente realizaremos la instalación de las librerías para el manejo del estado: react-redux, @reduxjs/toolkit y redux-saga.
; npx create-react-app curseca --template typescript
; cd curseca
; yarn add redux @reduxjs/toolkit redux-saga react-redux
; npm start
Una vez instaladas las dependencias podemos iniciar el proyecto con npx start
.
Configuración adicional
Para la interfaz de usuario el proyecto utiliza los componentes de React Material UI. Dado que el proyecto carece de una implementación para el servidor usaremos Mock Service Worker para capturar las peticiones HTTP y realizar las acciones correspondientes dentro de la misma aplicación. Dichas peticiones serán realizadas a través de Axios.
; yarn add @mui/material @emotion/react @emotion/styled @mui/icons-material
; yarn add msw
; yarn add axios
Para poder realizar tests de la aplicación haremos uso de jest y testing-library para React las cuales ya fueron instaladas con create-react-app
. Lo único que diferencia a este proyecto de la plantilla inicial es que reemplazaremos el subcomando test
por una llamada a jest
.
// package.json
"scripts": {
...
"test": "jest",
...
},
Esto requiere configuración independiente para jest
, la cual se halla en el archivo jest.config.js
Creando el estado global
Comenzaremos con la definición de la estructura de datos que define a un Curso, la cual será utilizada dentro de la aplicación.
// src/common/types.ts
export interface Course {
id: string;
name: string;
description: string;
}
A partir de ella podemos comenzar a construir el modelo para el estado de la aplicación mediante Redux. Inicialmente declararemos que la aplicación comienza con un catálogo de Cursos vacío.
Se puede observar cómo Redux Toolkit es utilizado para dicho modelo mediante el uso de la función createSlice
, la cual acepta el estado inicial, un objeto con funciones para efectuar los cambios en el estado (reducers) y un nombre para esta porción del estado. La función retorna un objeto que contiene las acciones y sus tipos para que podamos utilizarlas en la aplicación.
// src/state/features/catalogue/reducers.ts
import { createSlice } from "@reduxjs/toolkit";
import { Course } from "../../../common/types";
interface OwnState {
courses: Course[];
}
const initialState: OwnState = {
courses: [],
};
**export const catalogueSlice = createSlice({
name: 'catalogue',
initialState,
reducers: {
},
});**
export default catalogueSlice.reducer;
Con esto podemos crear el estado global de la aplicación, que en Redux es llamado store. En este caso lo haremos usando la función configureStore también de Redux Toolkit, la cual es una abstracción a la que le indicamos qué funciones estarán disponibles para cambiar el estado de la aplicación así como qué funciones pueden intervenir cuando dichos cambios están por suceder (middleware).
// src/state/store.ts
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import catalogueReducer from './features/catalogue/reducers';
**const store = configureStore({
reducer: combineReducers({ catalogue: catalogueReducer }),
});**
export type RootState = ReturnType<typeof store.getState>;
export default store;
Finalmente podemos indicar en la aplicación que su estado global será proveído por la store que acabamos de crear.
// src/index.tsx
...
**import store from './state/store';
import { Provider } from 'react-redux';**
ReactDOM.render(
<React.StrictMode>
**<Provider store={store}>**
<App />
**</Provider>**
</React.StrictMode>,
document.getElementById('root')
);
Integrando la Saga
Es momento de integrar el manejo del flujo de datos asíncrono entre el cliente y el servidor. En este caso comenzaremos con la carga de los cursos dentro del catálogo. Además incluiremos un par de banderas para indicar si este proceso está activo o si ha resultado en un error.
La carga de los cursos se realiza de la siguiente manera
Flujo de carga del catálogo
// src/state/features/catalogue/reducers.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { Course } from "../../../common/types";
import { RootState } from "../../store";
interface OwnState {
courses: Course[];
hasError: boolean;
isLoading: boolean;
}
const initialState: OwnState = {
courses: [],
hasError: false,
isLoading: false,
};
export const catalogueSlice = createSlice({
name: 'catalogue',
initialState,
reducers: {
catalogueFetched: (state, action: PayloadAction<Course[]>) => {
state.isLoading = false;
state.courses = action.payload;
}, errorFetchingCatalogue: (state) => {
state.isLoading = false;
state.hasError = true;
}, fetchCatalogue: (state) => {
state.isLoading = true;
},
},
});
export const courses = (state: RootState) => state.catalogue.courses;
export const { catalogueFetched, errorFetchingCatalogue, fetchCatalogue } = catalogueSlice.actions;
export default catalogueSlice.reducer;
Si se observa correctamente el reducer solamente se encarga de asignar la información correspondiente a la tarea que se está ejecutando. La saga, dado que se encarga del flujo asíncrono, interceptará la llamada para realizar la ejecución de la obtención de datos del servidor. Dicha llamada está dada por la acción fetchCatalogue
. Cuando esta se realiza la función generadoraloadCatalogue
es ejecutada.
// src/state/features/catalogue/sagas.ts
import { call, put, takeEvery } from 'redux-saga/effects';
import { getCatalogue } from '../../../api';
import { Course } from '../../../common/types';
import { catalogueFetched, errorFetchingCatalogue, fetchCatalogue } from './reducers';
export function* loadCatalogue() {
try {
const courses: Course[] = yield call(getCatalogue);
yield put(**catalogueFetched**(courses));
} catch (err) {
yield put(**errorFetchingCatalogue**());
}
}
export default function* catalogueSaga() {
yield takeEvery(**fetchCatalogue.type**, loadCatalogue);
}
Para que la saga sea capaz de reaccionar es necesario integrarla como middleware a la store.
import { combineReducers, configureStore } from "@reduxjs/toolkit";
import createSagaMiddleware from "@redux-saga/core";
import catalogueReducer from './features/catalogue/reducers';
import catalogueSaga from "./features/catalogue/sagas";
const sagaMiddleWare = createSagaMiddleware();
const store = configureStore({
reducer: combineReducers({ catalogue: catalogueReducer }),
middleware: [sagaMiddleWare],
});
sagaMiddleWare.run(catalogueSaga);
export type RootState = ReturnType<typeof store.getState>;
export default store;
Integrando los tests
Ahora podemos realizar los tests del comportamiento de la aplicación. En este ejercicio probaremos sólo tres casos:
- Mostrar un mensaje para informar al usuario que no hay cursos dentro del catálogo.
- Mostrar un mensaje para informar al usuario que hubo un error al cargar el catálogo.
- Mostrar el listado de los cursos del catálogo.
Estos comportamientos se encuentran dentro de un sólo componente llamado [Catalogue](https://gitlab.com/raymundo.vr/curseca/-/blob/main/src/screens/Catalogue.tsx)
.
Como se mencionó anteriormente los tests están escritos con jest
y @testing-library/react
. Utilizaremos también Mock Service Worker
con la cual controlaremos las respuestas del servidor y podremos evaluar correctamente el comportamiento de la aplicación.
Dado que sólo necesitamos probar el componente Catalogue
y que la aplicación maneja su estado mediante la store es necesario envolver a este componente con la store, de lo contrario no habrá llamadas a los reducers ni a la saga.
En el archivo [Catalogue.test.tsx](https://gitlab.com/raymundo.vr/curseca/-/blob/main/src/tests/Catalogue.test.tsx)
se puede observar la pequeña función renderWithWrapper
que se encarga de envolver al componente con otro componente que mantiene la store.
Mostrar un mensaje para informar al usuario que no hay cursos dentro del catálogo.
El test efectua la carga del componente, posteriormente valida que el componente muestre el mensaje “No hay cursos disponibles”.
Para obtener este comportamiento el servidor debe devolver un mensaje que contenga un arreglo de cursos vacío. El cual, por conveniencia, hemos definido como el comportamiento por defecto del servidor dentro de los tests.
Hay que observar que se debe completar el ciclo del flujo de datos para que sea posible verificar el comportamiento. Para eso la función waitFor de testing-library
permite, dentro de un tiempo limitado, esperar a que cierto componente aparezca en pantalla. Si dicho componente no aparece dentro de dicho límite el test dará un resultado negativo.
El componente que observaremos con waitFor está determinado por el rol “main”.
// src/tests/Catalogue.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { Provider } from 'react-redux';
import Catalogue from '../screens/Catalogue';
import store from '../state/store';
const server = setupServer(
rest.get('/course', (req, res, ctx) => res(ctx.json([]))),
);
const Wrapper = ({children}: {children: JSX.Element}) => <Provider store={store}>{children}</Provider>;
const renderWithWrapper = (component: JSX.Element) => render(component, { wrapper: Wrapper });
describe("Comportamiento de Catalogue", () => {
beforeAll(() => {
server.listen()
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
})
it(**"Debe mostrar un mensaje cuando no hay cursos disponibles"**, () => {
renderWithWrapper(<Catalogue />);
await (waitFor(() => { screen.getByRole('main'); }))
expect(screen.getByText('No hay cursos disponibles')).toBeInTheDocument();
});
});
Ahora podemos definir dicho comportamiento dentro del componente Catalogue
.
Lo primero que se debe observar es que el componente realiza la carga de datos del catálogo al inicio mediante un dispatch
a la acción fetchCatalogue
del reducer. El valor obtenido como respuesta a esa carga estará contenido dentro de la propiedad courses
, una de las variables de estado del reducer. Las variables que residen en la store son accedidas mediante la función useSelector
.
// src/screens/Catalogue.tsx
import React, { useEffect } from "react";
import { Box } from "@mui/material";
import { useDispatch, useSelector } from "react-redux";
import { courses, fetchCatalogue } from "../state/features/catalogue/reducers";
const Catalogue = () => {
const coursesInCatalogue = useSelector(courses);
const isCatalogueLoading = useSelector(isLoading);
...
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchCatalogue());
}, []);
return (
<Box>
<h1>Catálogo</h1>
{!isCatalogueLoading && (
<div className="div__catalogue" role="main">
...
{!hasErrorFetching &&coursesInCatalogue.length === 0 && (
<p>No hay cursos disponibles</p>
)}
...
</div>
</Box>
);
};
export default Catalogue;
Mostrar un mensaje para informar al usuario que hubo un error al cargar el catálogo.
Para este test el servidor debe responder con un mensaje de error, en este caso es un código 500 que indica un error interno.
Con esa respuesta erronea el test valida que el componente muestre el mensaje “Ha habido un error al cargar el catálogo”. El centinela para indicar que el ciclo se ha completado es el determinado por el componente con el rol “alert”.
// src/tests/Catalogue.test.tsx
...
it(**"Debe mostrar un mensaje de error cuando el catálogo no puede ser descargado"**, async () => {
server.use(
rest.get('/course', (req, res, ctx) => res(ctx.status(500)))
);
renderWithWrapper(<Catalogue />);
await waitFor(() => { screen.getByRole('alert'); });
expect(screen.getByRole('alert')).toHaveTextContent('Ha habido un error al cargar el catálogo');
});
...
Para definir dicho comportamiento la bandera indicativa de un error en el flujo de datos del reducer está disponible mediante la propiedad hasError
del reducer.
// src/screens/Catalogue.tsx
...
import {
courses,
fetchCatalogue,
hasError,
} from "../state/features/catalogue/reducers";
...
const Catalogue = () => {
const hasErrorFetching = useSelector(hasError);
...
return(
<Box>
...
{ hasErrorFetching && <p role="alert">Ha habido un error al cargar el catálogo</p> }
...
</Box>
...
);
}
Mostrar el listado de los cursos del catálogo.
Finalmente el componente debe mostrar el listado de los cursos dentro del catálogo. Para poder verificarlo haremos eso de la propiedad data-testid
, la cual debe contener para cada curso su identificador correspondiente.
En este caso el centinela para indicar que el ciclo de carga es nuevamente el componente con el rol “main”. Posteriormente verificaremos que se hayan cargo tres cursos con sus correspondientes identificadores tal y como se espera a partir de la respuesta del servidor. Esta verificación hace uso de la selección mediante la función queryAllByTestId
, la cual es parte de testing-library
.
// src/tests/Catalogue.test.tsx
...
it(**"Debe listar los cursos dentro de un catálogo"**, async () => {
const courses: Course[] = [
{
id: 'test-1',
name: 'Test Course One',
description: 'Test description'
},
{
id: 'test-2',
name: 'Test Course Two',
description: 'Test description'
},
{
id: 'test-3',
name: 'Test Course Three',
description: 'Test description'
}
];
server.use(
rest.get('/course', (req, res, ctx) => res(ctx.json(courses)))
);
renderWithWrapper(<Catalogue />);
await waitFor(() => { screen.getByRole('main'); });
const courseItems = screen.queryAllByTestId(/course-test-\d/);
expect(courseItems).toHaveLength(3);
expect(
courseItems.map(i => i.getAttribute('data-testid'))
).toEqual(expect.arrayContaining(['course-test-1', 'course-test-2', 'course-test-3']))
});
...
En el componente Catalogue
implementaremos dicho comportamiento poniendo atención a la propiedad data-testid
de los componentes necesarios cuando se realiza la muestra de la información de cada curso.
// src/screens/Catalogue.tsx
...
import {
courses,
fetchCatalogue,
hasError,
} from "../state/features/catalogue/reducers";
...
const Catalogue = () => {
const coursesInCatalogue = useSelector(courses);
...
return (
<Box>
...
{!hasErrorFetching && coursesInCatalogue.length > 0 && (
<List>
{coursesInCatalogue.map((course) => (
<React.Fragment key={course.id}>
<ListItem data-testid={`course-${course.id}`} key={course.id}
>
...
</ListItem>
<Divider variant="inset" component="li" />
</React.Fragment>
))}
</List>
)}
</Box>
);
};
Ejecutando los tests
Para ejecutar los tests sólo se necesita llamar al comando yarn test
dentro del proyecto.
; **yarn test**
yarn run v1.22.17
$ jest
PASS src/tests/Catalogue.test.tsx (7.805 s)
Comportamiento de Catalogue
✓ Debe mostrar un mensaje cuando no hay cursos disponibles (132 ms)
✓ Debe mostrar un mensaje de error cuando el catálogo no puede ser descargado (48 ms)
✓ Debe listar los cursos dentro de un catálogo (72 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 8.291 s, estimated 11 s
Ran all test suites.
✨ Done in 9.36s.
Conclusión
El uso de Redux Toolkit nos permite mantener un patrón uniforme en la definición del estado global dentro de una aplicación React. Para manipular las llamadas asíncronas al servidor podemos hacer uso de Redux Saga el cual estará escuchando a las acciones que la aplicación lanza y por lo cual funcionará como middleware de la store de Redux.
Para validar el comportamiento de los componentes, y de la aplicación en general, podemos hacer uso de testing-library
y jest
como librerías para realizar los tests. Dado que queremos verificar el comportamiento desde la misma perspectiva del usuario utilizamos Mock Service Worker
para emular las respuestas obtenidas del servidor y obtener así los resultados correspondientes en pantalla.
Todo esto puede ser observado en el proyecto de muestra que está hospedado en Gitlab.
https://gitlab.com/raymundo.vr/curseca
Comments:
[…] La segunda parte muestra la implementación inicial del código. […]