构建一个屏幕
我们专注于从底层构建 UI,从小处着手,逐步增加复杂性。这样做使我们能够单独开发每个组件,弄清楚它的数据需求,并在 Storybook 中进行测试。所有这些都不需要启动服务器或构建屏幕!
在本章中,我们将通过组合组件到一个屏幕中,并在 Storybook 中开发该屏幕,来继续提高复杂性。
已连接的屏幕
由于我们的应用程序很简单,所以我们要构建的屏幕也非常简单。它只是从远程 API 获取数据,将 TaskList 组件(它通过信号提供自己的数据)包装在一些布局中,并从商店中提取顶层的 error 字段(假设在连接服务器时出现问题时我们会设置该字段)。
我们将从更新我们的商店(在 src/app/state/store.ts 中)开始,以连接到远程 API 并处理应用程序的各种状态(例如,error、succeeded)。
// A simple Angular state management implementation using signals update methods and initial data.
// A true app would be more complex and separated into different files.
import type { TaskData } from '../types';
import { Injectable, signal, computed } from '@angular/core';
interface TaskBoxState {
tasks: TaskData[];
status: 'idle' | 'loading' | 'error' | 'success';
error: string | null;
}
const initialState: TaskBoxState = {
tasks: [],
status: 'idle',
error: null,
};
@Injectable({
providedIn: 'root',
})
export class Store {
private state = signal<TaskBoxState>(initialState);
// Public readonly signal for components to subscribe to
readonly tasks = computed(() => this.state().tasks);
readonly status = computed(() => this.state().status);
readonly error = computed(() => this.state().error);
readonly getFilteredTasks = computed(() => {
const filteredTasks = this.state().tasks.filter(
(t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
);
return filteredTasks;
});
archiveTask(id: string): void {
this.state.update((currentState) => {
const filteredTasks = currentState.tasks
.map(
(task): TaskData =>
task.id === id ? { ...task, state: 'TASK_ARCHIVED' as TaskData['state'] } : task
)
.filter((t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED');
return {
...currentState,
tasks: filteredTasks,
};
});
}
pinTask(id: string): void {
this.state.update((currentState) => ({
...currentState,
tasks: currentState.tasks.map((task) =>
task.id === id ? { ...task, state: 'TASK_PINNED' } : task
),
}));
}
async fetchTasks(): Promise<void> {
try {
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',
}))
.filter((task: TaskData) => task.state === 'TASK_INBOX' || task.state === 'TASK_PINNED');
this.state.update((currentState) => ({
...currentState,
tasks: result,
status: 'success',
error: null,
}));
} catch (error) {
this.state.update((currentState) => ({
...currentState,
error: error instanceof Error ? error.message : 'Failed to fetch tasks',
}));
}
}
}
现在我们已经更新了商店以从远程 API 端点检索数据,并准备好处理我们应用程序的各种状态,让我们在 src/app/components 目录中创建 inbox-screen.component.ts。
import { CommonModule } from '@angular/common';
import { Component, inject, OnInit, computed } from '@angular/core';
import { Store } from '../state/store';
import { TaskListComponent } from './task-list.component';
@Component({
selector: 'app-inbox-screen',
standalone: true,
imports: [CommonModule, TaskListComponent],
template: `
<div *ngIf="isError()" class="page lists-show">
<div class="wrapper-message">
<span class="icon-face-sad"></span>
<p class="title-message">Oh no!</p>
<p class="subtitle-message">Something went wrong</p>
</div>
</div>
<div *ngIf="!isError()" class="page lists-show">
<nav>
<h1 class="title-page">Taskbox</h1>
</nav>
<app-task-list></app-task-list>
</div>
`,
})
export class InboxScreenComponent implements OnInit {
store = inject(Store);
isError = computed(() => this.store.status() === 'error');
ngOnInit(): void {
this.store.fetchTasks();
}
}
我们还需要更改 app.ts 组件以渲染 InboxScreen 组件(最终,我们会使用路由来选择正确的屏幕,但暂时不用担心这个问题)。
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
+ import { InboxScreenComponent } from './components/inbox-screen.component';
@Component({
selector: 'app-root',
- imports: [RouterOutlet],
- templateUrl: './app.html',
- styleUrl: './app.css'
+ imports: [RouterOutlet, InboxScreenComponent],
+ template: `<app-inbox-screen></app-inbox-screen>`,
})
export class App {
protected readonly title = signal('taskbox');
}
然而,事情变得有趣的是在 Storybook 中渲染组件。
正如我们之前看到的,TaskList 组件是一个 **容器**,它渲染 PureTaskList 表示组件。根据定义,容器组件不能孤立渲染;它们期望被传入一些上下文或连接到服务。这意味着要在 Storybook 中渲染一个容器,我们必须模拟它所需的上下文或服务。
当我们将 TaskList 放入 Storybook 时,我们通过仅渲染 PureTaskList 并避免容器来规避了这个问题。然而,随着应用程序的增长,将连接的组件排除在 Storybook 之外并为每个组件创建表示组件很快就会变得难以管理。由于我们的 InboxScreen 是一个连接的组件,我们将需要提供一种方法来模拟它提供的存储和数据。
因此,当我们设置 inbox-screen.stories.ts 中的故事时:
import type { Meta, StoryObj } from '@storybook/angular';
import { InboxScreenComponent } from './inbox-screen.component';
const meta: Meta<InboxScreenComponent> = {
component: InboxScreenComponent,
title: 'InboxScreen',
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<InboxScreenComponent>;
export const Default: Story = {}
export const Error: Story = {}
我们可以很快发现 Error 故事存在一个问题。它显示了一个任务列表,而不是显示正确的状态。我们可以轻松地应用与上一章相同的方法。取而代之的是,我们将使用一个知名的 API 模拟库以及一个 Storybook 插件来帮助我们解决这个问题。

模拟 API 服务
由于我们的应用程序相当简单,并且不怎么依赖远程 API 调用,我们将使用 Mock Service Worker 和 Storybook 的 MSW 插件。Mock Service Worker 是一个 API 模拟库。它依赖于 service worker 来捕获网络请求并在响应中提供模拟数据。
在 入门部分 设置应用程序时,这两个包也被安装了。剩下要做的就是配置它们并更新我们的故事以使用它们。
在终端中,运行以下命令在 `public` 文件夹中生成一个通用的 service worker。
npm run init-msw
然后,我们需要更新我们的 `.storybook/preview.ts` 并进行初始化。
import type { Preview } from '@storybook/angular';
import { setCompodocJson } from '@storybook/addon-docs/angular';
import docJson from '../documentation.json';
+ import { initialize, mswLoader } from 'msw-storybook-addon';
setCompodocJson(docJson);
+ initialize();
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
+ loaders: [mswLoader],
};
export default preview;
最后,更新 `InboxScreen` stories 并包含一个 参数 来模拟远程 API 调用。
import type { Meta, StoryObj } from '@storybook/angular';
+ import { http, HttpResponse } from 'msw';
import { InboxScreenComponent } from './inbox-screen.component';
+ import * as PureTaskListStories from './pure-task-list.stories';
const meta: Meta<InboxScreenComponent> = {
component: InboxScreenComponent,
title: 'InboxScreen',
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<InboxScreenComponent>;
export const Default: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+ return HttpResponse.json(PureTaskListStories.TaskListData);
+ }),
+ ],
+ },
+ },
};
export const Error: Story = {
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
+ return new HttpResponse(null, {
+ status: 403,
+ });
+ }),
+ ],
+ },
+ },
};
检查您的 Storybook,您会发现 Error 故事现在按预期工作。MSW 拦截了我们的远程 API 调用并提供了相应的响应。
交互测试
到目前为止,我们已经能够从头开始构建一个功能齐全的应用程序,从一个简单的组件到一个屏幕,并持续通过我们的 stories 测试每个更改。但是,每个新的 story 也需要手动检查所有其他 stories,以确保 UI 不会中断。这需要大量额外的工作。
我们不能自动化这个工作流程并自动测试组件交互吗?
使用 play 函数编写交互测试
Storybook 的 play 函数可以帮助我们。play 函数包含一些在 story 渲染后运行的小代码片段。它使用框架无关的 DOM API,这意味着我们可以使用 play 函数编写 story 来与 UI 进行交互,并模拟人类行为,无论前端框架如何。我们将使用它们来验证当我们更新任务时 UI 是否按预期运行。
更新你新创建的 `InboxScreen` story,并通过添加以下内容来设置组件交互:
import type { Meta, StoryObj } from '@storybook/angular';
import { waitFor, waitForElementToBeRemoved } from 'storybook/test';
import { http, HttpResponse } from 'msw';
import { InboxScreenComponent } from './inbox-screen.component';
import * as PureTaskListStories from './pure-task-list.stories';
const meta: Meta<InboxScreenComponent> = {
component: InboxScreenComponent,
title: 'InboxScreen',
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<InboxScreenComponent>;
export const Default: Story = {
parameters: {
msw: {
handlers: [
http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
return HttpResponse.json(PureTaskListStories.TaskListData);
}),
],
},
},
+ play: async ({ canvas, userEvent }: any) => {
+ await waitForElementToBeRemoved(await canvas.findByTestId('empty'));
+ await waitFor(async () => {
+ await userEvent.click(canvas.getByLabelText('pinTask-1'));
+ await userEvent.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,
});
}),
],
},
},
};
💡 `Interactions` 面板帮助我们可视化 Storybook 中的测试,提供一个逐步流程。它还提供了一组方便的 UI 控件来暂停、恢复、倒带和逐步执行每个交互。
检查您新创建的故事。点击 Interactions 面板,查看故事的 play 函数中的交互列表。
使用测试运行器自动化测试
通过 Storybook 的 play 函数,我们能够规避问题,让我们能够与 UI 进行交互,并快速检查更新任务时的 UI 响应情况——无需额外的手动工作即可保持 UI 的一致性。
但是,如果我们仔细查看 Storybook,我们可以看到它仅在查看故事时运行组件测试。因此,如果我们进行更改,我们仍然需要逐个故事来运行所有检查。难道我们不能自动化它吗?
好消息是我们可以!Storybook 的 测试运行器 使我们能够做到这一点。它是一个独立的实用程序,由 Playwright 提供支持,它可以运行我们所有的交互测试并捕获损坏的故事。
让我们看看它是如何工作的!运行以下命令进行安装:
npm install @storybook/test-runner --save-dev
接下来,更新您的 package.json scripts 并添加一个新的测试任务:
{
"scripts": {
"test-storybook": "test-storybook"
}
}
最后,在 Storybook 运行时,打开一个新的终端窗口并运行以下命令:
npm run test-storybook -- --url https://:6006/ -- --watch
💡 使用 play 函数进行交互测试是测试 UI 组件的绝佳方式。它可以做很多我们在这里看到的,我们建议阅读 官方文档 以了解更多信息。
要更深入地了解测试,请参阅 测试手册。它涵盖了大型前端团队使用的测试策略,以加速您的开发工作流程。

成功!现在我们有了一个工具,可以帮助我们验证所有故事是否都成功渲染并且所有断言都自动通过。更重要的是,如果测试失败,它将提供一个链接,该链接会将失败的故事打开到浏览器中。
组件驱动开发
我们从 Task 开始,然后进展到 TaskList,现在我们有了整个屏幕 UI。我们的 InboxScreen 容纳了一个嵌套的容器组件,并带有配套的故事。
组件驱动开发 允许你在向上移动组件层级时逐步增加复杂性。其优点包括更集中的开发流程和对所有可能的 UI 排列的更高覆盖率。总之,CDD 有助于构建更高质量、更复杂的 UI。
我们还没有完成——工作并不会在 UI 构建完成后就结束。我们还需要确保它在长时间内保持稳定。