Costruisci una schermata
Ci siamo concentrati sulla costruzione di interfacce utente dal basso verso l'alto, partendo da piccole e aggiungendo complessità. Farlo ci ha permesso di sviluppare ogni componente in isolamento, capire le sue esigenze di dati e giocare con esso in Storybook. Tutto senza dover avviare un server o costruire schermate!
In questo capitolo, continuiamo ad aumentare la sofisticatezza combinando i componenti in una schermata e sviluppando quella schermata in Storybook.
Schermate collegate
Poiché la nostra app è semplice, la schermata che costruiremo è piuttosto banale, semplicemente recuperando dati da un'API remota, avvolgendo il componente TaskList
(che fornisce i propri dati da Redux) e tirando fuori un campo error
di livello superiore da Redux.
Inizieremo aggiornando il nostro Redux store (in src/lib/store.js
) per connettersi a un'API remota e gestire i vari stati per la nostra applicazione (cioè, 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;
Ora che abbiamo aggiornato il nostro store per recuperare i dati da un endpoint API remoto e lo abbiamo preparato per gestire i vari stati della nostra app, creiamo il nostro InboxScreen.jsx
nella directory src/components
:
import React, { 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>
);
}
Dovremo anche modificare il nostro componente App
per renderizzare InboxScreen
. Alla fine, useremmo un router per scegliere la schermata corretta, ma non preoccupiamoci di questo ora.
- 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;
Tuttavia, le cose diventano interessanti quando si tratta di renderizzare la storia in Storybook.
Come abbiamo visto in precedenza, il componente TaskList
è ora un componente connesso e si basa su un Redux store per renderizzare i task. Poiché anche il nostro InboxScreen
è un componente connesso, faremo qualcosa di simile e forniremo uno store alla storia. Quindi, quando impostiamo le nostre storie in InboxScreen.stories.js
:
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 = {};
Possiamo rapidamente individuare un problema con la storia dell'errore
. Invece di mostrare il giusto stato, mostra una lista di task. Un modo per aggirare questo problema sarebbe fornire una versione simulata per ciascuno stato, simile a quanto abbiamo fatto nel capitolo precedente. Invece, useremo una nota libreria di mocking delle API insieme a un addon di Storybook per aiutarci a risolvere questo problema.
Simulazione dei Servizi API
Poiché la nostra applicazione è piuttosto semplice e non dipende troppo dalle chiamate API remote, useremo Mock Service Worker e l'addon MSW di Storybook. Mock Service Worker è una libreria di mocking delle API. Si basa sui service worker per catturare le richieste di rete e fornire dati simulati nelle risposte.
Quando abbiamo impostato la nostra app nella sezione Introduzione, entrambi i pacchetti sono stati anche installati. Tutto ciò che rimane è configurarli e aggiornare le nostre storie per utilizzarli.
Nel tuo terminale, esegui il seguente comando per generare un service worker generico all'interno della tua cartella public
:
yarn init-msw
Successivamente, dovremo aggiornare il nostro file .storybook/preview.js
e inizializzare le librerie:
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: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
+ loaders: [mswLoader],
};
export default preview;
Infine, aggiorna le storie di InboxScreen
e includi un parametro che simula le chiamate API remote:
import InboxScreen from './InboxScreen';
import store from '../lib/store';
+ import { rest } 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: [
+ rest.get(
+ 'https://jsonplaceholder.typicode.com/todos?userId=1',
+ (req, res, ctx) => {
+ return res(ctx.json(MockedState.tasks));
+ }
+ ),
+ ],
+ },
+ },
};
export const Error = {
+ parameters: {
+ msw: {
+ handlers: [
+ rest.get(
+ 'https://jsonplaceholder.typicode.com/todos?userId=1',
+ (req, res, ctx) => {
+ return res(ctx.status(403));
+ }
+ ),
+ ],
+ },
+ },
};
Controlla il tuo Storybook e vedrai che la storia error
ora funziona come previsto. MSW ha intercettato la nostra chiamata API remota e fornito la risposta appropriata.
Test sulle interazioni
Finora, siamo stati in grado di costruire un'applicazione completamente funzionale partendo da zero, iniziando da un semplice componente fino ad arrivare a una schermata, testando continuamente ogni modifica con le nostre storie. Ma ogni nuova storia richiede anche un controllo manuale su tutte le altre storie per assicurarsi che l'interfaccia utente non si rompa. È un sacco di lavoro extra.
Non possiamo automatizzare questo flusso di lavoro e testare automaticamente le interazioni dei nostri componenti?
Scrivi un test di interazione usando la funzione play
Gli strumenti di Storybook play
e @storybook/addon-interactions
ci aiutano in questo. Una funzione play include piccoli frammenti di codice che vengono eseguiti dopo il rendering della storia.
La funzione play ci aiuta a verificare cosa succede all'UI quando i task vengono aggiornati. Utilizza API DOM indipendenti dal framework, il che significa che possiamo scrivere storie con la funzione play per interagire con l'UI e simulare il comportamento umano indipendentemente dal framework frontend utilizzato.
L'@storybook/addon-interactions
ci aiuta a visualizzare i nostri test in Storybook, fornendo un flusso passo-passo. Offre anche un pratico set di controlli dell'interfaccia utente per mettere in pausa, riprendere, riavvolgere e passare attraverso ogni interazione.
Vediamolo in azione! Aggiorna la tua storia InboxScreen
appena creata e configura le interazioni del componente aggiungendo quanto segue:
import InboxScreen from './InboxScreen';
import store from '../lib/store';
import { rest } 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: [
rest.get(
'https://jsonplaceholder.typicode.com/todos?userId=1',
(req, res, ctx) => {
return res(ctx.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: [
rest.get(
'https://jsonplaceholder.typicode.com/todos?userId=1',
(req, res, ctx) => {
return res(ctx.status(403));
}
),
],
},
},
};
💡 Il pacchetto @storybook/test
sostituisce i pacchetti di test @storybook/jest
e @storybook/testing-library
offrendo una dimensione bundle più piccola e un'API più semplice basata sul pacchetto Vitest.
Controlla la storia Default
. Clicca sul pannello Interazioni
per vedere l'elenco delle interazioni all'interno della funzione play della storia.
Automatizzare i test con il test runner
Con la funzione play di Storybook, siamo riusciti a evitare il nostro problema, permettendoci di interagire con la nostra UI e di controllare rapidamente come reagisce se aggiorniamo i nostri task—mantenendo l'UI coerente senza sforzo manuale aggiuntivo.
Tuttavia, se osserviamo più da vicino il nostro Storybook, possiamo vedere che esegue i test di interazione solo quando visualizziamo la storia. Quindi, dovremmo comunque passare attraverso ogni storia per eseguire tutti i controlli se apportiamo una modifica. Non potremmo automatizzarlo?
La buona notizia è che possiamo! Il test runner di Storybook ci permette di fare proprio questo. È uno strumento autonomo—alimentato da Playwright—che esegue tutti i nostri test di interazione e individua le storie rotte.
Vediamo come funziona! Esegui il seguente comando per installarlo:
yarn add --dev @storybook/test-runner
Successivamente, aggiorna la sezione scripts
del tuo package.json
e aggiungi un nuovo task di test:
{
"scripts": {
"test-storybook": "test-storybook"
}
}
Infine, con il tuo Storybook in esecuzione, apri una nuova finestra del terminale ed esegui il seguente comando:
yarn test-storybook --watch
Per un'analisi ancora più approfondita sui test, dai un'occhiata al Manuale dei Test. Copre le strategie di test utilizzate dai team di frontend scalabili per potenziare il tuo flusso di lavoro di sviluppo.
Successo! Ora abbiamo uno strumento che ci aiuta a verificare se tutte le nostre storie vengono renderizzate senza errori e tutte le asserzioni passano automaticamente. Inoltre, se un test fallisce, fornirà un link che apre la storia fallita nel browser.
Sviluppo guidato dai componenti
Abbiamo iniziato dal basso con Task
, poi siamo passati a TaskList
, e ora siamo qui con un'intera interfaccia utente della schermata. Il nostro InboxScreen
ospita componenti connessi e include storie di accompagnamento.
Sviluppo guidato dai componenti ti permette di espandere gradualmente la complessità mentre sali nella gerarchia dei componenti. Tra i vantaggi ci sono un processo di sviluppo più focalizzato e una copertura maggiore di tutte le possibili permutazioni dell'UI. In breve, il CDD ti aiuta a costruire interfacce utente di qualità superiore e più complesse.
Non abbiamo ancora finito - il lavoro non termina quando l'UI è costruita. Dobbiamo anche assicurarci che rimanga solida nel tempo.