Base UI Toast 测试:关闭按钮明明在 DOM 里,getByRole 却找不到它
给新组件库写单元测试时,最折磨人的 bug 不是「功能不对」,而是「测试里找不到它」。用 @base-ui-components/react 的 Toast 写关闭交互测试,getByRole("button", { name: "关闭" }) 报 Unable to find an accessible element——但 screen.debug() 一打,关闭按钮的 <button aria-label="关闭"> 明明就躺在 DOM 里。
现象
测试套件里依次触发了三个让人抓狂的报错:
getByRole("button", { name: "关闭" })→ Unable to find an accessible element with the role 'button'getByText("出错了")→ Found multiple elements with the text: 出错了- 前一条测试的 toast 渗入下一条,凭空报错
根因
三个现象各有独立根因,但第一个最坑,也最反直觉。
Root 1:Toast.Close 在 toast 未聚焦时携带 aria-hidden="true"
Base UI 的无障碍设计遵循 ARIA 规范:toast 没有被键盘聚焦时,关闭按钮对屏幕阅读器是隐藏的(aria-hidden="true")。这让 getByRole 彻底看不到它——RTL 的角色查询走无障碍树,aria-hidden 的元素不在树里。jsdom 里不存在真实焦点触发,所以在测试环境里这个按钮永远处于「未聚焦」状态,aria-hidden 永远为 true。
Root 2:aria-live 公告副本
Base UI 在 Viewport 之外还会渲染一个 aria-live 通知区域,把 toast 标题复制一份注入其中供屏幕阅读器朗读。结果标题文字在 DOM 里出现了两次,getByText 会报「找到多个元素」。
Root 3:全局单例 Manager
createToastManager() 返回的是单例,toast 在测试间不自动清空;加上 RTL 默认不调用 cleanup(),上一条测试遗留的 toast DOM 会污染下一条。
Toast.Close 在 toast 未聚焦时会被打上 aria-hidden="true"。jsdom 没有真实焦点事件,测试里 toast 始终「未聚焦」,所以 getByRole("button") 在任何情况下都会失败——不是按钮消失了,是它从无障碍树里被抹除了。
正解
1. 关闭按钮:绕开角色查询,改走 DOM 查询
先拿到可见标题元素,再从它出发向上找 Toast.Root(role="dialog"),最后在这个 Root 范围内用 querySelector 精准定位按钮:
// 找可见标题(styled 那个,不是 aria-live 副本)
const titleEl = screen.queryAllByText("操作成功")
.find(el => el.className.includes("text-lg")); // 按你实际的样式类名筛
// 从标题向上找 Toast Root,再在其内部定位关闭按钮
const root = titleEl!.closest('[role="dialog"]') as HTMLElement;
const closeBtn = root.querySelector('button[aria-label="关闭"]') as HTMLButtonElement;
fireEvent.click(closeBtn);
await waitFor(() => expect(
screen.queryAllByText("操作成功").find(el => el.className.includes("text-lg"))
).toBeFalsy());2. 标题查询:用 queryAllByText 加类名过滤
const titleEl = (text: string, cls: string) =>
screen.queryAllByText(text).find(el => el.className.includes(cls));用 queryAllByText 拿到所有匹配节点,再按样式类筛出可见的那个,彻底规避「找到多个元素」问题。
3. toast 污染:afterEach cleanup + 唯一标题
import { cleanup } from "@testing-library/react";
afterEach(cleanup);
// 每条测试给 toast 传唯一标题,利用 Provider 的 limit 机制自动驱逐旧 toast
toast("操作成功-test-01", { description: "..." });当组件库出于无障碍设计主动设置 aria-hidden 时,RTL 的角色/标签查询全部失效。正确做法是先用 queryAllByText 定位到可见的兄弟/父级节点,再用原生 querySelector 在限定范围内取按钮——范围锁定能防止选中 aria-live 副本区域里的同名元素。
附:可忽略的警告
测试环境里 Base UI 会发出 flushSync was called from inside a lifecycle method 警告。这是库自身在 jsdom 下的已知行为,不影响断言结果,直接忽略即可。自动关闭计时、多 toast 堆叠几何位置等交互建议留给 Playwright/CDP 的端到端测试覆盖,jsdom 不适合测这类时序和布局行为。
一句话外卖
组件库有意设置的
aria-hidden是规范行为,不是 bug——遇到getByRole找不到元素时,先检查目标是否被aria-hidden排出了无障碍树,再决定用 DOM 查询绕行。