Construir una pantalla
Nos hemos concentrado en crear interfaces de usuario de abajo hacia arriba; comenzando por lo pequeño y añadiendo complejidad. Esto nos ha permitido desarrollar cada componente de forma aislada, determinar los datos que necesita y jugar con ellos en Storybook. ¡Todo sin necesidad de levantar un servidor o construir pantallas!
En este capítulo continuaremos aumentando la sofisticación combinando componentes en una pantalla y desarrollando esa pantalla en Storybook.
Pantallas conectadas
Como nuestra aplicación es muy simple, la pantalla que construiremos es bastante trivial, simplemente obteniendo datos de una API remota, envolviendo el componente TaskList
(que proporciona sus propios datos a través de Redux) y sacando un campo error
de primer nivel de Redux.
Empezamos actualizando nuestro store de Redux (en src/lib/store.js
) para conectar a una API remota y manejar los diversos estados de nuestra aplicación (por ejemplo, error
, succeeded
):
/* A simple redux store/actions/reducer implementation.
* A true app would be more complex and separated into different files.
*/
import {
configureStore,
createSlice,
+ createAsyncThunk,
} from '@reduxjs/toolkit';
/*
* The initial state of our store when the app loads.
* Usually, you would fetch this from a server. Let's not worry about that now
*/
const TaskBoxData = {
tasks: [],
status: 'idle',
error: null,
};
/*
* Creates an asyncThunk to fetch tasks from a remote endpoint.
* You can read more about Redux Toolkit's thunks in the docs:
* https://redux-toolkit.js.org/api/createAsyncThunk
*/
+ export const fetchTasks = createAsyncThunk('todos/fetchTodos', async () => {
+ const response = await fetch(
+ 'https://jsonplaceholder.typicode.com/todos?userId=1'
+ );
+ const data = await response.json();
+ const result = data.map((task) => ({
+ id: `${task.id}`,
+ title: task.title,
+ state: task.completed ? 'TASK_ARCHIVED' : 'TASK_INBOX',
+ }));
+ return result;
+ });
/*
* The store is created here.
* You can read more about Redux Toolkit's slices in the docs:
* https://redux-toolkit.js.org/api/createSlice
*/
const TasksSlice = createSlice({
name: 'taskbox',
initialState: TaskBoxData,
reducers: {
updateTaskState: (state, action) => {
const { id, newTaskState } = action.payload;
const task = state.tasks.findIndex((task) => task.id === id);
if (task >= 0) {
state.tasks[task].state = newTaskState;
}
},
},
/*
* Extends the reducer for the async actions
* You can read more about it at https://redux-toolkit.js.org/api/createAsyncThunk
*/
+ extraReducers(builder) {
+ builder
+ .addCase(fetchTasks.pending, (state) => {
+ state.status = 'loading';
+ state.error = null;
+ state.tasks = [];
+ })
+ .addCase(fetchTasks.fulfilled, (state, action) => {
+ state.status = 'succeeded';
+ state.error = null;
+ // Add any fetched tasks to the array
+ state.tasks = action.payload;
+ })
+ .addCase(fetchTasks.rejected, (state) => {
+ state.status = 'failed';
+ state.error = "Something went wrong";
+ state.tasks = [];
+ });
+ },
});
// The actions contained in the slice are exported for usage in our components
export const { updateTaskState } = TasksSlice.actions;
/*
* Our app's store configuration goes here.
* Read more about Redux's configureStore in the docs:
* https://redux-toolkit.js.org/api/configureStore
*/
const store = configureStore({
reducer: {
taskbox: TasksSlice.reducer,
},
});
export default store;
Ahora que hemos actualizado nuestro store para recuperar los datos desde un endpoint de una API remota y lo hemos preparado para manejar los diversos estados de nuestra aplicación, vamos a crear nuestro InboxScreen.jsx
en el directorio src/components
:
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchTasks } from '../lib/store';
import TaskList from './TaskList';
export default function InboxScreen() {
const dispatch = useDispatch();
// We're retrieving the error field from our updated store
const { error } = useSelector((state) => state.taskbox);
// The useEffect triggers the data fetching when the component is mounted
useEffect(() => {
dispatch(fetchTasks());
}, []);
if (error) {
return (
<div className="page lists-show">
<div className="wrapper-message">
<span className="icon-face-sad" />
<p className="title-message">Oh no!</p>
<p className="subtitle-message">Something went wrong</p>
</div>
</div>
);
}
return (
<div className="page lists-show">
<nav>
<h1 className="title-page">Taskbox</h1>
</nav>
<TaskList />
</div>
);
}
También tenemos que cambiar nuestro componente App
para renderizar la pantalla InboxScreen
(al final usaríamos un router para elegir la pantalla correcta, pero no nos preocupemos por ello aquí):
- import { useState } from 'react'
- import reactLogo from './assets/react.svg'
- import viteLogo from '/vite.svg'
- import './App.css'
+ import './index.css';
+ import store from './lib/store';
+ import { Provider } from 'react-redux';
+ import InboxScreen from './components/InboxScreen';
function App() {
- const [count, setCount] = useState(0)
return (
- <div className="App">
- <div>
- <a href="https://vitejs.dev" target="_blank">
- <img src={viteLogo} className="logo" alt="Vite logo" />
- </a>
- <a href="https://reactjs.org" target="_blank">
- <img src={reactLogo} className="logo react" alt="React logo" />
- </a>
- </div>
- <h1>Vite + React</h1>
- <div className="card">
- <button onClick={() => setCount((count) => count + 1)}>
- count is {count}
- </button>
- <p>
- Edit <code>src/App.jsx</code> and save to test HMR
- </p>
- </div>
- <p className="read-the-docs">
- Click on the Vite and React logos to learn more
- </p>
- </div>
+ <Provider store={store}>
+ <InboxScreen />
+ </Provider>
);
}
export default App;
Sin embargo, donde las cosas se ponen interesantes es en la renderización de la historia en Storybook.
Como vimos anteriormente, el componente TaskList
ahora es un componente conectado y depende de un store de Redux para renderizar las tareas. Dado que InboxScreen
también es un componente conectado, haremos algo similar y proporcionaremos un store a la historia. Entonces cuando configuramos nuestras historias en InboxScreen.stories.jsx
:
import InboxScreen from './InboxScreen';
import store from '../lib/store';
import { Provider } from 'react-redux';
export default {
component: InboxScreen,
title: 'InboxScreen',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
tags: ['autodocs'],
};
export const Default = {};
export const Error = {};
Podemos detectar rápidamente un problema con la historia de error
. En lugar de mostrar el estado correcto, muestra una lista de tareas. Una formar de evitar este problema sería proporcionar una versión simulada para cada estado, similar a lo que hicimos en el último capítulo. En lugar de esto, utilizaremos una conocida librería de simulación de API junto con un complemento de Storybook para ayudarnos a resolver este problema.
Simulación de servicios de API
Ya que nuestra aplicación es bastante sencilla y no depende mucho en llamadas APIs remotas vamos a utilizar Mock Service Worker y el complemento de Storybook "MSW". Mock Service Worker es una librería de simulación de API. Depende de Service Workers para capturar solicitudes de red y proporciona datos simulados en las respuestas.
Cuando configuramos nuestra aplicación en la sección Empezando se instalaron ambos paquetes. Solo queda configurarlos y actualizar nuestras historias para usarlos.
En tu terminal, ejecuta el siguiente comando para generar un Service Worker genérico dentro de tu carpeta public
:
yarn init-msw
Luego, necesitamos actualizar nuestro .storybook/preview.js
e inicializarlos:
import '../src/index.css';
// Registers the msw addon
+ import { initialize, mswLoader } from 'msw-storybook-addon';
// Initialize MSW
+ initialize();
//👇 Configures Storybook to log the actions( onArchiveTask and onPinTask ) in the UI.
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
+ loaders: [mswLoader],
};
export default preview;
Por último, actualiza las historias InboxScreen
y incluye un parámetro que simula las llamadas API remotas:
import InboxScreen from './InboxScreen';
import store from '../lib/store';
+ import { http, HttpResponse } from 'msw';
+ import { MockedState } from './TaskList.stories';
import { Provider } from 'react-redux';
export default {
component: InboxScreen,
title: 'InboxScreen',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
tags: ['autodocs'],
};
export const Default = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+ return HttpResponse.json(MockedState.tasks);
+ }),
+ ],
+ },
+ },
};
export const Error = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+ return new HttpResponse(null, {
+ status: 403,
+ });
+ }),
+ ],
+ },
+ },
};
💡 Como comentario adicional, pasar datos a través de la jerarquía es una práctica legítima, especialmente cuando se utiliza GraphQL. Es como hemos construido Chromatic junto con más de 800 historias.
Revisa tu Storybook y vas a ver que la historia de error
está funcionando. MSW interceptó nuestra llamada API remota y proporcionó la respuesta adecuada.
Pruebas de componentes
Hasta ahora, hemos podido crear una aplicación completamente funcional desde cero, empezando desde un componente simple hasta una pantalla y probando continuamente cada cambio usando nuestras historias. Pero cada nueva historia también requiere una verificación manual de todas las demás historias para asegurar que la interfaz de usuario no se rompa. Eso es mucho trabajo extra.
¿No podemos automatizar este flujo de trabajo y probar las interacciones de nuestros componentes automáticamente?
Escribe una prueba de componente usando la función "play"
La función play
de Storybook y @storybook/addon-interactions
nos ayuda con esto. La función play
incluye pequeños fragmentos de código que se ejecutan después de que se renderiza la historia.
La función play nos ayuda a verificar lo que sucede a la interfaz de usuario cuando se actualizan las tareas. Usa APIs del DOM que son "framework-agnostic", lo que significa que podemos escribir historias con la función play para interactuar con la interfaz de usuario y simular el comportamiento humano sin importar el framework del frontend.
@storybook/addon-interactions
nos ayuda a visualizar nuestras pruebas en Storybook, dando un flujo paso a paso. También ofrece un práctico conjunto de controles de IU para pausar, continuar, retroceder y pasar paso a paso cada interacción.
¡Veámoslo en acción! Actualiza tu historia InboxScreen
recién creada y configura las interacciones de componentes agregando lo siguiente:
import InboxScreen from './InboxScreen';
import store from '../lib/store';
import { http, HttpResponse } from 'msw';
import { MockedState } from './TaskList.stories';
import { Provider } from 'react-redux';
+ import {
+ fireEvent,
+ waitFor,
+ within,
+ waitForElementToBeRemoved
+ } from '@storybook/test';
export default {
component: InboxScreen,
title: 'InboxScreen',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
tags: ['autodocs'],
};
export const Default = {
parameters: {
msw: {
handlers: [
http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
return HttpResponse.json(MockedState.tasks);
}),
],
},
},
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ // Waits for the component to transition from the loading state
+ await waitForElementToBeRemoved(await canvas.findByTestId('loading'));
+ // Waits for the component to be updated based on the store
+ await waitFor(async () => {
+ // Simulates pinning the first task
+ await fireEvent.click(canvas.getByLabelText('pinTask-1'));
+ // Simulates pinning the third task
+ await fireEvent.click(canvas.getByLabelText('pinTask-3'));
+ });
+ },
};
export const Error = {
parameters: {
msw: {
handlers: [
http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
return new HttpResponse(null, {
status: 403,
});
}),
],
},
},
};
💡 El paquete @storybook/test
reemplaza a los paquetes de prueba @storybook/jest
y @storybook/testing-library
, ofreciendo un tamaño de paquete más pequeño y una API más sencilla basada en el paquete Vitest.
Revisa la historia Default
. Haz click en el panel de Interactions
para ver la lista de interacciones dentro de la función play de la historia.
Automatizar pruebas con el "test runner"
Con la función de play
de Storybook, pudimos eludir nuestro problema, permitiéndonos interactuar con nuestra interfaz de usuario y verificar rápidamente cómo responde si actualizamos nuestras tareas, manteniendo la interfaz de usuario consistente sin ningún esfuerzo manual adicional.
Pero, si miramos a Storybook más a fondo, podemos ver que solo ejecuta las pruebas de interacción al ver la historia. Por lo tanto, todavía tendríamos que revisar cada historia para ejecutar todas los checks si hacemos un cambio. ¿No podríamos automatizarlo?
¡La buena noticia es que podemos! El test runner de Storybook nos permite hacer precisamente eso. Es una utilidad independiente accionado por Playwright que ejecuta todas nuestras pruebas de interacciones y detecta historias rotas.
Vamos a ver cómo funciona. Ejecuta el siguiente comando para instalarlo:
yarn add --dev @storybook/test-runner
Luego, actualiza tu package.json
scripts
y añade una nueva tarea de prueba:
{
"scripts": {
"test-storybook": "test-storybook"
}
}
Por último, con Storybook corriendo, abre una nueva ventana de teminal y ejecuta el siguiente comando:
yarn test-storybook --watch
💡 Las pruebas de componentes con la función play son una forma fantástica para probar los componentes de la interfaz de usuario. Puede hacer mucho más de lo que hemos visto aquí. Recomendamos leer la documentación oficial para aprender más al respecto.
Para profundizar aún más en las pruebas, puedes mirar el Manual de pruebas. Cubre las estrategias de prueba utilizadas por los equipos de front-end escalados para potenciar tu flujo de trabajo de desarrollo.
¡Éxito! Ahora tenemos una herramienta que nos ayuda a verificar si todas nuestras historias se renderizan sin errores y si todas las afirmaciones pasan automáticamente. Además, si una prueba falla, nos proporcionará un enlace en que abre la historia fallida en el navegador.
Desarrollo basado en componentes
Empezamos desde lo más básico con Task
, luego progresamos a TaskList
, y ahora estamos aquí con una interfaz de usuario de pantalla completa. Nuestra InboxScreen
acomoda componentes conectados e incluye historias correspondientes.
El desarrollo basado en componentes (CDD) te permite expandir gradualmente la complejidad a medida que asciendes en la jerarquía de componentes. Entre los beneficios están un proceso de desarrollo más enfocado y una mayor cobertura de todas las posibles mutaciones de la interfaz de usuario. En resumen, el CDD te ayuda a construir interfaces de usuario de mayor calidad y complejidad.
Aún no hemos terminado, el trabajo no termina cuando se construye la interfaz de usuario. También tenemos que asegurarnos de que siga siendo duradero a lo largo del tiempo.