DrillLab
第 12 / 21 节LESSON 12 / 21约 12 分钟~12 min

四个测试逐条读,以及它们的盲区The four tests read line by line, and what they fail to catch

判卷器长什么样,它查什么,它查不到什么。What the grader looks like, what it checks, and what it cannot check.

3 个练习3 exercisesReact · 第 3 部分React · Part 3
这一页有什么On this page7
学完这节你会After this lesson you can
  • 读懂 Testing Library 的三件套:render / screen / userEventRead the three main pieces of Testing Library: render, screen, and userEvent
  • 说清 getByTestId 和 getByRole 各在什么时候用Say when to use getByTestId and when to use getByRole
  • 知道为什么每个 userEvent 前面都有 awaitKnow why every userEvent call has await in front of it
  • 列出这四个测试的三个盲区,以及怎么自己补上List the three blind spots of these four tests, and how to cover them yourself
这在考试里考什么What the exam does with this

测试就是判卷器。看懂它 = 知道及格线在哪。而看懂它的盲区 = 知道题目要求里哪些是测试之外还得自己保证的。The tests are the grader. Reading them tells you where the pass line is. Reading their blind spots tells you which requirements you still have to guarantee yourself.

这节课要看的真实文件Real files this lesson looks at3 项 · 3 个可以展开看原文3 items · 3 can be opened
react-notes-app/src/NoteManager.test.tsx四个判卷测试The four tests that decide the marks
TSXNoteManager.test.tsx源项目From source
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import NoteManager from "./components/NoteManager";
4
5test("adds a note", async () => {
6 render(<NoteManager />);
7 await userEvent.type(screen.getByTestId("form-input"), "My Title");
8 await userEvent.type(screen.getByTestId("form-textarea"), "My Content");
9 await userEvent.click(screen.getByTestId("form-submit-button"));
10
11 expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
12});
13
14test("submit button disabled when inputs empty", () => {
15 render(<NoteManager />);
16 expect(screen.getByTestId("form-submit-button")).toBeDisabled();
17});
18
19test("deletes a note", async () => {
20 render(<NoteManager />);
21 await userEvent.type(screen.getByTestId("form-input"), "ToDelete");
22 await userEvent.type(screen.getByTestId("form-textarea"), "x");
23 await userEvent.click(screen.getByTestId("form-submit-button"));
24 await userEvent.click(screen.getByRole("button", { name: "Delete" }));
25
26 expect(screen.getByTestId("notes-list")).not.toHaveTextContent("ToDelete");
27});
28
29test("edits a note in place", async () => {
30 render(<NoteManager />);
31 await userEvent.type(screen.getByTestId("form-input"), "Old");
32 await userEvent.type(screen.getByTestId("form-textarea"), "c1");
33 await userEvent.click(screen.getByTestId("form-submit-button"));
34
35 await userEvent.click(screen.getByRole("button", { name: "Edit" }));
36 expect(screen.getByTestId("form-submit-button")).toHaveTextContent("Update");
37
38 const input = screen.getByTestId("form-input");
39 await userEvent.clear(input);
40 await userEvent.type(input, "New");
41 await userEvent.click(screen.getByTestId("form-submit-button"));
42
43 expect(screen.getByTestId("notes-list")).toHaveTextContent("New");
44 expect(screen.getByTestId("notes-list")).not.toHaveTextContent("Old");
45});
Source: react-notes-app/src/NoteManager.test.tsx
react-notes-app/vite.config.tsvitest 配置内联在这里The vitest config is inline here
TypeScriptvite.config.ts源项目From source
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3
4export default defineConfig({
5 plugins: [react()],
6 test: {
7 environment: "jsdom",
8 globals: true,
9 setupFiles: "./vitest.setup.ts",
10 },
11});
Source: react-notes-app/vite.config.ts
react-notes-app/vitest.setup.ts引入 jest-dom 断言Brings in the jest-dom assertions
TypeScriptvitest.setup.ts源项目From source
1import "@testing-library/jest-dom";
Source: react-notes-app/vitest.setup.ts
§01

测试环境是怎么搭起来的How the test setup is put together

三个文件,各管一段。Three files, each responsible for one part.

vite.config.ts —— vitest 的配置内联在 vite 配置里(不是单独的 vitest.config.ts)。 两个关键项:environment: "jsdom"(在 Node 里模拟一个浏览器 DOM,这样 React 才有东西可渲染)、globals: true(让 test /expect 变成全局变量,不用 import)。

vitest.setup.ts —— 只有一行 import "@testing-library/jest-dom"。 它给 expect 加了一批 DOM 专用断言:toBeDisabled()toHaveTextContent()没有它,那两个断言方法就不存在。

顺带回答上一门课那个问题:globals: truetest运行时存在,但 TypeScript 编译时并不知道 —— 这正是 npm run build 报 10 个Cannot find name 'test' 的原因。 要修需要在 tsconfig 里加"types": ["vitest/globals"], 但题目没让你改配置。

vite.config.ts — the vitest config is inlined into the vite config (there is no separate vitest.config.ts). Two entries matter: environment: "jsdom" (simulate a browser DOM inside Node, so React has something to render into) and globals: true (make test / expect global, no import needed).

vitest.setup.ts — a single line, import "@testing-library/jest-dom". It adds a batch of DOM-specific assertions to expect: toBeDisabled(), toHaveTextContent(). Without it, those two methods do not exist.

While we are here, the answer to that question from the previous module: globals: true makes test exist at runtime, but TypeScript does not know it at compile time — which is exactly why npm run build reports 10 counts of Cannot find name 'test'. Fixing it would mean adding "types": ["vitest/globals"] to tsconfig, and the brief never told you to touch the config.

TypeScriptvite.config.ts(全文)vite.config.ts (full file)源项目From source
1import { defineConfig } from "vite";
2import react from "@vitejs/plugin-react";
3
4export default defineConfig({
5 plugins: [react()],
6 test: {
7 environment: "jsdom",
8 globals: true,
9 setupFiles: "./vitest.setup.ts",
10 },
11});
Source: react-notes-app/vite.config.ts
§02

Testing Library 三件套The three main pieces of Testing Library

工具作用这个项目里怎么用
render把组件挂载到 jsdom 里render(<NoteManager />) —— 每个测试都从顶层组件开始
screen在渲染结果里查找元素getByTestId / getByRole
userEvent模拟真人操作type / click / clear

注意每个测试都 render 的是 NoteManager, 不是单独测 NoteForm这意味着任何一环断掉都会让测试挂 —— 受控输入没接好、props 名字对不上、handler 写错, 全都表现为「同一个测试失败」。 所以失败时要顺着整条链查,不能只盯一个文件。

ToolWhat it doesHow this project uses it
renderMounts the component into jsdomrender(<NoteManager />) — every test starts from the top-level component
screenFinds elements in the rendered outputgetByTestId / getByRole
userEventActs like a real persontype / click / clear

Notice that every test renders NoteManager, not NoteForm on its own. Which means a break anywhere along the chain fails the test — a controlled input wired wrong, a mismatched prop name, a broken handler all show up as “the same test failed”. So when one fails, trace the whole chain instead of staring at one file.

§03

getByTestId 和 getByRole:为什么两种都用getByTestId and getByRole: why both are used

getByTestId("form-input") —— 按 data-testid 属性找。 最稳,因为 testid 是专门为测试加的,不会因为文案改动而变。代价是它跟实现绑死了 —— 这也是 README 要求「不得修改任何 data-testid」的原因。

getByRole("button", { name: "Delete" }) —— 按「无障碍角色 + 可见名称」找,也就是 「一个按钮,上面写着 Delete」。 这更接近真人的找法(用户是靠看文字找按钮的)。代价是文案变了就找不到。

这个项目里,表单元素用 testid(它们没有可见文字), 行内的 Edit / Delete 按钮用 role + name(它们没有 testid)。两种方式各自绑定了不同的东西, 所以 testid 和按钮文字都不能改。

另外记一个特性:getBy* 系列找不到就抛错,找到多个也抛错。 后半条解释了为什么测试里每次只放一条数据 —— 两条数据就有两个 Delete 按钮,getByRole 会因为「找到多个」而失败。

getByTestId("form-input") — looks up the data-testid attribute. The steadiest option, because a testid exists purely for the tests and never changes when the wording does. The price is that it is welded to the implementation — which is why the README demands “do not modify any data-testid”.

getByRole("button", { name: "Delete" }) — looks up an accessibility role plus a visible name, i.e. “a button with Delete written on it”. Closer to how a real person searches (users find buttons by reading them). The price is that changed wording breaks it.

In this project the form elements use testids (they have no visible text), and the inline Edit / Delete buttons use role plus name (they have no testid). Each approach binds a different thing, which is why neither the testids nor the button text can change.

One more trait to keep in mind: the getBy* family throws when it finds nothing, and throws when it finds several. The second half explains why the tests only ever add one note — two notes mean two Delete buttons, and getByRole fails with “found multiple”.

§04

为什么每个 userEvent 都要 awaitWhy every userEvent call needs await

userEvent 的每个方法都返回 Promise。 因为它模拟的是真人操作type("My Title") 会一个字符一个字符地触发keydown / keypress /input / keyup, 每个字符之间还有微小的间隔。

更重要的是:React 的 state 更新和重新渲染是异步批处理的。await 保证「等这次操作引发的所有渲染都结束了」 再往下走。

漏了 await 会怎样?断言会在渲染完成之前执行,看到的是旧界面, 于是报「找不到 My Title」—— 但你的代码其实是对的。这是最容易误导人的一类测试失败。

Every userEvent method returns a Promise, because what it simulates is a real person: type("My Title") fires keydown / keypress / input / keyup one character at a time, with a tiny gap between characters.

More importantly: React batches state updates and re-renders asynchronously. await guarantees that every render this action triggered is finished before you move on.

What happens if you drop the await?The assertion runs before the render lands, sees the old screen, and reports “cannot find My Title” — even though your code is fine. This is the most misleading kind of test failure there is.

TSX示意Illustrative
1// ✗ 漏了 await:断言跑在渲染之前
2userEvent.click(screen.getByTestId("form-submit-button"));
3expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
4// → Unable to find text content "My Title" (代码其实没错!)
5
6// ✓
7await userEvent.click(screen.getByTestId("form-submit-button"));
8expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
1// ✗ The await is missing: the assertion runs before the render
2userEvent.click(screen.getByTestId("form-submit-button"));
3expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
4// → Unable to find text content "My Title" (the code is fine!)
5
6// ✓
7await userEvent.click(screen.getByTestId("form-submit-button"));
8expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
§05

三个盲区,以及怎么自己补Three blind spots, and how to cover them yourself

这一段是本节的重点。This section is the most important part of the lesson.

四个测试合起来覆盖了「能不能跑通」, 但漏掉了三条题目明确要求的东西:

题目要求为什么测不出自己怎么验
Task 2「按 id 移除」只有一条数据,按 title 甚至清空列表都能过加三条同名笔记,删中间那条
Task 3「原位置更新」只有一条数据,谈不上顺序加三条,编辑中间那条,看它还在不在第二行
Task 3「退出编辑模式提交后没检查表单和按钮状态更新完看按钮是否回到 Add、表单是否清空

所以正确的自测流程是两步

  1. npx vitest run —— 确认没有低级错误(及格线)。
  2. npm run dev + 上面三个手动场景 —— 确认真的满足题面(真正的正确性)。

考场上时间紧,很多人只做第 1 步。而这三条恰好都是 README 明确写了的 —— 出题人是故意的:他想区分「跑通了」和「读懂了」。

Together the four tests cover “does it run”, but they skip three things the brief explicitly asks for:

What the brief asksWhy the tests miss itHow to check it yourself
Task 2, “removed by idOne note only, so filtering by title or even wiping the list passesAdd three same-named notes, delete the middle one
Task 3, “update in placeOne note only, so there is no order to speak ofAdd three, edit the middle one, see whether it is still on row two
Task 3, “leave edit modeNothing checks the form or the button after the submitAfter an update, see whether the button is Add again and the form is empty

So a proper self-check is two steps:

  1. npx vitest run — confirm there are no basic mistakes (the pass mark).
  2. npm run dev plus the three manual scenarios above — confirm it really satisfies the brief (actual correctness).

Time is tight in the exam, so plenty of people stop after step 1. And all three of these are spelled out in the README — the author did it on purpose: he wants to separate “it runs” from “you read it”.

练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L1认出来Spot it哪个实现能骗过全部四个测试但其实是错的Which implementation passes all four tests and is still wrong

下面哪个 handleDelete 能让四个测试全部通过, 但明显违反题目要求?

Which handleDelete below makes all four tests pass while clearly breaking what the task asks for?

先选一个选项Pick an option first
L1认出来Spot it这个测试失败是因为什么Why this test fails

你写的 handleSubmitNote setNotes((prev) => [...prev, submittedNote]), 但自己加的测试报「找不到 My Title」。 测试代码是 userEvent.click(btn); expect(list).toHaveTextContent("My Title")。 最可能的原因?

Your handleSubmitNote is setNotes((prev) => [...prev, submittedNote]), but the test you added reports that My Title cannot be found. The test code is userEvent.click(btn); expect(list).toHaveTextContent("My Title"). What is the most likely reason?

先选一个选项Pick an option first
L3写整块Write a block自己补一个测试,覆盖「按 id 删除」这个盲区Write a test of your own to cover the delete-by-id blind spotDrillLab 自出Written by DrillLab

现有测试测不出「按 id 删除」。写一个新测试: 添加两条同名笔记,删掉其中一条, 断言另一条还在。
提示:两条数据时页面上有两个 Delete 按钮,getByRole 会因为「找到多个」而抛错 —— 得用 getAllByRole

The existing tests cannot check the delete by id. Write a new test: add two notes with the same title, delete one of them, and assert that the other is still there.
A note: with two notes there are two Delete buttons on the page, and getByRole throws because it found more than one. Use getAllByRole.

要求Requirements
  • 添加两条 title 完全相同、content 不同的笔记Add two notes with exactly the same title and different content
  • 用 getAllByRole 拿到 Delete 按钮数组,点第一个Use getAllByRole to get the array of Delete buttons, then click the first one
  • 断言 notes-list 不再含「内容A」Assert that notes-list no longer holds 内容A
  • 断言 notes-list 仍然含「内容B」Assert that notes-list still holds 内容B
  • 所有 userEvent 调用都要 awaitPut an await on every userEvent call
TSXsrc/NoteManager.test.tsx(自己加的测试)
This check is textual: it looks for the right constructs, it does not run your code
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.

迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

测试说找不到元素/文字,但代码看着没错The test cannot find an element or some text, but the code looks right
先数 awaitCount the await keywords first
getByRole 报「找到多个」getByRole reports that it found more than one
换 getAllByRole + 下标Switch to getAllByRole and an index
toBeDisabled is not a functiontoBeDisabled is not a function
缺 jest-dom 的 setupFilesThe setupFiles entry for jest-dom is missing
测试全过但心里没底Every test passes but you are still not sure
找测试的盲区,手动造场景补上Look for the blind spots and build those cases by hand
这节的要点What to take away
  1. vitest 配置内联在 vite.config.ts 里;jest-dom 的断言靠 vitest.setup.ts 引入。The Vitest config sits inside vite.config.ts, and the jest-dom assertions are loaded by vitest.setup.ts.
  2. 四个测试都 render 顶层 NoteManager,所以任何一环断掉都表现为同一个失败。All four tests render the top-level NoteManager, so a break anywhere in the chain shows up as the same failure.
  3. testid 用于无文字的表单元素,role + name 用于行内按钮 —— 两者都是契约。data-testid is for form elements with no text, and role plus name is for the row buttons. Both are a contract.
  4. userEvent 都要 await,否则断言跑在重新渲染之前。Every userEvent call needs await, or the assertion runs before the re-render.
  5. 三个盲区:按 id 删、原位置更新、退出编辑模式。都得手动验证。Three blind spots: deleting by id, updating in place, and leaving edit mode. Check all three by hand.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson读题:三条要求,每一条都在指定一种写法Reading the question: three requirements, and each one decides how you write it
    下一节Next lesson