
使用 Storybook 进行可访问性测试
通过集成工具快速反馈

美国有 26% 的成年人 至少患有一种残疾。改善可访问性会对您当前和未来的客户产生巨大影响。它也是法律要求。
检查可访问性的最准确方法是在真实设备上手动检查。但这需要专业知识和大量时间。而这些在前端团队中都很少。
Twilio、Adobe 和 Shopify 的团队结合使用了自动化和手动测试。自动化可以轻松发现常见的可访问性问题,而开发人员付出的努力很少。手动 QA 则留给需要人工关注的更棘手的问题。
有很多资源深入探讨可访问性原则,所以这里我们就不赘述了。本文将向您展示如何通过 Storybook 自动化可访问性测试。这是一种切实可行的方法,可以找到并修复您可能遇到的绝大多数问题。
为什么选择自动化?
在开始之前,让我们先了解一下常见的残疾类型:视觉、听觉、运动、认知、言语和神经系统。这些用户残疾会产生应用需求,例如
- ⌨ 键盘导航
- 🗣 屏幕阅读器支持
- 👆 触屏友好
- 🎨 足够的色彩对比度
- ⚡️ 减少动画
- 🔍 缩放
过去,您需要通过在各种浏览器、设备和屏幕阅读器上检查每个组件来验证所有这些需求。但由于应用程序有几十个组件并且 UI 经常更新,因此手动进行这项工作是不切实际的。
自动化加速您的工作流程
自动化工具会根据 WCAG 规则和其他行业公认的最佳实践,对照一组启发式规则来审计渲染后的 DOM。它们充当第一道 QA 防线,以发现明显的性能违规行为。
例如,Axe 平均可以 自动发现 57% 的 WCAG 问题。这使得团队能够将专家资源集中用于更复杂的、需要人工审查的问题。
许多团队使用 Axe 库,因为它与大多数现有测试环境集成。例如,Twilio Paste 团队使用 jest-axe 集成。而 Shopify Polaris 和 Adobe Spectrum 团队则使用 Storybook 插件 版本。
通过在整个开发过程中运行这些检查,您可以缩短反馈周期并更快地修复问题。工作流程如下所示:
- 👨🏽💻 开发过程中: 使用 Storybook 一次专注于一个组件。使用 A11y 插件模拟视觉缺陷,并在组件级别运行可访问性审计。
- ✅ QA: 将 Axe 审计集成到您的功能测试流程中。对所有组件运行检查以捕获回归。

教程
让我们看看这个工作流程是如何运作的。我们将使用我在 之前的文章 中介绍过的 Taskbox 应用。获取 代码 并跟随操作。

安装可访问性插件
Storybook 的可访问性插件会在活动故事上运行 Axe。它会在面板中可视化测试结果,并突出显示所有存在违规的 DOM 节点。

要安装该插件,请运行:yarn add -D @storybook/addon-a11y。然后,将 '@storybook/addon-a11y' 添加到您的 .storybook/main.js 文件中的插件数组中。
// .storybook/main.js
const path = require('path');
const toPath = (_path) => path.join(process.cwd(), _path);
module.exports = {
stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/preset-create-react-app',
'@storybook/addon-a11y',
],
webpackFinal: async (config) => {...},
};在编码时测试可访问性
在 之前的 博客 文章 中,我们学习了如何隔离一个组件并将其所有用例捕获为故事。例如,以下是 Task 组件的所有故事。在开发阶段,您可以循环浏览每个故事,以验证组件的外观并发现任何可访问性问题。
// src/components/Task.stories.js
import React from 'react';
import { Task } from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
};
const Template = (args) => <Task {...args} />;
export const Default = Template.bind({});
Default.args = {
task: {
id: '1',
title: 'Buy milk',
state: 'TASK_INBOX',
},
};
export const Pinned = Template.bind({});
Pinned.args = {
task: {
id: '2',
title: 'QA dropdown',
state: 'TASK_PINNED',
},
};
export const Archived = Template.bind({});
Archived.args = {
task: {
id: '3',
title: 'Write schema for account menu',
state: 'TASK_ARCHIVED',
},
};
const longTitleString = `This task's name is absurdly large. In fact, I think if I keep going I might end up with content overflow. What will happen? The star that represents a pinned task could have text overlapping. The text could cut-off abruptly when it reaches the star. I hope not!`;
export const LongTitle = Template.bind({});
LongTitle.args = {
task: {
id: '4',
title: longTitleString,
state: 'TASK_INBOX',
},
};
请注意,该插件发现了两个违规。第一个,“确保前景和背景颜色之间的对比度符合 WCAG 2 AA 对比度阈值”,特定于已存档状态。实际上,这意味着文本和背景之间的对比度不足。我们可以通过将文本颜色更改为稍微深一点的灰色来解决这个问题—从 gray.400 改为 gray.600。
// src/components/Task.js
import React from 'react';
import PropTypes from 'prop-types';
import {
Checkbox,
Flex,
IconButton,
Input,
Box,
VisuallyHidden,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
export const Task = ({
task: { id, title, state },
onArchiveTask,
onTogglePinTask,
onEditTitle,
...props
}) => (
// code omitted for brevity
<Box width="full" as="label">
<VisuallyHidden>Edit</VisuallyHidden>
<Input
variant="unstyled"
flex="1 1 auto"
color={state === 'TASK_ARCHIVED' ? 'gray.600' : 'gray.700'}
textDecoration={state === 'TASK_ARCHIVED' ? 'line-through' : 'none'}
fontSize="sm"
isTruncated
value={title}
onChange={(e) => onEditTitle(e.target.value, id)}
/>
</Box>
// code omitted for brevity
</Flex>
);
Task.propTypes = {
task: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
state: PropTypes.string.isRequired,
}),
onArchiveTask: PropTypes.func.isRequired,
onTogglePinTask: PropTypes.func.isRequired,
onEditTitle: PropTypes.func.isRequired,
};第二个违规,“确保 <li> 元素被语义化使用”,表明 DOM 结构不正确。Task 组件渲染了一个 <li> 元素。但是,它在它的故事中没有被 <ul> 包裹。这很合理。这些故事是为 Task 组件准备的。<ul> 实际上是由 TaskList 提供的。因此,DOM 结构会在 TaskList 的故事中得到验证。因此,忽略此错误是安全的。事实上,我们可以继续为所有 Task 故事禁用此规则。
// src/components/Task.stories.js
import React from 'react';
import { Task } from './Task';
export default {
component: Task,
title: 'Task',
argTypes: {
onArchiveTask: { action: 'onArchiveTask' },
onTogglePinTask: { action: 'onTogglePinTask' },
onEditTitle: { action: 'onEditTitle' },
},
parameters: {
a11y: {
config: {
rules: [{ id: 'listitem', enabled: false }],
},
},
},
};
// remaining code omitted for brevity您现在可以对所有其他组件重复此过程。
将可访问性测试集成到 Storybook 可以简化您的开发工作流程。在处理组件时,您不必在不同的工具之间切换。您所需的一切都在浏览器中。您甚至可以模拟视觉障碍,如氘色盲、红绿色盲或蓝绿色盲。

防止回归
组件是相互依赖的——一个组件中的更改可能会意外破坏其他组件。为了确保不会引入可访问性违规,我们需要在合并更改之前对所有组件运行 Axe。
故事是以基于 ES6 模块的格式编写的,允许您将其与其他测试框架一起重用。在上一篇文章中,我们研究了如何将故事导入 Jest 并使用 Testing Library 验证交互。以下是 InboxScreen 的测试文件。
// src/InboxScreen.test.js
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import {
render,
waitFor,
cleanup,
within,
fireEvent,
} from '@testing-library/react';
import { composeStories } from '@storybook/testing-react';
import { getWorker } from 'msw-storybook-addon';
import * as stories from './InboxScreen.stories';
describe('InboxScreen', () => {
afterEach(() => {
cleanup();
});
// Clean up after all tests are done, preventing this
// interception layer from affecting irrelevant tests
afterAll(() => getWorker().close());
const { Default } = composeStories(stories);
it('should pin a task', async () => {
const { queryByText, getByRole } = render(<Default />);
await waitFor(() => {
expect(queryByText('You have no tasks')).not.toBeInTheDocument();
});
const getTask = () => getByRole('listitem', { name: 'Export logo' });
const pinButton = within(getTask()).getByRole('button', { name: 'pin' });
fireEvent.click(pinButton);
const unpinButton = within(getTask()).getByRole('button', {
name: 'unpin',
});
expect(unpinButton).toBeInTheDocument();
});
// More interaction tests
it('should archive a task', async () => {...});
it('should edit a task', async () => {...});
});
同样,我们可以使用 Jest Axe 集成 来运行组件的可访问性测试。让我们先安装它:yarn add -D jest-axe
接下来,在 it 块中添加一个运行 Axe 并检查违规的代码。Jest-axe 还提供了一个方便的断言 toHaveNoViolations,可以通过一次函数调用来验证此项。
// src/InboxScreen.test.js
import React from 'react';
import '@testing-library/jest-dom/extend-expect';
import {
render,
waitFor,
cleanup,
within,
fireEvent,
} from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { composeStories } from '@storybook/testing-react';
import { getWorker } from 'msw-storybook-addon';
import * as stories from './InboxScreen.stories';
expect.extend(toHaveNoViolations);
describe('InboxScreen', () => {
afterEach(() => {
cleanup();
});
// Clean up after all tests are done, preventing this
// interception layer from affecting irrelevant tests
afterAll(() => getWorker().close());
const { Default } = composeStories(stories);
// Run axe
it('Should have no accessibility violations', async () => {
const { container, queryByText } = render(<Default />);
await waitFor(() => {
expect(queryByText('You have no tasks')).not.toBeInTheDocument();
});
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should pin a task', async () => {...});
it('should archive a task', async () => {...});
it('should edit a task', async () => {...});
});
运行 yarn test 来启动 Jest。它将执行所有交互测试并运行可访问性审计。您现在可以在修改代码时随时运行整个测试套件。从而能够捕获回归。

我们已经研究了如何将自动化可访问性测试分层到 UI 开发工作流程中。这并不能使您的应用程序完全可访问。您仍然需要使用 VoiceOver 或 NVDA 等辅助技术测试界面。然而,自动化确实可以节省您的时间并及早发现问题。
结论
Web 可访问性并不容易——在平衡可访问性与紧迫的截止日期、业务目标和技术债务方面,可能会让人不知所措。
像 Axe 和 Storybook 可访问性插件这样的工具可以集成到您现有的开发工作流程中,并提供快速的反馈循环。您可以在构建 UI 的同时找到和修复问题,从而节省时间。更重要的是,使界面可访问会为所有用户带来更好的体验!
将测试用例写成故事意味着我们可以将它们用于各种测试。故事文件成为组件所有功能的单一事实来源。接下来,我们将介绍另一个这样的例子——用户流程测试。我们将研究如何使用 Cypress 来验证跨多个组件执行的任务。订阅邮件列表,以获取 UI 测试文章发布的通知。
在构建 UI 的同时查找和修复问题 🤔
— Storybook (@storybookjs) 2021 年 8 月 4 日
构建可访问的 UI 需要大量的测试。通过结合手动和自动化测试,您可以节省时间并及早发现问题。
关于将 Storybook a11y 插件和 Axe 集成到您的开发工作流程的新教程:https://#/DL7xycwTdC pic.twitter.com/GpPbBQXRf0