Testing Library 查询 API
系统整理 Testing Library 的 getBy/queryBy/findBy、AllBy、ByRole 与查询优先级,并说明 presence、absence、async 与 scoped query 的选择规则。
[!info] related notes
- 所属 MOC: testing-moc
- 前置概念:
- 并列概念:
- 易混淆概念:
- 关系笔记:
Testing Library 查询 API
核心模型
Testing Library 的 Queries 用来回答:
用户现在能在界面上找到什么?
查询 API 可以沿两条轴理解:
第一条轴:找到 1 个还是多个?
By → 单个
AllBy → 多个
第二条轴:现在找、允许没有、还是异步等?
get → 现在必须有
query → 允许没有
find → 异步等待出现
组合后就得到:
getBy... getAllBy...
queryBy... queryAllBy...
findBy... findAllBy...
getBy...:现在必须存在一个
const button = screen.getByRole("button", { name: "保存" });
行为:
- 找到恰好一个 → 返回 element
- 一个都没有 → 立即抛出带 DOM 信息的错误
- 找到多个 → 立即抛错,提示应使用更精确条件或
getAllBy...
适合:
expect(screen.getByText("疼痛")).toBeInTheDocument();
这里实际常常不需要 toBeInTheDocument() 才能证明“存在”,因为 getBy... 找不到已经会失败;但 matcher 会让测试意图更明确。
queryBy...:检查元素可能不存在
const button = screen.queryByRole("button", { name: "后来改善" });
行为:
- 找到一个 → 返回 element
- 一个都没有 → 返回
null - 找到多个 → 抛错
它最典型的用途是 absence assertion:
expect(
screen.queryByRole("button", { name: "后来改善" }),
).not.toBeInTheDocument();
为什么不能用 getBy... 检查不存在
错误写法:
expect(
screen.getByRole("button", { name: "后来改善" }),
).not.toBeInTheDocument();
如果元素不存在,getByRole() 会在 expect() 得到返回值之前就 throw,所以否定 matcher 根本来不及执行。
记忆:
要证明“没有”,用 query。
findBy...:异步等待元素出现
const message = await screen.findByText("保存成功");
findBy... 返回 Promise,并在一段时间内重复查询 DOM。
适合:
点击
↓
异步 mutation / state update
↓
稍后才出现 UI
例如:
await user.click(screen.getByRole("button", { name: "保存" }));
expect(await screen.findByText("已保存")).toBeInTheDocument();
如果超时仍未满足条件,则测试失败。
记忆:
现在应该有 → get;应该没有 → query;等一下应该有 → find。
getAllBy / queryAllBy / findAllBy
当业务上本来就应该有多个匹配元素时使用 All 版本。
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
语义:
| Query | 0 个 | 1+ 个 | Async |
|---|---|---|---|
getAllBy... | throw | array | 否 |
queryAllBy... | [] | array | 否 |
findAllBy... | 超时后 throw | Promise | 是 |
不要为了绕过“找到多个”的报错而机械换 getAllBy;如果界面逻辑只应该有一个按钮,找到两个恰恰可能暴露真实 bug。
查询条件:优先用户和可访问性语义
Testing Library 推荐优先使用接近用户定位界面的方式。
常见优先顺序:
ByRoleByLabelTextByPlaceholderTextByTextByDisplayValueByAltTextByTitleByTestId
核心不是死背顺序,而是:
优先选择用户真实能够感知的语义,而不是实现专用 selector。
ByRole:通常是第一选择
screen.getByRole("button", { name: "确认记录" });
role 来自 accessibility tree。
常见元素天然具有隐式 role:
<button>保存</button> → button
<a href="/x">详情</a> → link
<input type="checkbox" /> → checkbox
<h2>身体记录</h2> → heading
通常不要仅仅为了测试给标准 HTML 元素额外加重复 role。
name 是 accessible name
screen.getByRole("button", { name: "保存" });
name 不一定只是元素内部 text,它可能来自:
- 按钮文本
<label>aria-labelaria-labelledby- 图片 alt 等 accessibility 计算
因此 ByRole + name 同时测试了交互语义和可访问名称。
常用 ByRole filters
screen.getByRole("heading", { level: 2 });
screen.getByRole("checkbox", { checked: true });
screen.getByRole("tab", { selected: true });
screen.getByRole("button", { pressed: true });
screen.getByRole("treeitem", { expanded: true });
还有 current、busy、value、description 等过滤条件。
ByLabelText:表单控件非常自然
<label htmlFor="email">邮箱</label>
<input id="email" />
测试:
screen.getByLabelText("邮箱");
这比:
container.querySelector("#email")
更接近用户理解表单的方式,并顺便保护 label 与 input 的可访问关联。
ByPlaceholderText
screen.getByPlaceholderText("记录内容");
适合界面确实主要用 placeholder 提示用户的场景。
但如果有正式 label,优先 label;placeholder 通常不是最佳的可访问名称替代品。
ByText
screen.getByText("日常活动以久坐为主");
适合:
- paragraph
- status 文案
- 普通展示文本
对于 button、input、checkbox 等有明确语义的可交互元素,优先 ByRole。
ByDisplayValue
适合验证表单当前值:
screen.getByDisplayValue("右肩疼痛");
但如果你已经拿到 input element,通常更直接:
expect(input).toHaveValue("右肩疼痛");
ByAltText
主要用于:
<img alt="BodySense 身体模型" />
screen.getByAltText("BodySense 身体模型");
ByTitle
可以查 title 属性,但 title 往往不是用户最主要的交互语义,所以优先级较低。
ByTestId:最后手段
<div data-testid="body-state-row" />
screen.getByTestId("body-state-row");
它很稳定,但本质是专门为测试提供的实现标识,用户并不知道 body-state-row。
适合:
- 元素没有合理的 role/text/label
- 复杂 canvas / visualization 容器
- 语义查询无法稳定定位,但确实需要测试这个节点
不要因为写起来方便就把所有查询都变成 test id。
Exact string、Regex 与 matcher
screen.getByRole("button", { name: "保存" });
screen.getByRole("button", { name: /保存/ });
screen.getByRole("button", { name: /保存/i });
Exact string 更明确;Regex 适合:
- 文案中包含动态数字
- 大小写不重要
- 只关心稳定语义片段
不要用过宽的 /./、/确认/ 让多个完全不同元素都可能匹配。
screen 与 render 返回的 queries
两种都可以:
const { getByRole } = render(<Component />);
getByRole("button", { name: "保存" });
或:
render(<Component />);
screen.getByRole("button", { name: "保存" });
多数测试推荐 screen,因为:
- 少解构
- 查询方式统一
- 更像“从页面上找”
within():把查询限制在某一块 DOM
当页面有多个相同按钮时,先定位业务区域,再在区域内部查询:
const card = screen.getByRole("region", { name: "右肩" });
const save = within(card).getByRole("button", { name: "保存" });
这比使用:
第 2 个按钮
第 4 个 div
稳定得多。
Async queries 与 waitFor
首选 findBy:等待一个元素出现
expect(await screen.findByText("已保存")).toBeInTheDocument();
waitFor:等待任意断言最终成立
await waitFor(() => {
expect(api.save).toHaveBeenCalledOnce();
});
waitFor 会重复执行 callback,直到不再 throw 或超时。
因此 callback 里应该放 assertion,而不是把异步 side effect 本身反复执行:
// ❌ 每次 retry 都可能再次点击
await waitFor(async () => {
await user.click(button);
expect(...);
});
应先 Act,再 wait/assert。
waitForElementToBeRemoved
适合等待 loading / dialog 等元素消失:
await waitForElementToBeRemoved(() => screen.queryByText("加载中"));
Presence、Absence、Appearance、Disappearance 四种模式
Presence
现在应该有
→ getBy
Absence
现在应该没有
→ queryBy
Appearance
异步后应该出现
→ findBy
Disappearance
异步后应该消失
→ waitForElementToBeRemoved / waitFor + queryBy
这是比死背所有 API 更稳定的选择方式。
BodyState 示例
当前 fact 必须显示
expect(
screen.getByText("日常活动以久坐为主"),
).toBeInTheDocument();
Lifestyle 不允许 symptom action
expect(
screen.queryByRole("button", { name: "后来改善" }),
).not.toBeInTheDocument();
用户确认
await user.click(
screen.getByRole("button", { name: "确认记录" }),
);
等待异步 UI
expect(
await screen.findByText("记录已确认"),
).toBeInTheDocument();
只有最后一个场景真正需要 findBy。
常见错误
用 queryBy 做应该存在的断言
expect(screen.queryByText("疼痛")).toBeInTheDocument();
虽然能工作,但失败信息通常不如 getByText 清楚,而且掩盖了“现在必须存在”的语义。
用 getAllBy 隐藏重复元素 bug
如果业务要求只有一个“提交”按钮,就应该让 getByRole 在出现两个时失败。
过度依赖 CSS selector
container.querySelector(".mt-2 > button:nth-child(3)")
这是典型的实现细节耦合。
无条件使用 findBy
所有查询都 await findBy... 会让测试不必要地等待,也模糊同步/异步边界。
最短记忆方式
get = 必须有;query = 可以没有;find = 等它有。
再加一条:
先 Role,再考虑 Text;最后才 TestId。