DrillLab
第 01 / 08 节LESSON 01 / 08约 14 分钟~14 min

先读四个测试:它们到底要什么Read the four tests first: what exactly they ask for

四个测试全靠 data-testid 找元素。先抄一张 testid 表出来,再动手。All four tests find elements by data-testid. Copy out the list of testids first, then start writing.

2 个练习2 exercisesCab Booking · 第 1 部分Cab Booking · Part 1
这一页有什么On this page5
学完这节你会After this lesson you can
  • 读出四个测试各自查的是什么Say what each of the four tests checks
  • 抄出全部 13 个 data-testid,知道改名会红哪一片Copy out all 13 data-testid values, and know which tests fail if you rename one
  • 说清为什么测试 4 是这道题真正的分水岭Explain why test 4 is the real dividing line in this task
  • 知道 `vi.useFakeTimers()` 让 Loading 的 1 秒变成可控的Know that `vi.useFakeTimers()` puts the 1 second in Loading under your control
这在考试里考什么What the exam does with this

这道题的判分完全由 data-testid 驱动 —— 页面长得对但 testid 错一个,那一片全红。先读测试再写代码,能省掉一半的返工。测试 4「只留最新三条」是分水岭:slice 方向写反、忘了 reverse、或者原地改了 state,都会挂在这一条。Scoring here is driven entirely by data-testid. The page can look right, and one wrong testid still fails every check around it. Reading the tests before writing code saves half of the rework. Test 4, which keeps only the three newest rides, is the dividing line: a slice in the wrong direction, a missing reverse, or changing the state array in place all fail on that one test.

这节课要看的真实文件Real files this lesson looks at2 项 · 2 个可以展开看原文2 items · 2 can be opened
cab-booking-context/src/test/App.test.jsx四个测试,判分的全部依据Four tests, and the whole basis for the marks
JSXApp.test.jsx源项目From source
1import { act, fireEvent, render, screen } from "@testing-library/react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import App from "../App";
4import { CabProvider } from "../context/CabContext";
5
6const renderApp = () =>
7 render(
8 <CabProvider>
9 <App />
10 </CabProvider>,
11 );
12
13describe("React: Cab Booking", () => {
14 beforeEach(() => {
15 vi.useFakeTimers();
16 });
17
18 afterEach(() => {
19 vi.runOnlyPendingTimers();
20 vi.useRealTimers();
21 });
22
23 it("renders the home page and empty ride history", () => {
24 renderApp();
25
26 expect(
27 screen.getByText("Book a Safe Ride with HackerRide"),
28 ).toBeInTheDocument();
29 expect(screen.getByTestId("book-button")).toBeInTheDocument();
30 expect(screen.getByTestId("no-ride-title")).toHaveTextContent(
31 "No ride history yet.",
32 );
33 });
34
35 it("shows grouped cab options with all required card fields", () => {
36 renderApp();
37
38 fireEvent.click(screen.getByTestId("book-button"));
39
40 expect(screen.getByTestId("all-cabs-section")).toBeInTheDocument();
41 expect(screen.getAllByTestId("car-type-heading").map((node) => node.textContent))
42 .toEqual(["Sedan", "SUV", "Luxury"]);
43 expect(screen.getAllByTestId("cab-card-img")).toHaveLength(6);
44 expect(screen.getAllByTestId("cab-card-name")).toHaveLength(6);
45 expect(screen.getAllByTestId("cab-card-type")).toHaveLength(6);
46 expect(screen.getAllByTestId("cab-card-price")).toHaveLength(6);
47 expect(screen.getAllByTestId("cab-card-select-button")).toHaveLength(6);
48 });
49
50 it("completes a booking and adds it to ride history", () => {
51 renderApp();
52
53 fireEvent.click(screen.getByTestId("book-button"));
54 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[0]);
55
56 expect(screen.getByTestId("loading")).toBeInTheDocument();
57
58 act(() => {
59 vi.advanceTimersByTime(1000);
60 });
61
62 expect(screen.getByTestId("confirm-message")).toHaveTextContent(
63 "Ford Fusion is on the way and will arrive shortly.",
64 );
65
66 fireEvent.click(screen.getByTestId("confirm-button"));
67
68 expect(screen.getByTestId("history-cabs")).toHaveTextContent(
69 "Ford Fusion",
70 );
71 expect(screen.getByTestId("history-cabs")).toHaveTextContent("$20");
72 });
73
74 it("keeps only the newest three rides", () => {
75 renderApp();
76
77 const selectCabByIndex = (index) => {
78 fireEvent.click(screen.getByTestId("book-button"));
79 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[index]);
80 act(() => {
81 vi.advanceTimersByTime(1000);
82 });
83 fireEvent.click(screen.getByTestId("confirm-button"));
84 };
85
86 selectCabByIndex(0);
87 selectCabByIndex(1);
88 selectCabByIndex(2);
89 selectCabByIndex(3);
90
91 const rides = screen.getAllByTestId("history-cabs");
92 expect(rides).toHaveLength(3);
93 expect(rides[0]).toHaveTextContent("Ford Explorer");
94 expect(rides[1]).toHaveTextContent("Toyota Highlander");
95 expect(rides[2]).toHaveTextContent("Honda Accord");
96 expect(screen.queryByText(/Ford Fusion/)).not.toBeInTheDocument();
97 });
98});
Source: cab-booking-context/src/test/App.test.jsx
cab-booking-context/src/data/data.json三组六辆车,分组顺序来自这里的键顺序Six cars in three groups; the group order comes from the key order here
JSONdata.json源项目From source
1{
2 "Sedan": [
3 {
4 "id": "sedan-1",
5 "name": "Ford Fusion",
6 "type": "Sedan",
7 "price": 20,
8 "image": "/cabs/ford-fusion.svg"
9 },
10 {
11 "id": "sedan-2",
12 "name": "Honda Accord",
13 "type": "Sedan",
14 "price": 24,
15 "image": "/cabs/honda-accord.svg"
16 }
17 ],
18 "SUV": [
19 {
20 "id": "suv-1",
21 "name": "Toyota Highlander",
22 "type": "SUV",
23 "price": 32,
24 "image": "/cabs/toyota-highlander.svg"
25 },
26 {
27 "id": "suv-2",
28 "name": "Ford Explorer",
29 "type": "SUV",
30 "price": 36,
31 "image": "/cabs/ford-explorer.svg"
32 }
33 ],
34 "Luxury": [
35 {
36 "id": "luxury-1",
37 "name": "Mercedes E-Class",
38 "type": "Luxury",
39 "price": 55,
40 "image": "/cabs/mercedes-e-class.svg"
41 },
42 {
43 "id": "luxury-2",
44 "name": "BMW 5 Series",
45 "type": "Luxury",
46 "price": 60,
47 "image": "/cabs/bmw-5-series.svg"
48 }
49 ]
50}
Source: cab-booking-context/src/data/data.json
§01

四个测试各查什么What each of the four tests checks

读完这张表,你就知道要写哪些东西Read this table and you know everything you have to build

一句话:四个测试从「首页长对了吗」一路走到 「连订四辆车之后历史对吗」,刚好是一次完整的用户流程

#测试名查什么
1renders the home page and empty ride history首页标题 + book-button + no-ride-title 的文字是No ride history yet.
2shows grouped cab options with all required card fieldsall-cabs-section 存在;三个 car-type-heading 的文字严格等于 ["Sedan", "SUV", "Luxury"];五种卡片字段各 6 个
3completes a booking and adds it to ride history选第一辆 → 出现 loading → 时间推进 1 秒 →confirm-messageFord Fusion is on the way and will arrive shortly.→ 点确认 → 历史里有 Ford Fusion $20
4keeps only the newest three rides连订 4 辆,历史只剩 3 条,顺序是 Ford Explorer / Toyota Highlander / Honda Accord, 而且 Ford Fusion必须已经不在 DOM 里

测试 2 那个 toEqual 要特别注意:它比的是一个有序数组,不是「包含这三个」。 分组顺序错了就红。而顺序不是你决定的 —— 它来自 data.json 的键顺序, 你只要老实地 Object.keys(cabData).map(...) 就对了。

测试 4 是分水岭。它同时查三件事:只剩三条(数量)、最新在最上(顺序)、最旧那条真的不在 DOM 里(不是藏起来了)。 后面有一整节专门讲它。

In one line: the four tests walk from “does the home page look right” to “is the history correct after four bookings” — exactly one full user journey.

#TestWhat it checks
1renders the home page and empty ride historyThe hero heading, book-button, and no-ride-title reading No ride history yet.
2shows grouped cab options with all required card fieldsall-cabs-section exists; the three car-type-heading texts are strictly equal to ["Sedan", "SUV", "Luxury"]; five card fields, 6 of each
3completes a booking and adds it to ride historyPick the first cab → loading appears → advance time by 1s → confirm-message reads Ford Fusion is on the way and will arrive shortly. → confirm → history holds Ford Fusion and $20
4keeps only the newest three ridesBook 4 cabs; history has exactly 3 entries, ordered Ford Explorer / Toyota Highlander / Honda Accord, and Ford Fusion must be gone from the DOM

Watch that toEqual in test 2: it compares an ordered array, not “contains these three”. Wrong group order fails. And the order is not yours to choose — it comes from the key order in data.json, so plainly mapping Object.keys(cabData) gets it right.

Test 4 is where people fall. It checks three things at once: only three remain (count), newest on top (order), and the oldest is really out of the DOM (not merely hidden). A whole lesson below is about it.

JSXsrc/test/App.test.jsx(判分的全部依据)src/test/App.test.jsx (the whole basis of the grade)源项目From source
1import { act, fireEvent, render, screen } from "@testing-library/react";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3import App from "../App";
4import { CabProvider } from "../context/CabContext";
5
6const renderApp = () =>
7 render(
8 <CabProvider>
9 <App />
10 </CabProvider>,
11 );
12
13describe("React: Cab Booking", () => {
14 beforeEach(() => {
15 vi.useFakeTimers();
16 });
17
18 afterEach(() => {
19 vi.runOnlyPendingTimers();
20 vi.useRealTimers();
21 });
22
23 it("renders the home page and empty ride history", () => {
24 renderApp();
25
26 expect(
27 screen.getByText("Book a Safe Ride with HackerRide"),
28 ).toBeInTheDocument();
29 expect(screen.getByTestId("book-button")).toBeInTheDocument();
30 expect(screen.getByTestId("no-ride-title")).toHaveTextContent(
31 "No ride history yet.",
32 );
33 });
34
35 it("shows grouped cab options with all required card fields", () => {
36 renderApp();
37
38 fireEvent.click(screen.getByTestId("book-button"));
39
40 expect(screen.getByTestId("all-cabs-section")).toBeInTheDocument();
41 expect(screen.getAllByTestId("car-type-heading").map((node) => node.textContent))
42 .toEqual(["Sedan", "SUV", "Luxury"]);
43 expect(screen.getAllByTestId("cab-card-img")).toHaveLength(6);
44 expect(screen.getAllByTestId("cab-card-name")).toHaveLength(6);
45 expect(screen.getAllByTestId("cab-card-type")).toHaveLength(6);
46 expect(screen.getAllByTestId("cab-card-price")).toHaveLength(6);
47 expect(screen.getAllByTestId("cab-card-select-button")).toHaveLength(6);
48 });
49
50 it("completes a booking and adds it to ride history", () => {
51 renderApp();
52
53 fireEvent.click(screen.getByTestId("book-button"));
54 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[0]);
55
56 expect(screen.getByTestId("loading")).toBeInTheDocument();
57
58 act(() => {
59 vi.advanceTimersByTime(1000);
60 });
61
62 expect(screen.getByTestId("confirm-message")).toHaveTextContent(
63 "Ford Fusion is on the way and will arrive shortly.",
64 );
65
66 fireEvent.click(screen.getByTestId("confirm-button"));
67
68 expect(screen.getByTestId("history-cabs")).toHaveTextContent(
69 "Ford Fusion",
70 );
71 expect(screen.getByTestId("history-cabs")).toHaveTextContent("$20");
72 });
73
74 it("keeps only the newest three rides", () => {
75 renderApp();
76
77 const selectCabByIndex = (index) => {
78 fireEvent.click(screen.getByTestId("book-button"));
79 fireEvent.click(screen.getAllByTestId("cab-card-select-button")[index]);
80 act(() => {
81 vi.advanceTimersByTime(1000);
82 });
83 fireEvent.click(screen.getByTestId("confirm-button"));
84 };
85
86 selectCabByIndex(0);
87 selectCabByIndex(1);
88 selectCabByIndex(2);
89 selectCabByIndex(3);
90
91 const rides = screen.getAllByTestId("history-cabs");
92 expect(rides).toHaveLength(3);
93 expect(rides[0]).toHaveTextContent("Ford Explorer");
94 expect(rides[1]).toHaveTextContent("Toyota Highlander");
95 expect(rides[2]).toHaveTextContent("Honda Accord");
96 expect(screen.queryByText(/Ford Fusion/)).not.toBeInTheDocument();
97 });
98});
Source: cab-booking-context/src/test/App.test.jsx
§02

13 个 data-testid 就是契约The 13 data-testid values are the contract

改一个名字,红一片。所以先抄表Rename one and a whole group of tests fails. So copy the list out first

一句话:这道题的判分不看你的 class 名、 不看 DOM 结构、也不看样式,只看 13 个 data-testid 和它们里面的文字

testid在哪个组件几个
book-buttonHome1
no-ride-titleRideHistory(空的时候)0 或 1
history-cabsRideHistory(每条一个)0–3
all-cabs-sectionCabOptions1
car-type-headingCabOptions(每组一个)3
cab-card-img / -name / -type / -price / -select-buttonCabCard(每张卡五个)各 6
loadingLoading1
confirm-message / confirm-buttonCabConfirmation各 1

两个容易忽略的细节:

  • no-ride-titlehistory-cabs是互斥的 —— 空的时候只有前者, 有记录的时候只有后者。所以 RideHistory 里必须是一个三元表达式, 不能两个都渲染。
  • 测试 4 最后一行是 queryByText(/Ford Fusion/)不能存在。注意它用的是 queryBy 而不是 getBy ——getBy 找不到会抛错,queryBy 找不到返回 null, 所以断言「不存在」时只能用 queryBy。 这一点面试也会问。

In one line: the grader ignores your class names, your DOM shape and your styling. It looks at 13 data-testid hooks and the text inside them.

testidComponentHow many
book-buttonHome1
no-ride-titleRideHistory (when empty)0 or 1
history-cabsRideHistory (one per row)0–3
all-cabs-sectionCabOptions1
car-type-headingCabOptions (one per group)3
cab-card-img / -name / -type / -price / -select-buttonCabCard (five per card)6 each
loadingLoading1
confirm-message / confirm-buttonCabConfirmation1 each

Two details people miss:

  • no-ride-title and history-cabs are mutually exclusive — only the first when empty, only the second once there are rides. So RideHistory needs a ternary; rendering both fails.
  • The last line of test 4 asserts that queryByText(/Ford Fusion/) is not there. Note it uses queryBy, not getBy getBy throws when nothing matches, queryBy returns null, so asserting absence only works with queryBy. Interviewers ask about this too.
练习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 assertion makes the group order fixed?
测试 2 里有一行让「Sedan / SUV / Luxury 的顺序」变成硬要求。是哪一行?One line in test 2 turns the order of Sedan / SUV / Luxury into a hard requirement. Which line?
先选一个选项Pick an option first
L2填空Fill the blanks补齐 RideHistory 的两个 testid 和互斥逻辑Fill in the two testids of RideHistory and the either-or logic
空的时候只能出现 no-ride-title, 有记录的时候只能出现 history-cabs。把三个空填上。When it is empty only no-ride-title may appear; when there are records only history-cabs may appear. Fill in the three blanks.
JSXsrc/components/Home/RideHistory.jsx3 个空3 blanks
1const RideHistory = () => {
2 const { rideHistory } = useCabContext();
3 const latestRides = rideHistory.slice(-3).reverse();
4
5 return (
6 <section className="history-container">
7 <h3>Ride History</h3>
8
9 {latestRides.length > 0 ? (
10 <ul className="history-list">
11 {latestRides.map((ride, index) => (
12 <li key={`${ride.id}-${index}`} data-testid="">
13 <span>{ride.name}</span>
14 <strong>${ride.price}</strong>
15 </li>
16 ))}
17 </ul>
18 ) : (
19 <p data-testid="" className="empty-state">
20
21 </p>
22 )}
23 </section>
24 );
25};
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

JSX挂错层级Placed on the wrong level示意Illustrative
1// ✕ testid 挂在外层 —— 测试 4 数出来只有 1 个
2<ul className="history-list" data-testid="history-cabs">
3 {latestRides.map((ride, index) => (
4 <li key={ride.id}>
5 <span>{ride.name}</span>
6 </li>
7 ))}
8</ul>
1// ✕ the testid sits on the outer node — test 4 counts only 1
2<ul className="history-list" data-testid="history-cabs">
3 {latestRides.map((ride, index) => (
4 <li key={ride.id}>
5 <span>{ride.name}</span>
6 </li>
7 ))}
8</ul>
测试 4 是 expect(rides).toHaveLength(3)history-cabs 挂在 <ul> 上,getAllByTestId 只找到 1 个, 断言 3 直接失败。
顺带另一个错:key={ride.id} 在这里不安全 —— 同一辆车可以被订两次,id 会重复。 源项目用的是 `${ride.id}-${index}`
Test 4 is expect(rides).toHaveLength(3). Here history-cabs sits on the <ul>, so getAllByTestId finds only 1 element and the assertion for 3 fails.
A second mistake in the same snippet: key={ride.id} is not safe here — the same cab can be booked twice, so the id repeats. The source project uses `${ride.id}-${index}`.
迁移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.

测试全靠 data-testid 找元素The tests find every element by data-testid
先抄一张 testid 表,标清每个几个、挂在哪一层Copy out a testid table first: how many of each, and which element it sits on
要断言「某个东西不存在」You have to assert that something is not there
queryBy 而不是 getBy —— getBy 找不到会抛错queryBy, not getBy; getBy throws when it finds nothing
空状态和列表两个 testidOne testid for the empty state and one for the list
它们互斥,用三元表达式,别两个都渲染They exclude each other: use a ternary, do not render both
toEqual 比一个数组toEqual compares a whole array
那是有序断言,顺序错了就红That is an ordered check; a wrong order fails
这节的要点What to take away
  1. 四个测试是一次完整用户流程:首页 → 选车 → 加载 → 确认 → 历史。The four tests are one complete user journey: home page, pick a cab, loading, confirmation, history.
  2. 13 个 data-testid 是唯一契约;改名字或挂错层级都会红一片。The 13 data-testid values are the only contract; a renamed one or one on the wrong element fails a whole group of tests.
  3. 测试 2 的 toEqual 是有序断言,分组顺序来自 data.json 的键顺序,别自己排。The toEqual in test 2 is an ordered assertion. The group order comes from the key order in data.json, so do not sort them yourself.
  4. 断言「不存在」只能用 queryBy —— getBy 找不到会抛错。To assert that something is absent you have to use queryBy. getBy throws an error when it finds nothing.
  5. 原样跑是 0 个测试跑起来:把 CabContext.js 改名成 .jsx 才能开始。Run the project as it comes and 0 tests start: rename CabContext.js to .jsx before anything else.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lessonContext 放在哪一层 —— 这道题最容易死的地方Which level the Context goes on — the most common way to fail this task
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?