cypress-测试规范.mdc 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. ---
  2. description: 为 Label Studio 编写和更新 Cypress 集成测试
  3. globs:
  4. alwaysApply: false
  5. ---
  6. ## Label Studio 的 Cypress 测试生成规则
  7. ### 项目结构和组织
  8. **测试文件结构:**
  9. - 测试应放置在 `web/libs/editor/tests/integration/e2e/` 中,采用语义化文件夹组织
  10. - 遵循现有文件夹结构:`core/`、`image_segmentation/`、`control_tags/`、`audio/`、`video/`、`timeseries/`、`relations/`、`outliner/`、`bulk_mode/`、`config/`、`drafts/`、`linking_modes/`、`ner/`、`sync/`、`view_all/`
  11. - 测试文件应以 `.cy.ts` 扩展名结尾
  12. - 测试数据应放置在 `web/libs/editor/tests/integration/data/` 中,遵循相同的文件夹结构
  13. **文件命名约定:**
  14. - 使用反映被测试功能的描述性名称
  15. - 使用 kebab-case 命名文件(例如 `audio-regions.cy.ts`、`image-segmentation.cy.ts`)
  16. - 将相关测试分组到逻辑文件夹中
  17. ### 导入标准
  18. **必需的导入:**
  19. 始终从集中的辅助库导入辅助函数:
  20. ```typescript
  21. import { LabelStudio, ImageView, Sidebar, Labels, Hotkeys } from "@humansignal/frontend-test/helpers/LSF";
  22. ```
  23. **测试数据导入:**
  24. 使用相对路径从数据文件夹导入测试数据:
  25. ```typescript
  26. import { configName, dataName, resultName } from "../../data/folder_name/file_name";
  27. ```
  28. **可用的辅助函数:**
  29. - `LabelStudio` - 核心初始化和控制
  30. - `ImageView` - 图像交互和绘制
  31. - `VideoView` - 视频播放和交互
  32. - `AudioView` - 音频播放和区域
  33. - `Sidebar` - 大纲视图和区域管理
  34. - `Labels` - 标签选择和管理
  35. - `Hotkeys` - 跨平台键盘快捷键(Mac/PC 兼容性)
  36. - `Taxonomy`、`Choices`、`DateTime`、`Number`、`Rating`、`Textarea` - 控制标签辅助函数
  37. - `Relations` - 关系管理
  38. - `ToolBar` - 工具栏交互
  39. - `Modals` - 模态对话框处理
  40. - `Tooltip` - 工具提示验证
  41. ### 测试结构标准
  42. **基本测试结构:**
  43. ```typescript
  44. describe("功能名称 - 特定区域", () => {
  45. it("应该执行特定操作", () => {
  46. // 测试实现
  47. });
  48. });
  49. ```
  50. **嵌套 Describes:**
  51. 使用嵌套的 describe 块进行逻辑分组:
  52. ```typescript
  53. describe("图像分割", () => {
  54. describe("矩形工具", () => {
  55. it("应该绘制矩形", () => {
  56. // 测试实现
  57. });
  58. });
  59. });
  60. ```
  61. ### LabelStudio 初始化模式
  62. **简单初始化:**
  63. ```typescript
  64. LabelStudio.init({
  65. config: configString,
  66. task: {
  67. id: 1,
  68. annotations: [{ id: 1001, result: [] }],
  69. predictions: [],
  70. data: { image: "url" },
  71. },
  72. });
  73. ```
  74. **流式 API 初始化(推荐):**
  75. ```typescript
  76. LabelStudio.params()
  77. .config(configString)
  78. .data(dataObject)
  79. .withResult(expectedResult)
  80. .init();
  81. ```
  82. **带附加参数:**
  83. ```typescript
  84. LabelStudio.params()
  85. .config(config)
  86. .data(data)
  87. .withResult([])
  88. .withInterface("panel")
  89. .withEventListener("eventName", handlerFunction)
  90. .withParam("customParam", value)
  91. .init();
  92. ```
  93. ### 必需的测试准备步骤
  94. **始终包含:**
  95. 1. LabelStudio 初始化
  96. 2. 等待对象就绪:`LabelStudio.waitForObjectsReady();`
  97. 3. (可选,通常 waitForObjectsReady 就足够了)等待媒体加载(用于图像/视频/音频):`ImageView.waitForImage();`
  98. 4. 初始状态验证:`Sidebar.hasNoRegions();`
  99. 5. (可选,如果可能)操作后的某些状态验证,例如:`Sidebar.hasRegions(count);`
  100. ### 交互模式
  101. **图像交互:**
  102. ```typescript
  103. // 等待图像加载
  104. ImageView.waitForImage();
  105. // 选择工具
  106. ImageView.selectRectangleToolByButton();
  107. ImageView.selectPolygonToolByButton();
  108. // 绘制操作
  109. ImageView.drawRect(x, y, width, height);
  110. ImageView.drawRectRelative(0.1, 0.1, 0.4, 0.8); // 推荐
  111. // 点击交互
  112. ImageView.clickAt(x, y);
  113. ImageView.clickAtRelative(0.5, 0.5); // 推荐
  114. // 截图比较
  115. ImageView.capture("screenshot_name");
  116. ImageView.canvasShouldChange("screenshot_name", threshold);
  117. ```
  118. **标签管理:**
  119. ```typescript
  120. // 绘制前选择标签
  121. Labels.select("标签名称");
  122. // 验证标签选择
  123. Labels.isSelected("标签名称");
  124. ```
  125. **侧边栏操作:**
  126. ```typescript
  127. // 区域验证
  128. Sidebar.hasRegions(count);
  129. Sidebar.hasNoRegions();
  130. Sidebar.hasSelectedRegions(count);
  131. // 区域操作
  132. Sidebar.toggleRegionVisibility(index);
  133. Sidebar.toggleRegionSelection(index);
  134. ```
  135. ### 断言模式
  136. **标准 Cypress 断言:**
  137. ```typescript
  138. cy.get(selector).should("be.visible");
  139. cy.get(selector).should("have.text", "期望文本");
  140. cy.get(selector).should("have.class", "class-name");
  141. ```
  142. **自定义辅助断言:**
  143. ```typescript
  144. Sidebar.hasRegions(expectedCount);
  145. Sidebar.hasSelectedRegions(expectedCount);
  146. ImageView.canvasShouldChange("screenshot", threshold);
  147. ```
  148. **访问 Window 对象:**
  149. ```typescript
  150. cy.window().then((win) => {
  151. expect(win.Htx.annotationStore.selected.names.get("image")).to.exist;
  152. });
  153. ```
  154. ### 测试数据结构
  155. **配置格式:**
  156. ```typescript
  157. export const configName = `
  158. <View>
  159. <Image name="img" value="$image"/>
  160. <RectangleLabels name="tag" toName="img">
  161. <Label value="Planet"/>
  162. <Label value="Moonwalker" background="blue"/>
  163. </RectangleLabels>
  164. </View>
  165. `;
  166. ```
  167. **数据格式:**
  168. ```typescript
  169. export const dataName = {
  170. image: "https://htx-pub.s3.us-east-1.amazonaws.com/examples/images/example.jpg",
  171. text: "用于处理的示例文本",
  172. };
  173. ```
  174. **结果格式:**
  175. ```typescript
  176. export const resultName = [
  177. {
  178. id: "unique_id",
  179. type: "rectanglelabels",
  180. value: {
  181. x: 10.5,
  182. y: 15.2,
  183. width: 25.8,
  184. height: 30.1,
  185. rectanglelabels: ["Planet"]
  186. },
  187. origin: "manual",
  188. to_name: "img",
  189. from_name: "tag",
  190. }
  191. ];
  192. ```
  193. ### 功能标志管理
  194. **设置功能标志:**
  195. ```typescript
  196. // 导航前
  197. LabelStudio.setFeatureFlagsOnPageLoad({
  198. featureName: true,
  199. });
  200. // 导航后(通常不需要)
  201. LabelStudio.setFeatureFlags({
  202. featureName: true,
  203. });
  204. // 验证
  205. LabelStudio.featureFlag("featureName").should("be.true");
  206. ```
  207. ### 错误处理和重试策略
  208. **对于不稳定的测试:**
  209. ```typescript
  210. const suiteConfig = {
  211. retries: {
  212. runMode: 3,
  213. openMode: 0,
  214. },
  215. };
  216. describe("测试套件名称", suiteConfig, () => {
  217. // 这里是测试
  218. });
  219. ```
  220. ### 日志和调试
  221. **始终包含描述性日志:**
  222. ```typescript
  223. cy.log("使用图像分割配置初始化 LSF");
  224. cy.log("在相对位置绘制矩形");
  225. cy.log("验证区域已创建");
  226. ```
  227. ### 性能考虑
  228. **使用相对坐标:**
  229. - 优先使用 `*Relative()` 方法而非绝对坐标
  230. - 使用 `ImageView.drawRectRelative()` 而非 `ImageView.drawRect()`
  231. **高效等待:**
  232. - 使用 `LabelStudio.waitForObjectsReady()` 进行初始化检查
  233. - 使用 UI 状态检查确保操作已完成且 UI 已准备好进行下一个操作
  234. - **避免使用 `cy.wait(milliseconds)` - 改用基于事件或基于状态的等待**
  235. **等待最佳实践:**
  236. ```typescript
  237. // ❌ 错误 - 任意时间等待不可靠且缓慢
  238. cy.wait(500); // 不知道操作是否真正完成
  239. cy.wait(300); // 在慢速机器上可能太短,在快速机器上太长
  240. // ✅ 正确 - 等待特定的 UI 状态变化
  241. Sidebar.hasRegions(1); // 等待区域出现
  242. Labels.isSelected("标签名称"); // 等待标签选择
  243. cy.get(".loading-spinner").should("not.exist"); // 等待加载完成
  244. // ✅ 正确 - 等待元素状态变化
  245. cy.get("[data-testid='submit-button']").should("be.enabled");
  246. cy.get(".htx-timeseries-channel svg").should("be.visible");
  247. cy.get(".region").should("have.class", "selected");
  248. // ✅ 正确 - 等待网络请求(必要时)
  249. cy.intercept("POST", "/api/annotations").as("saveAnnotation");
  250. cy.get("[data-testid='save-button']").click();
  251. cy.wait("@saveAnnotation"); // 等待特定的网络调用
  252. // ✅ 正确 - 等待动画完成
  253. cy.get(".modal").should("have.class", "fade-in-complete");
  254. cy.get(".tooltip").should("be.visible").and("not.have.class", "animating");
  255. ```
  256. **何时基于时间的等待可能可以接受:**
  257. - 非常短的等待(50ms 或更少)用于 UI 防抖
  258. - 单个动画帧等待(16-32ms)用于快速重渲染
  259. - 当没有完成事件可用时等待 CSS 动画
  260. - 解决已知的浏览器时序问题(记录为临时修复)
  261. **使用常量而非魔法数字:**
  262. ```typescript
  263. // 导入时序常量
  264. import { SINGLE_FRAME_TIMEOUT, TWO_FRAMES_TIMEOUT } from "../utils/constants";
  265. // ✅ 正确 - 使用常量表示帧时序
  266. cy.wait(SINGLE_FRAME_TIMEOUT); // 等待单个动画帧(60fps)
  267. cy.wait(TWO_FRAMES_TIMEOUT); // 等待 1-2 个动画帧用于快速但更复杂的重渲染
  268. // ❌ 错误 - 魔法数字不清楚
  269. cy.wait(16); // 16 是什么?为什么是 16?
  270. cy.wait(32); // 32 是什么?为什么是 32?
  271. // ✅ 正确 - 使用意图明确的常量
  272. ImageView.clickAt(100, 100);
  273. cy.wait(SINGLE_FRAME_TIMEOUT); // 允许画布完成重绘
  274. // 对于其他时序需求可接受,需要文档说明
  275. cy.wait(50); // 允许防抖输入稳定
  276. cy.wait(100); // TODO: 替换为适当的动画完成检查
  277. ```
  278. **截图比较:**
  279. ```typescript
  280. // 操作前捕获
  281. ImageView.capture("before_action");
  282. // 执行操作
  283. Labels.select("Label");
  284. ImageView.drawRectRelative(0.2, 0.2, 0.6, 0.6);
  285. // 验证视觉变化
  286. ImageView.canvasShouldChange("before_action", 0.1);
  287. ```
  288. - 仅在必要时使用,因为它们可能会减慢测试速度
  289. ### 常见测试模式
  290. **基本绘制测试:**
  291. ```typescript
  292. it("应该绘制矩形区域", () => {
  293. LabelStudio.params()
  294. .config(imageConfig)
  295. .data(imageData)
  296. .withResult([])
  297. .init();
  298. LabelStudio.waitForObjectsReady();
  299. ImageView.waitForImage();
  300. Sidebar.hasNoRegions();
  301. Labels.select("标签名称");
  302. ImageView.drawRectRelative(0.1, 0.1, 0.4, 0.8);
  303. Sidebar.hasRegions(1);
  304. });
  305. ```
  306. **控制标签交互测试:**
  307. ```typescript
  308. it("应该选择分类选项", () => {
  309. LabelStudio.params()
  310. .config(taxonomyConfig)
  311. .data(simpleData)
  312. .withResult([])
  313. .init();
  314. Taxonomy.open();
  315. Taxonomy.findItem("选项 1").click();
  316. Taxonomy.hasSelected("选项 1");
  317. });
  318. ```
  319. **状态验证测试:**
  320. ```typescript
  321. it("应该在交互后保持状态", () => {
  322. LabelStudio.params()
  323. .config(config)
  324. .data(data)
  325. .withResult(existingResult)
  326. .init();
  327. LabelStudio.waitForObjectsReady();
  328. Sidebar.hasRegions(1);
  329. // 执行操作
  330. cy.contains("button", "更新").click();
  331. // 验证状态
  332. LabelStudio.serialize().then((results) => {
  333. expect(results).to.have.length(1);
  334. expect(results[0].value).to.deep.equal(expectedValue);
  335. });
  336. });
  337. ```
  338. ### 无障碍性和用户体验
  339. **包含适当的 ARIA 标签:**
  340. ```typescript
  341. cy.get('[aria-label="rectangle-tool"]').click();
  342. ```
  343. **测试键盘交互:**
  344. ```typescript
  345. // 始终优先使用 Hotkeys 辅助函数以实现跨平台兼容性
  346. Hotkeys.undo(); // 自动处理 Ctrl+Z/Cmd+Z
  347. Hotkeys.redo(); // 自动处理 Ctrl+Shift+Z/Cmd+Shift+Z
  348. Hotkeys.deleteRegion(); // Backspace
  349. Hotkeys.deleteAllRegions(); // Ctrl+Backspace/Cmd+Backspace
  350. Hotkeys.unselectAllRegions(); // Escape
  351. // 其他可用的特定辅助函数
  352. ImageView.zoomIn(); // 放大
  353. Labels.selectWithHotkey("1"); // 通过快捷键选择标签
  354. // 直接 Cypress 命令(在大多数情况下避免 - 仅作为最后手段使用)
  355. cy.get("body").type("{esc}"); // ❌ 优先使用 Hotkeys.unselectAllRegions()
  356. cy.get("body").type("{ctrl}{+}"); // ❌ 不跨平台,优先使用辅助函数
  357. cy.get("body").type("{cmd}{+}"); // ❌ 平台特定,优先使用辅助函数
  358. ```
  359. **重要:** 在大多数情况下,你应该**避免**直接使用 Cypress 键盘命令(`cy.get("body").type()`)。改用辅助函数,因为:
  360. - `Hotkeys` 辅助函数自动处理 Mac(Cmd)与 PC(Ctrl)的差异
  361. - 辅助函数提供更好的抽象,更易于维护
  362. - 辅助函数不太容易出现跨平台问题
  363. - 仅在没有辅助函数存在且需要非常特定的键盘交互时使用直接命令
  364. ### 创建新辅助函数
  365. **辅助函数架构模式:**
  366. Label Studio 使用两种主要模式创建辅助函数:
  367. 1. **静态对象模式**(用于单例组件):
  368. ```typescript
  369. export const ComponentName = {
  370. get root() {
  371. return cy.get(".component-selector");
  372. },
  373. get subElement() {
  374. return this.root.find(".sub-element");
  375. },
  376. performAction() {
  377. cy.log("在 ComponentName 上执行操作");
  378. this.root.click();
  379. },
  380. assertState(expectedValue: string) {
  381. this.subElement.should("contain.text", expectedValue);
  382. }
  383. };
  384. ```
  385. 2. **基于类的模式**(用于可参数化的组件):
  386. ```typescript
  387. class ComponentHelper {
  388. private get _baseRootSelector() {
  389. return ".component-base";
  390. }
  391. private _rootSelector: string;
  392. constructor(rootSelector: string) {
  393. this._rootSelector = rootSelector.replace(/^\&/, this._baseRootSelector);
  394. }
  395. get root() {
  396. return cy.get(this._rootSelector);
  397. }
  398. performAction() {
  399. cy.log(`在 ${this._rootSelector} 上执行操作`);
  400. this.root.click();
  401. }
  402. }
  403. // 导出单例和工厂
  404. const ComponentName = new ComponentHelper("&:eq(0)");
  405. const useComponentName = (rootSelector: string) => {
  406. return new ComponentHelper(rootSelector);
  407. };
  408. export { ComponentName, useComponentName };
  409. ```
  410. **辅助函数创建规则:**
  411. 1. **文件放置:**
  412. - 将 UI 辅助函数放在 `web/libs/frontend-test/src/helpers/LSF/`
  413. - 将工具函数放在 `web/libs/frontend-test/src/helpers/utils/`
  414. - 文件名使用 PascalCase(例如 `MyComponent.ts`)
  415. 2. **工具 vs 辅助函数决策:**
  416. - **提取到 `utils/`** 如果函数:
  417. - 不使用 Cypress 命令(`cy.*`)
  418. - 不特定于任何 UI 组件
  419. - 执行通用计算、解析或数据操作
  420. - 可以独立进行单元测试
  421. - **保留在辅助类中** 如果函数:
  422. - 使用 Cypress 命令进行 UI 交互
  423. - 特定于 UI 组件
  424. - 管理元素状态或用户交互
  425. ```typescript
  426. // ✅ 正确 - 提取到 utils/SVGTransformUtils.ts
  427. export class SVGTransformUtils {
  428. static parseTransformString(transformStr: string): DOMMatrix {
  429. // 纯工具 - 无 Cypress,无 UI 依赖
  430. const matrix = new DOMMatrix();
  431. // ... 实现
  432. return matrix;
  433. }
  434. }
  435. // ❌ 错误 - 不要将工具放在 UI 辅助函数中
  436. class TimeSeriesHelper {
  437. // 这应该在工具中
  438. private parseTransformString(transformStr: string): DOMMatrix { ... }
  439. }
  440. ```
  441. 3. **命名约定:**
  442. - 使用与 UI 组件匹配的描述性名称
  443. - 可参数化的辅助函数前缀使用 `use`(例如 `useChoices`)
  444. - 使用一致的方法命名:
  445. - `get propertyName()` 用于元素获取器
  446. - `performAction()` 用于操作
  447. - `assertState()` 用于断言
  448. - `hasState()` 用于布尔检查
  449. 4. **辅助函数内容指南:**
  450. - **应该包含**:可复用的 UI 交互、元素获取器、简单状态验证
  451. - **不应该包含**:复杂的多步骤场景、完整的测试工作流、一次性业务逻辑
  452. 5. **必需元素:**
  453. - 始终包含 `get root()` 作为主元素获取器
  454. - 使用语义化子元素获取器(例如 `get submitButton()`)
  455. - 使用 `cy.log()` 包含描述性日志
  456. 6. **选择器最佳实践:**
  457. - 尽可能使用 ARIA 属性而非其他方法
  458. 7. **方法类型:**
  459. - **获取器**:返回 Cypress 元素以供进一步链接
  460. - **操作**:执行用户交互(点击、输入等)
  461. - **断言**:使用 `.should()` 验证预期状态
  462. - **简单工具**:特定 UI 操作的辅助方法(不是通用工具)
  463. **完整辅助函数示例:**
  464. ```typescript
  465. import TriggerOptions = Cypress.TriggerOptions;
  466. class NewComponentHelper {
  467. private get _baseRootSelector() {
  468. return ".lsf-new-component";
  469. }
  470. private _rootSelector: string;
  471. constructor(rootSelector: string) {
  472. this._rootSelector = rootSelector.replace(/^\&/, this._baseRootSelector);
  473. }
  474. get root() {
  475. return cy.get(this._rootSelector);
  476. }
  477. get input() {
  478. return this.root.find('input[type="text"]');
  479. }
  480. get submitButton() {
  481. return this.root.find('[aria-label="submit"]');
  482. }
  483. get items() {
  484. return this.root.find('.item');
  485. }
  486. fillInput(text: string) {
  487. cy.log(`填充输入:${text}`);
  488. this.input.clear().type(text);
  489. }
  490. selectItem(index: number) {
  491. cy.log(`选择索引为 ${index} 的项目`);
  492. this.items.eq(index).click();
  493. }
  494. findItem(text: string) {
  495. return this.items.contains(text);
  496. }
  497. submit() {
  498. cy.log("提交表单");
  499. this.submitButton.click();
  500. }
  501. hasItem(text: string) {
  502. this.findItem(text).should("be.visible");
  503. }
  504. hasNoItems() {
  505. this.items.should("not.exist");
  506. }
  507. hasSelectedItem(text: string) {
  508. this.findItem(text).should("have.class", "selected");
  509. }
  510. }
  511. // 导出模式
  512. const NewComponent = new NewComponentHelper("&:eq(0)");
  513. const useNewComponent = (rootSelector: string) => {
  514. return new NewComponentHelper(rootSelector);
  515. };
  516. export { NewComponent, useNewComponent };
  517. ```
  518. **将辅助函数添加到索引:**
  519. 创建辅助函数后,将其添加到索引文件:
  520. ```typescript
  521. // 在 web/libs/frontend-test/src/helpers/LSF/index.ts 中
  522. export { NewComponent, useNewComponent } from "./NewComponent";
  523. ```
  524. ### 最佳实践总结
  525. 1. **始终使用语义化文件夹组织**
  526. 2. **从集中位置导入辅助函数**
  527. 3. **使用流式 API 进行 LabelStudio 初始化**
  528. 4. **包含适当的等待机制**
  529. 5. **使用相对坐标进行响应式测试**
  530. 6. **添加描述性日志**
  531. 7. **单独构建测试数据**
  532. 8. **包含正面和负面测试用例**
  533. 9. **在可用时使用辅助方法而非原始 Cypress 命令**
  534. 10. **避免直接使用 Cypress 键盘命令 - 使用 `Hotkeys` 辅助函数实现跨平台兼容性**
  535. 11. **遵循现有命名约定**
  536. 12. **为常见 UI 模式创建可复用的辅助函数**
  537. 13. **对可参数化的组件使用基于类的模式**
  538. 14. **在辅助函数中包含全面的错误处理**
  539. 15. **使用 JSDoc 注释记录复杂的辅助方法**
  540. 16. **在生产测试中使用辅助函数之前先测试它们**
  541. 17. **当工具函数不使用 Cypress 时,将其提取到单独的 utils 文件**
  542. 18. **将复杂的测试场景保留在测试文件中,而不是辅助函数中 - 辅助函数应该简单且可复用**
  543. 19. **避免使用 cy.wait(time) - 使用基于事件或基于状态的等待以获得更可靠的测试**