返回博客

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 里。

现象

测试套件里依次触发了三个让人抓狂的报错:

  1. getByRole("button", { name: "关闭" })Unable to find an accessible element with the role 'button'
  2. getByText("出错了")Found multiple elements with the text: 出错了
  3. 前一条测试的 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 会污染下一条。

坑:aria-hidden 的元素不在无障碍树,getByRole 永远找不到

Toast.Close 在 toast 未聚焦时会被打上 aria-hidden="true"。jsdom 没有真实焦点事件,测试里 toast 始终「未聚焦」,所以 getByRole("button") 在任何情况下都会失败——不是按钮消失了,是它从无障碍树里被抹除了。

正解

1. 关闭按钮:绕开角色查询,改走 DOM 查询

先拿到可见标题元素,再从它出发向上找 Toast.Rootrole="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 元素走 DOM 查询,不走 getByRole

当组件库出于无障碍设计主动设置 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 查询绕行。