构建屏幕
我们专注于自下而上构建 UI,从小处着手并增加复杂性。这样做使我们能够孤立地开发每个组件,弄清楚其数据需求,并在 Storybook 中进行实验。所有这些都无需启动服务器或构建屏幕!
在本章中,我们将继续提高复杂性,将组件组合到一个屏幕中,并在 Storybook 中开发该屏幕。
连接的屏幕
由于我们的应用程序很简单,因此我们将构建的屏幕非常简单,只需从远程 API 获取数据,包装 TaskList
组件(该组件从 Redux 提供自己的数据),并从 Redux 中拉出一个顶级的 error
字段。
我们将首先更新我们的 Redux 存储(在 src/lib/store.ts
中)以连接到远程 API 并处理我们应用程序的各种状态(即 error
、succeeded
)
/* A simple redux store/actions/reducer implementation.
* A true app would be more complex and separated into different files.
*/
import type { TaskData } from '../types';
import {
configureStore,
createSlice,
createAsyncThunk,
PayloadAction,
} from '@reduxjs/toolkit';
interface TaskBoxState {
tasks: TaskData[];
status: 'idle' | 'loading' | 'failed' | 'succeeded';
error: string | null;
}
/*
* 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: TaskBoxState = {
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://toolkit.redux.js.cn/api/createAsyncThunk
*/
export const fetchTasks = createAsyncThunk('taskbox/fetchTasks', async () => {
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos?userId=1'
);
const data = await response.json();
const result = data.map(
(task: { id: number; title: string; completed: boolean }) => ({
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://toolkit.redux.js.cn/api/createSlice
*/
const TasksSlice = createSlice({
name: 'taskbox',
initialState: TaskBoxData,
reducers: {
updateTaskState: (
state,
action: PayloadAction<{ id: string; newTaskState: TaskData['state'] }>
) => {
const task = state.tasks.find((task) => task.id === action.payload.id);
if (task) {
task.state = action.payload.newTaskState;
}
},
},
/*
* Extends the reducer for the async actions
* You can read more about it at https://toolkit.redux.js.cn/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://toolkit.redux.js.cn/api/configureStore
*/
const store = configureStore({
reducer: {
taskbox: TasksSlice.reducer,
},
});
// Define RootState and AppDispatch types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;
现在我们已经更新了我们的存储以从远程 API 端点检索数据,并准备好处理我们应用程序的各种状态,让我们在 src/components
目录中创建我们的 InboxScreen.tsx
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AppDispatch, fetchTasks, RootState } from '../lib/store';
import TaskList from "./TaskList";
export default function InboxScreen() {
const dispatch = useDispatch<AppDispatch>();
// We're retrieving the error field from our updated store
const { error } = useSelector((state: RootState) => 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>
);
}
我们还需要更改我们的 App
组件以渲染 InboxScreen
(最终,我们将使用路由器来选择正确的屏幕,但这里我们先不用担心这个)
- 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://vite.vuejs.ac.cn" target="_blank">
- <img src={viteLogo} className="logo" alt="Vite logo" />
- </a>
- <a href="https://reactjs.ac.cn" 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;
然而,有趣的是在 Storybook 中渲染 story。
正如我们之前看到的,TaskList
组件现在是一个 **连接的** 组件,并依赖于 Redux 存储来渲染任务。由于我们的 InboxScreen
也是一个连接的组件,我们将做类似的事情,并为 story 提供一个存储。因此,当我们在 InboxScreen.stories.tsx
中设置我们的 stories 时
import type { Meta, StoryObj } from '@storybook/react';
import InboxScreen from './InboxScreen';
import store from '../lib/store';
import { Provider } from 'react-redux';
const meta = {
component: InboxScreen,
title: 'InboxScreen',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
tags: ['autodocs'],
} satisfies Meta<typeof InboxScreen>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Error: Story = {};
我们可以很快发现 error
story 的问题。它没有显示正确的状态,而是显示了一个任务列表。解决此问题的一种方法是为每个状态提供一个模拟版本,类似于我们在上一章中所做的。相反,我们将使用一个著名的 API 模拟库以及一个 Storybook 插件来帮助我们解决这个问题。
模拟 API 服务
由于我们的应用程序非常简单,并且不太依赖于远程 API 调用,我们将使用 Mock Service Worker 和 Storybook 的 MSW 插件。Mock Service Worker 是一个 API 模拟库。它依赖于 service workers 来捕获网络请求并在响应中提供模拟数据。
当我们在 开始部分 中设置我们的应用程序时,这两个软件包也已安装。剩下的就是配置它们并更新我们的 stories 以使用它们。
在您的终端中,运行以下命令以在您的 public
文件夹中生成一个通用的 service worker
yarn init-msw
然后,我们需要更新我们的 .storybook/preview.ts
并初始化它们
import type { Preview } from '@storybook/react';
import { initialize, mswLoader } from 'msw-storybook-addon';
import '../src/index.css';
// Registers the msw addon
initialize();
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
loaders: [mswLoader],
};
export default preview;
最后,更新 InboxScreen
stories 并包含一个 参数,该参数模拟远程 API 调用
import type { Meta, StoryObj } from '@storybook/react';
import InboxScreen from './InboxScreen';
import store from '../lib/store';
+ import { http, HttpResponse } from 'msw';
+ import { MockedState } from './TaskList.stories';
import { Provider } from 'react-redux';
const meta = {
component: InboxScreen,
title: 'InboxScreen',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
tags: ['autodocs'],
} satisfies Meta<typeof InboxScreen>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+ return HttpResponse.json(MockedState.tasks);
+ }),
+ ],
+ },
+ },
};
export const Error: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+ return new HttpResponse(null, {
+ status: 403,
+ });
+ }),
+ ],
+ },
+ },
};
检查您的 Storybook,您会看到 error
story 现在可以按预期工作了。MSW 拦截了我们的远程 API 调用并提供了适当的响应。
组件测试
到目前为止,我们已经能够从头开始构建一个功能齐全的应用程序,从一个简单的组件到一个屏幕,并使用我们的 stories 持续测试每个更改。但是,每个新的 story 也需要手动检查所有其他 stories,以确保 UI 不会崩溃。这需要大量额外的工作。
我们是否可以自动化此工作流程并自动测试我们的组件交互?
使用 play function 编写组件测试
Storybook 的 play
和 @storybook/addon-interactions
可以帮助我们做到这一点。
play function 帮助我们验证在任务更新时 UI 会发生什么。它使用与框架无关的 DOM API,这意味着我们可以使用 play function 编写 stories 来与 UI 交互并模拟人类行为,而无需考虑前端框架。
@storybook/addon-interactions
帮助我们在 Storybook 中可视化我们的测试,提供一个逐步的流程。它还提供了一组方便的 UI 控件,用于暂停、恢复、倒带和单步执行每个交互。
让我们看看它的实际效果!更新您新创建的 InboxScreen
story,并通过添加以下内容来设置组件交互
import type { Meta, StoryObj } from '@storybook/react';
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';
const meta = {
component: InboxScreen,
title: 'InboxScreen',
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
tags: ['autodocs'],
} satisfies Meta<typeof InboxScreen>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
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: Story = {
parameters: {
msw: {
handlers: [
http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
return new HttpResponse(null, {
status: 403,
});
}),
],
},
},
};
💡 @storybook/test
软件包取代了 @storybook/jest
和 @storybook/testing-library
测试软件包,提供更小的捆绑包大小和基于 Vitest 软件包的更直接的 API。
检查 Default
story。单击 Interactions
面板以查看 story 的 play function 内的交互列表。
使用 test runner 自动化测试
借助 Storybook 的 play function,我们能够避开我们的问题,使我们能够与我们的 UI 交互并快速检查如果我们更新任务,它将如何响应——以零额外的手动工作量保持 UI 的一致性。
但是,如果我们仔细查看我们的 Storybook,我们可以看到它仅在查看 story 时运行交互测试。因此,如果我们进行更改,我们仍然必须遍历每个 story 以运行所有检查。我们难道不能自动化它吗?
好消息是我们可以!Storybook 的 test runner 允许我们做到这一点。它是一个独立的实用程序——由 Playwright 提供支持——它运行我们所有的交互测试并捕获损坏的 stories。
让我们看看它是如何工作的!运行以下命令来安装它
yarn add --dev @storybook/test-runner
接下来,更新您的 package.json
scripts
并添加一个新的测试任务
{
"scripts": {
"test-storybook": "test-storybook"
}
}
最后,在您的 Storybook 运行时,打开一个新的终端窗口并运行以下命令
yarn test-storybook --watch
💡 使用 play function 进行组件测试是测试您的 UI 组件的绝佳方法。它可以做的事情比我们在这里看到的要多得多;我们建议阅读 官方文档 以了解更多信息。
要更深入地了解测试,请查看 测试手册。它涵盖了大型前端团队用于增强您的开发工作流程的测试策略。
成功!现在我们有了一个工具,可以帮助我们验证是否所有 stories 都在没有错误的情况下渲染,并且所有断言都自动通过。更重要的是,如果测试失败,它将为我们提供一个链接,该链接会在浏览器中打开失败的 story。
组件驱动开发
我们从底部的 Task
开始,然后发展到 TaskList
,现在我们已经到达了整个屏幕 UI。我们的 InboxScreen
容纳了连接的组件,并包括了 accompanying stories。
组件驱动开发 允许您在组件层次结构中向上移动时逐步扩展复杂性。好处包括更集中的开发过程和增加所有可能的 UI 排列的覆盖率。简而言之,CDD 帮助您构建更高质量和更复杂的用户界面。
我们还没有完成 - 工作不会在 UI 构建完成时结束。我们还需要确保它随着时间的推移保持持久。