0-introduction
课程介绍与测试的重要性
- 讲师介绍:Steve Kinney,课程主题为软件测试。
- 核心信念:Steve 提出关于测试的四个核心信念,课程初期重点介绍其中一个,后续会详细讨论全部四个。
测试的基本观点
- 测试并不难:
- 编写测试本身并不复杂,尽管很多人可能认为很难。
- 但并非所有代码都容易测试,课程将探讨如何处理难以测试的代码。
- 测试的必然性:
- 软件总是会被测试,要么由开发者测试,要么由用户在生产环境中测试(导致 bug 报告或夜间紧急修复)。
- 目标是建立自动化测试系统,确保软件正常运行。
- 测试与代码设计的双重关注:
- 测试不仅仅是写测试用例,还包括如何设计易于测试的代码。
- 如果代码难以测试,可能需要重构代码,而不仅仅是提升测试技能。
- 测试和代码设计是相辅相成的,需要平衡两者。
课程内容预览
- 编写测试:学习如何编写测试用例。
- 改进代码以便测试:分析现有代码并改进其可测试性。
- 处理难以测试的代码:包括继承的代码、库代码或自己过去的代码。
- 测试后的反思:通过修改代码和测试,找到更简便的实现方式。
- 测试框架:不假设学员有特定框架经验,课程会涉及 React 和 Svelte 组件的测试,但重点是测试本身。
学习测试的时机
- 建议时机:
- 一旦能写一两个函数并运行,就适合开始学习测试。
- 不要拖延,拖得越久,需要清理的代码问题越多。
- 测试的必要性:
- 当代码复杂到无法完全靠手动检查(如 console.log)验证时。
- 当修改代码时感到恐惧,担心引入回归问题或破坏其他功能时。
- 测试的目标:
- 减少修改代码时的恐惧,确保修改不会破坏其他功能。
- 增强对代码的信心,避免因 bug 影响团队或导致生产环境问题。
- 测试的最终目标是安心,而非追求 100% 测试覆盖率或荣誉徽章。
测试驱动开发(TDD)
- 概念:先写测试,再写代码,确保代码通过测试。
- 优势:
- 先写测试可以避免写出难以测试的代码。
- 通过测试的红-绿循环(失败到通过)带来成就感,帮助理清代码逻辑。
- 适用性:适合在学习基础 JavaScript 后立即学习测试,因为测试本质是自动化验证代码行为。
简单测试示例
- 测试的基本结构:
- 测试就是 JavaScript 代码,使用类似
it或test的函数,描述测试内容并断言期望结果。 - 示例:一个简单的测试,验证基本事实(如
true为真)。
- 测试就是 JavaScript 代码,使用类似
- 测试的作用:
- 逐步构建更复杂的测试,例如验证函数输出(如加法或乘法函数)。
- 确保代码行为符合预期,尤其在修改代码逻辑时。
- 测试的本质:
- 测试运行器会执行所有断言,确保代码行为与预期一致。
- 测试的核心目标是消除对代码修改的恐惧,确保代码变更不会引入问题。
总结
- 测试是软件开发的重要环节,目标是增强开发者对代码的信心。
- 课程将从基础开始,逐步深入,帮助学员掌握测试技能并学会设计易于测试的代码。
- 测试的最佳学习时机是早期,越早开始越能避免后续问题。
1-course-overview
课程内容概述
- 初始阶段:简单测试:
- 课程从编写非常简单的测试开始,目的是让学员熟悉测试流程,建立写测试的习惯和动力。
- 虽然测试内容可能看似琐碎(如验证加法运算),但重点在于培养测试的舒适感和节奏。
- 随着课程进展,测试难度会逐渐增加,但以循序渐进的方式让学员适应。
- 测试的目标:处理预期与异常情况:
- 测试不仅仅是验证代码在预期情况下是否正常工作,更要处理代码可能遇到的各种异常或边缘情况。
- 示例:用户输入异常数据(如姓名中包含表情符号)或变量值为
undefined。 - 测试的作用不仅是抛出边缘情况,而是通过思考测试用例,确保代码覆盖所有可能的场景。
- 平衡代码编写与测试编写之间的关系,探索如何通过测试改进代码设计。
- 测试技巧与实用性:
- 课程会介绍一些实用的小技巧,虽然不一定每天使用,但可以在特定问题上显著简化测试工作。
- 目标不是要求学员记住所有技巧,而是建立“有更好方法”的意识,在需要时能查找并应用这些技巧。
测试中的常见问题:过度模拟(Mocking)
- 模拟的定义与问题:
- 模拟(Mocking)是指在测试中模拟现实世界中的依赖(如 API 调用),以避免在每次测试中实际调用外部服务。
- 问题在于过度模拟可能导致虚假通过(False Positive),即测试通过但实际上代码有问题。
- 过度模拟现实世界可能让测试变成“幻想世界”,失去实际意义。
- 课程处理方式:
- 探讨在适当情况下如何使用模拟,并避免过度依赖。
- 学习如何重构代码,减少对模拟的需要。
- 强调测试的真正目标是减少对代码修改的恐惧,而非追求高测试覆盖率或测试数量。
- 如果过度模拟导致对代码重构仍缺乏信心,则测试未达到预期效果。
测试的具体领域
- 浏览器与 DOM 测试:
- 针对 JavaScript 开发者(特别是前端工程师),课程将涵盖如何测试浏览器中的 DOM 元素。
- 包括直接操作 DOM 节点,以及通过自动化工具模拟浏览器行为(如点击按钮)。
- 目标是自动化“踢轮胎”(验证功能),确保代码修改后仍能正常运行。
- 外部依赖与不可控因素:
- 处理不受控制的外部因素,如 API 调用或网络问题。
- 避免因外部服务问题(如后端 bug 或 API 限流)导致测试失败。
- 确保测试不依赖他人的代码,避免因外部问题导致测试不可靠或被弃用。
课程资源与工具
- 学习资源:
- 提供包含大量笔记的网站(数万字内容),包括代码示例、逐步指导、进一步阅读材料及相关话题的深入内容。
- 即使课程时间有限无法深入探讨某些主题,网站上仍有详细补充资料。
- 提供包含示例代码的仓库,便于测试实践,避免浪费时间编写基础代码,更多关注测试本身。
- 测试工具:
- 测试运行工具有多种选择,如 Jest、Mocha、Chai、Jasmine 等,大多底层机制相似,测试代码在不同框架间可基本复用。
- 课程将使用 Vitest(与 Vite 配套,支持 React、Angular、Svelte、Vue 等框架)。
- 若学员工作中使用 Jest 等其他工具,课程内容依然适用,Steve 会偶尔指出工具间的细微差异。
- 大多数测试工具基于相同的核心库,语法差异较小,主要集中在导入方式或小调整。
总结
- 课程从简单测试入手,逐步深入,覆盖从基础验证到复杂边缘情况的测试技能。
- 重点解决测试中的常见问题(如过度嘲笑),并关注实际应用场景(如 DOM 测试和外部依赖处理)。
- 提供丰富的资源支持学习,确保学员能将测试技能应用到实际项目中,无论使用何种测试工具。
2-anatomy-of-a-test
测试的基本结构与运行
- 示例应用与测试环境:
- 课程提供了一个示例应用(Scratchpad),可以在浏览器中打开并操作,也可以在编辑器中查看代码。
- 测试代码位于
examples/scratchpad目录下,运行测试需使用命令npm test。 - 运行后,测试结果会显示是否通过(pass),如预期通过则显示成功信息。
- 测试运行机制:
npm test命令来源于package.json中的scripts字段,具体调用的是 Vitest 测试运行器(Steve 提到常误读为 "V test",实际为 "Vi-test")。- 对于新创建的
package.json(如通过npm init),默认test脚本为空(仅输出提示信息),需手动安装测试运行器(如 Vitest)并配置测试脚本。 - Vitest 提供 UI 界面(通过
start脚本启动),但 Steve 更倾向于命令行运行测试。
- 测试运行特性:
- 测试运行器会持续运行并监听代码变化,自动重新运行受影响的测试。
- 现代测试运行器(如 Vitest)优化性能,仅重新运行与修改代码相关的测试,避免不必要的重复运行,提升反馈速度。
测试的成功与失败
- 测试通过与失败的定义:
- 测试通过(Pass):测试中的所有断言(expectations)都符合预期,无错误抛出。
- 测试失败(Fail):断言不符合预期或代码抛出错误,导致测试显示红色(失败状态)并提供错误反馈。
- 示例:修改断言(如将
true改为false),保存后测试失败,显示红色并提示错误。
- 测试通过不等于代码正确:
- 重要原则:测试通过并不意味着代码正确,仅表示测试未失败。
- 即使有 100% 代码覆盖率,若测试设计不合理,仍可能隐藏问题。
- 示例:注释掉断言代码,测试仍通过,但这是因为未执行任何检查,而非代码无问题。
- 测试失败的机制:
- 测试失败的核心是抛出错误(Exception)。
- 示例:使用
throw new Error()手动抛出错误,测试会失败并显示错误位置。 - 测试运行器执行测试函数,遇到不符合预期的断言(如
expect(true).toBe(false))时抛出错误,导致测试失败。
测试中的常见陷阱
- 异步代码问题:
- 异步操作(如
setTimeout)可能导致测试在断言执行前结束,从而错误通过。 - 示例:在一个
setTimeout中设置断言expect(true).toBe(false),测试仍通过,因为测试在断言执行前已完成。 - 解决异步问题将在课程后续深入探讨,当前仅指出问题,不展开解决方案。
- 异步操作(如
- 测试的核心逻辑:
- 单元测试的基本过程:调用函数,检查结果,若结果不符合预期则抛出错误,否则继续。
- 测试失败时,测试套件显示红色(失败状态);通过时显示绿色(成功状态)。
- 测试的本质是运行函数并验证结果,异常会导致失败,无异常则通过。
总结
- 测试是通过运行代码并验证断言是否符合预期来评估代码行为的过程。
- 测试通过不代表代码无问题,仅表示未发现问题,需谨慎设计测试用例。
- 测试运行器(如 Vitest)提供自动化运行与反馈机制,支持代码变更时的快速验证。
- 异步代码等特殊情况可能导致测试结果误导,需后续学习解决方案。
3-writing-your-first-test
编写第一个测试
- 示例项目介绍:
- 课程切换到一个名为
arithmetic的示例项目,位于basic-math文件夹中,文件名为arithmetic.js。 - 初始状态下,
arithmetic.js中的函数(如add)和对应的测试文件内容为空,未实现任何功能。
- 课程切换到一个名为
- 测试文件的必要性:
- 如果测试文件中没有任何测试用例(例如将所有内容注释掉),测试运行器(如 Vitest)会报错,提示文件中没有测试。
- 解决方法:可以在测试或测试组上使用
.todo或.skip标记,跳过测试运行。.todo:表示计划后续编写测试,适用于初期规划或模板设置。.skip:表示暂时跳过测试,可能是因为测试有问题或暂不需要运行。
- 使用
.todo或.skip后,测试运行器不会报错,状态显示为跳过。
测试驱动开发(TDD)流程
- TDD 基本流程:红-绿-重构:
- 红(Red):编写一个失败的测试,反映功能未实现或不符合预期。
- 绿(Green):实现或修改代码,使测试通过。
- 重构(Refactor):在测试通过的基础上,优化代码结构,确保功能不变。
- 对于简单功能(如加法函数),重构可能不明显,但对于复杂功能,重构是关键步骤。
- 测试文件与代码文件的组织:
- 测试运行器(如 Vitest)通常识别文件名包含
.test.js或.test.ts的文件,或位于特定目录(如__tests__)的文件。 - Steve 倾向于将测试文件与代码文件放在同一目录,便于同时打开和编辑,而非分离到
__tests__目录。 - 测试文件中通过
import引入要测试的函数(如import add from './arithmetic.js'),以便进行单元测试。
- 测试运行器(如 Vitest)通常识别文件名包含
- 单元测试的优势与局限:
- 单元测试(如测试单个函数)速度快,反馈循环短,便于快速开发和调试(红-绿循环)。
- 相比之下,集成测试(如启动浏览器、导航页面、检查 DOM)虽然能验证整体功能,但速度慢,反馈循环长。
- 两种测试各有适用场景,目标是减少对代码修改的恐惧(dread),课程后续会讨论不同测试类型的具体应用。
编写测试的具体步骤
- 编写第一个测试用例:
- 在测试文件中,定义一个测试用例,描述功能预期(如
it('should add two positive numbers'))。 - 使用
expect断言验证函数结果(如expect(add(2, 2)).toBe(4))。 - 运行测试,初始状态下测试失败(显示红色),因为
add函数未实现,返回undefined。
- 在测试文件中,定义一个测试用例,描述功能预期(如
- 实现功能使测试通过:
- 在
arithmetic.js中实现add函数(如return a + b)。 - 保存后,测试运行器重新运行测试,测试通过(显示绿色),带来成就感(dopamine hit)。
- 在
- 重构阶段:
- 测试通过后,可进入重构阶段,优化代码结构。
- 对于简单函数(如
add),重构可能不明显;但对于复杂功能,重构时测试提供安全保障。 - 每次保存代码,测试运行器会提供即时反馈,确保重构未破坏功能。
总结
- 课程通过一个简单的加法函数示例,介绍了测试驱动开发(TDD)的基本流程:红-绿-重构。
- 强调测试文件与代码文件的组织方式,以及单元测试在快速反馈中的重要性。
- 展示了如何编写第一个测试用例,并通过实现功能使测试通过,同时指出重构在复杂项目中的价值。
4-simple-tests-exercise
练习目标:建立测试节奏
- 目标:
- 本节旨在通过编写简单的数学运算测试,帮助学习者建立测试驱动开发(TDD)的节奏和习惯。
- 重点不在于复杂的实现逻辑(如加法、减法等),而在于熟悉测试流程:引入功能、观察失败、运行测试、修改代码、看到成功。
- 练习内容:
- 从加法(
addition)测试开始,逐步引入其他数学运算函数(如减法、乘法、除法)。 - 通过重复编写测试、观察失败(红色)、实现功能、看到成功(绿色)的过程,培养测试习惯。
- 从加法(
逐步编写简单测试
- 减法测试:
- 引入减法功能,编写测试用例
it('should subtract one number from the other')。 - 使用
expect(subtract(5, 3)).toBe(2)断言,初始运行测试失败(红色),因为subtract函数未实现。 - 实现
subtract函数(return a - b),保存后测试通过(绿色),带来成就感。
- 引入减法功能,编写测试用例
- 乘法测试:
- 编写乘法测试用例
it('should multiply two numbers'),断言如expect(multiply(3, 2)).toBe(6)。 - 测试初始失败,修改
multiply函数(return a * b),测试通过。 - 强调重复此过程有助于建立肌肉记忆,减少对测试语法的陌生感。
- 编写乘法测试用例
- 除法测试:
- 编写除法测试用例
it('should divide two numbers'),断言如expect(divide(10, 5)).toBe(2)。 - 提到除法可能涉及浮点数等复杂问题,但本节暂不处理,仅实现基本功能(
return a / b),使测试通过。
- 编写除法测试用例
- 测试节奏:
- 强调不急于一次性实现所有功能,而是逐步完成每个测试用例,培养良好习惯。
- Steve 提到个人偏好手动保存(
Command + S)而非自动保存,以获得即时反馈,同时尊重他人使用自动保存的习惯。
测试驱动开发的适用性与局限性
- TDD 的简单应用:
- 本节展示的 TDD 流程是基础版本:编写失败测试(红)、实现功能使测试通过(绿)、继续下一个功能。
- TDD 特别适用于明确需求的功能开发,如处理数据结构、转换数据或计算属性。
- TDD 的优势:
- 提供流畅的工作节奏,容易进入专注状态(flow state),通过红-绿反馈循环增强信心。
- 快速反馈帮助开发者在修改代码时降低出错风险。
- TDD 的局限性与挑战:
- 已有代码:对于没有测试的遗留代码,难以直接应用 TDD,因为代码已经存在,新增功能时难以从失败测试开始。
- 不明确问题:面对 bug 报告或未知问题时,可能需要先探索解决方案,无法直接编写测试,可能在解决问题途中才意识到未遵循 TDD。
- 现实情况:有时开发者会因外部判断或时间压力而偏离 TDD 流程。
- 建议与许可:
- Steve 鼓励在可能的情况下尝试 TDD,享受其节奏和反馈带来的好处。
- 同时给予“许可”,承认 TDD 并非在所有场景都适用,开发者无需因未严格遵循 TDD 而感到内疚。
总结
- 本节通过简单的数学运算测试,引导学习者熟悉 TDD 的基本流程和节奏:红-绿循环。
- 强调重复练习以建立肌肉记忆,减少对测试语法的陌生感。
- 讨论了 TDD 的适用场景及其局限性,鼓励在适合时使用 TDD,同时理解现实中的挑战和灵活性。
5-testing-guidelines
测试的基本原则
- 测试并不难:
- Steve 强调编写测试并不复杂。通过之前的练习,学习者已经完成了四个简单测试(如加法、减法等),证明测试的基本编写过程是可行的。
- 例如,测试
2 + 2等于4,通过简单的断言即可完成,降低了初学者的心理门槛。
- 将代码分解为小函数以便测试:
- 将代码拆分为小的、命名清晰、易于测试的函数是一个好习惯。没有人会因为将代码拆得太细而导致问题。
- 例如,将复杂的逻辑(如标题大小写转换)从大型组件中提取为独立函数(如
titleCase),便于测试其输入和输出。 - 对于大型代码库,可能无法一次性为所有内容编写测试,但逐步提取小功能进行测试是可行的策略。
代码覆盖率与测试的不足
- 代码覆盖率分析:
- 代码覆盖率工具只分析被测试文件导入的代码,未被测试的文件不计入覆盖率报告。
- 当前的简单测试(如加法、乘法)在覆盖率报告中可能显示 100%,因为所有代码路径都被测试覆盖。
- 然而,100% 覆盖率并不意味着测试完备,当前测试忽略了许多潜在问题。
- 未考虑的问题(不快乐路径,Unhappy Path):
- 当前测试仅覆盖了理想情况(Happy Path),如
2 + 2 = 4,未考虑现实世界中的异常情况。 - JavaScript 中存在许多数学运算的怪异行为,例如:
true + true = 2"1" + "1" = "11"- 除以 0、传递
null、传递字符串(如"potato")、传递单个参数等。
- 测试和代码编写需要考虑这些“不快乐路径”,以确保代码在各种异常情况下也能正确处理。
- 当前测试仅覆盖了理想情况(Happy Path),如
如何处理异常情况
- 培养对异常的敏感性:
- 测试不仅仅是调用测试库的方法,而是需要开发者培养对潜在问题的“偏执”(paranoia),思考所有可能出错的情况。
- Steve 提到,资深工程师看似“聪明”,并非天赋,而是因为他们经历过多次失败,积累了经验,知道哪些地方可能出错。
- 处理异常的三种选择:
- 优雅失败(Fail Gracefully):
- 尝试让函数在异常情况下仍能工作。例如,将字符串
"1"解析为数字,或在缺少参数时假设默认值为0。 - 具体处理方式取决于业务需求,没有固定答案。
- 尝试让函数在异常情况下仍能工作。例如,将字符串
- 抛出错误(Throw an Error):
- 在无法合理处理的情况下抛出错误,尤其是在构建时或有错误处理机制的情况下。
- 抛出错误比让代码进入不确定的诡异状态要好。
- 不处理(错误的做法):
- 当前代码未处理异常情况(如传入
undefined),尽管覆盖率显示 100%,但行为不可预测,这是错误的。 - 必须通过测试明确定义代码在异常情况下的行为。
- 当前代码未处理异常情况(如传入
- 优雅失败(Fail Gracefully):
测试驱动开发与异常处理
- 通过测试驱动异常处理:
- 使用 TDD 流程,编写测试覆盖各种异常情况,驱动代码完善。
- 测试套件的作用在于反复用不同输入“锤击”代码,确保代码在各种场景下表现符合预期。
- 例如,添加测试用例
it('should add two negative numbers'),断言expect(add(-2, -2)).toBe(-4),验证代码处理负数的能力。
- 持续验证假设:
- 测试帮助开发者验证对代码行为的假设。如果某些情况下代码行为不符合预期,测试会提前暴露问题。
- 通过不断添加测试用例(包括正常和异常情况),开发者可以更全面地了解代码的实际表现。
总结
- 本节讨论了测试的基本原则,强调编写测试并不难,但需要将代码分解为小函数以便测试。
- 指出代码覆盖率并非测试质量的唯一指标,当前测试忽略了“不快乐路径”,需考虑异常情况。
- 提供了处理异常的三种策略(优雅失败、抛出错误、不处理),并强调通过 TDD 驱动代码完善。
- 鼓励开发者培养对潜在问题的敏感性,通过测试验证代码行为,逐步构建健壮的代码库。
7-testing-for-edge-cases-exercise
边缘案例测试的探索与练习
- 任务目标:
- Steve 鼓励学习者探索数学函数中的边缘案例(Edge Cases),如除以零、传入无效值(如数组、字符串、布尔值、
undefined、null等)。 - 开发者需自行决定函数在这些情况下的行为(如抛出错误、返回默认值或特定结果),并通过测试明确定义这些行为。
- 目标是发现难以测试或实现的情况,共同讨论解决方案。
- Steve 鼓励学习者探索数学函数中的边缘案例(Edge Cases),如除以零、传入无效值(如数组、字符串、布尔值、
- 测试的作用与文档化:
- 测试不仅是验证代码正确性,也是记录代码意图的方式。相比开发者常忽视的文档编写,测试用例(如
it('should default undefined values to 0'))明确表达了代码在特定情况下的预期行为。 - 测试帮助后来的开发者或自己重构代码时理解设计意图,避免无意打破原有逻辑。
- 测试不仅是验证代码正确性,也是记录代码意图的方式。相比开发者常忽视的文档编写,测试用例(如
具体边缘案例讨论与实现
- 处理数组输入:
- 学生提出测试数组输入的情况,讨论
subtract函数在传入数组时的行为。 - 决定函数应接受数组并计算数组内数字的差值(如
[10, 5, 2]应计算为10 - 5 - 2 = 3)。 - 编写测试用例
it('should accept an array and subtract all the numbers'),断言expect(subtract([10, 5, 2])).toBe(3)。 - 初始测试失败(返回
NaN),修改代码使用Array.isArray检查输入是否为数组,并用reduce计算差值,测试通过。 - 强调测试记录了函数行为,若未来有人查看代码,可明确了解数组输入的处理方式。
- 学生提出测试数组输入的情况,讨论
- 处理无效字符串与
NaN:- 测试无效输入(如字符串
'potato'或NaN),预期抛出错误。 - 编写测试用例
it('should throw if the input is not a number'),验证无效输入行为与预期一致。 - 测试通过,确认代码在无效输入时抛出相同错误,确保重构不会破坏此逻辑。
- 测试无效输入(如字符串
- 处理缺失参数(
undefined):- 测试函数在缺少参数时的行为,决定将
undefined默认值为0。 - 编写测试用例
it('should default undefined values to 0'),断言expect(subtract(3, undefined)).toBe(3)及expect(subtract(undefined, 3)).toBe(-3)。 - 初始测试失败,修改代码设置默认值(如
a = a || 0),测试通过。 - Steve 提到测试驱动开发(TDD)的挑战在于克制冲动,避免一次性解决所有问题,需逐步通过测试驱动代码完善。
- 测试函数在缺少参数时的行为,决定将
- 处理
null输入:- 测试
null输入的行为,决定默认值为0(或可选择抛错)。 - 编写测试用例
it('should default null values to 0'),断言expect(subtract(3, null)).toBe(3)。 - 测试通过,强调测试套件确保在关注新问题(如
null)时,不会遗忘旧问题(如undefined),提供即时反馈。
- 测试
- 处理除以零:
- 测试除以零的情况,讨论决定返回
null(或抛错)。 - 编写测试用例
it('should return null if dividing by zero'),断言expect(divide(10, 0)).toBe(null)。 - 初始测试失败(返回
Infinity),修改代码检查除数是否为0,若为0则返回null。 - 测试过程中发现修改代码时意外破坏了正常情况(如
10 / 2未返回值),测试套件立即反馈问题,修正后测试通过。 - 进一步优化代码,直接在除法前检查
b === 0,返回null,确认重构后所有测试仍通过。
- 测试除以零的情况,讨论决定返回
- 其他潜在边缘案例:
- 提到空字符串(
'')在 JavaScript 中被视为0,需决定是否接受此行为并编写测试。 - 强调测试帮助开发者快速验证假设,避免手动运行代码检查输出。
- 提到空字符串(
测试驱动开发的核心价值
- 即时反馈:
- 测试套件在代码变更时提供即时反馈,帮助开发者发现因专注于新问题而破坏旧逻辑的情况。
- 例如,处理除以零时意外破坏正常除法逻辑,测试立即暴露问题,避免问题隐藏。
- 重构信心:
- 测试覆盖边缘案例后,开发者可自信重构代码,验证新实现是否仍满足所有需求。
- 例如,优化除以零检查逻辑后,测试确认未破坏其他功能。
- 逐步解决问题:
- TDD 强调通过测试逐步驱动代码完善,避免一次性解决所有问题,确保每个步骤都经过验证。
- Steve 坦言即使资深开发者也需克制冲动,遵循 TDD 流程。
总结
- 本节通过练习探索数学函数的边缘案例(如数组、缺失参数、
null、除以零等),展示了如何通过 TDD 定义和实现代码行为。 - 强调测试不仅是验证工具,也是记录代码意图的方式,弥补文档化的不足。
- 测试套件提供即时反馈和重构信心,确保在解决新问题时不会破坏旧逻辑。
- 鼓励开发者决定函数在边缘情况下的行为,并通过测试明确表达这些决策,逐步构建健壮代码。
8-referential-equality
JavaScript 中的相等性与测试
- 背景与问题:
- Steve 介绍了 JavaScript 中相等性(Equality)的概念,特别是在前端框架(如 React)中常遇到的不可变状态(Immutable State)和对象引用问题。
- 在 React 中,通常希望新对象与旧对象不同以触发重新渲染(Re-render),但在测试中,对象引用相等性会带来挑战。
- 迄今为止,测试主要集中在简单值(如
2 + 2 = 4),这些值在内存中是唯一的(如数字4或字符串'four'),因此可以用===(严格相等)直接比较。
- 对象与数组的引用相等性问题:
- 对于对象和数组,即使内容完全相同,
===也无法判断相等,因为它们在内存中是不同的引用。 - 示例:两个内容相同的数组或对象(如
{ a: 1 }和{ a: 1 })用===比较会返回false,因为它们指向不同的内存地址。
- 对于对象和数组,即使内容完全相同,
测试中的相等性断言方法
.toBe的局限性:.toBe用于测试原始值(Primitives,如数字、字符串)的严格相等性(===),适用于简单值的比较。- 对于对象或数组,
.toBe会失败,因为它检查的是引用相等性,而非内容相等性。 - 测试工具(如 Vitest 或 Jest)会提供有用的错误提示,指出可能使用了错误的断言方法。
.toEqual的作用:.toEqual用于比较对象或数组的内容相等性,递归检查每个键值对是否相同,而不关心引用是否相同。- 示例:
expect({ a: 1 }).toEqual({ a: 1 })会通过,即使它们是不同的对象引用。 - 这是 Steve 最常用的方法,适用于大多数测试场景。
.toStrictEqual的严格性:.toStrictEqual比.toEqual更严格,不仅检查内容相等,还要求对象没有额外的未定义属性,且实例类型(如原型)必须相同。- 示例:
{ a: 1 }和{ a: 1, b: 2 }用.toEqual可能通过(如果只关心a),但用.toStrictEqual会失败,因为属性数量不同。 - 另一个示例:两个具有相同属性但不同原型的对象(如不同构造函数创建的对象)用
.toStrictEqual会失败。 - Steve 提到 99.999% 的情况下使用
.toEqual就足够,.toStrictEqual适用于需要严格验证对象结构或类型的场景。
其他测试辅助方法
.not的反向断言:.not用于反向断言,测试与预期相反的结果。- 常见用法如
expect(() => someFunction()).not.toThrow(),验证函数不抛出错误。 - 提供灵活性,但需谨慎使用,确保测试意图清晰。
.fails的反向测试(不推荐):test.fails用于期望测试失败,适用于检查是否存在误判(False Positive)或调试时验证失败消息。- Steve 不推荐将其提交到代码库中,仅用于临时 sanity check(理智检查),避免代码库中充斥混乱的测试。
.todo和.skip的注意事项:.todo用于标记待完成的测试,.skip用于暂时跳过测试。- Steve 警告不要在代码库中留下过多
.todo或.skip,可能导致重要测试被遗忘或忽略。 - 可以通过 ESLint 规则禁止提交包含
.skip或.todo的代码,防止意外跳过测试。
工具支持与总结
- 编辑器支持:
- VS Code 等工具会识别 Jest/Vitest 的断言方法,提供代码补全,表明这些方法在不同测试框架中通用。
- 总结与建议:
.toBe适用于原始值的严格相等性测试(===),如数字、字符串。.toEqual适用于对象和数组的内容相等性测试,递归检查键值对,是最常用的方法。.toStrictEqual适用于需要严格验证对象结构或类型的场景,检查额外属性和实例类型。- 辅助方法如
.not、.fails、.todo和.skip需谨慎使用,确保测试意图清晰且代码库整洁。 - 选择合适的断言方法是测试对象和数组时的关键,避免因引用相等性问题导致测试失败。
9-testing-randomness
测试中的随机性问题
- 背景与挑战:
- Steve 提出了测试中遇到的一个新问题:如何处理非确定性(Non-deterministic)行为。
- 非确定性行为的例子包括:当前日期和时间(会随时间变化)、随机数、UUID(唯一标识符,设计上几乎不会重复)。
- UUID 的目标是生成的字符串几乎不可能重复(理论上可能,但在实际时间范围内几乎不可能),这为测试带来了困难,因为每次运行结果都不同,无法直接断言固定值。
- 测试随机性的三种策略:
- Steve 提前剧透了处理随机性的三种方法(并在本次讨论中先聚焦前两种,第三种稍后揭示):
- 不关心随机部分:忽略随机生成的内容,只测试可控的部分。
- 使非随机部分可控:通过某种方式固定随机值,使其在测试中可预测。
- 控制随机性:通过模拟(Mocking)或种子(Seed)等方式完全控制随机行为(后续讨论)。
- 本节先讨论前两种方法。
- Steve 提前剧透了处理随机性的三种方法(并在本次讨论中先聚焦前两种,第三种稍后揭示):
示例代码与问题
- 代码概述:
- 示例代码是一个
Person类或函数,用于创建人员对象。 - 每个人员对象包含
firstName(名)、lastName(姓)和一个随机生成的id(格式为person-前缀加随机字符串)。 - 如果未提供
firstName或lastName,代码会抛出错误。 - 包含一个
fullNamegetter,用于动态计算并返回全名(firstName + lastName)。
- 示例代码是一个
- 测试中的问题:
- 测试用例(当前被标记为
skip)试图验证创建的Person对象是否符合预期。 - 断言
expect(person).toEqual({ firstName: 'Grace', lastName: 'Hopper' })会失败,因为实际对象包含一个额外的随机id属性,而.toEqual会检查所有属性。 - 即使使用
.toEqual,随机id的值每次都不同,无法直接断言固定值,导致测试不稳定。
- 测试用例(当前被标记为
解决方法 1:不关心随机部分
- 策略:
- 忽略
id的具体值,只验证其存在以及格式是否正确(如以person-开头)。 - 测试重点放在可控属性(
firstName和lastName)上,确保它们按预期返回。 - 使用自定义断言或辅助方法验证
id的格式,而不关心其随机部分。
- 忽略
- 实现:
- 修改测试用例,不直接用
toEqual检查整个对象,而是分开验证:- 使用
expect(person.firstName).toBe('Grace')和expect(person.lastName).toBe('Hopper')验证固定属性。 - 使用
expect(person.id).toBeDefined()确保id存在。 - 使用
expect(person.id).toMatch(/^person-/)验证id以person-开头,忽略后续随机部分。
- 使用
- 这样,测试只关心可控部分和格式要求,忽略随机值的具体内容。
- 修改测试用例,不直接用
- 结果:
- 修改后的测试通过,因为它不依赖于
id的具体值,只验证了格式和固定属性。 - 如果
id前缀不正确(如不是person-开头),测试会失败,确保代码行为符合预期。
- 修改后的测试通过,因为它不依赖于
总结
- 当前进展:
- 本节讨论了测试中随机性带来的挑战,聚焦于 UUID 生成的不可预测性。
- 通过“不关心随机部分”的策略,测试可以忽略
id的具体值,只验证其格式和固定属性,确保测试的稳定性。 - Steve 提到后续会讨论其他两种策略(使非随机部分可控和完全控制随机性),为更复杂的场景提供解决方案。
- 核心要点:
- 测试非确定性行为需要调整策略,不能直接断言随机值。
- 通过聚焦可控部分(如属性值、格式要求),可以有效绕过随机性带来的测试困难。
- 测试工具(如
toMatch、toBeDefined)提供了灵活的方法来验证部分属性,而不依赖具体随机值。
10-asymmetric-matchers-exercise
测试随机性和非确定性行为的挑战
- 背景:
- Steve 提出了一个新的测试挑战,涉及一个
Character类(基于《龙与地下城》D&D 游戏角色),包含随机性(如掷骰子生成的属性值)和非确定性行为(如日期)。 - 目标是通过编写测试来验证角色的属性,但由于随机性和日期等不可控因素,传统的断言方法(如
.toEqual)会失败。 - Steve 鼓励参与者自行尝试解决问题,并承诺在之后提供多种解决方案,强调“正确答案”是适合个人或团队需求的方法。
- Steve 提出了一个新的测试挑战,涉及一个
- 代码概述:
Character类包含属性如firstName、lastName、role(角色职业,如“Necromancer”)、level(默认从 1 开始)、掷骰子生成的随机属性值(如strength、dexterity等)以及createdAt和lastModified日期。rollDice函数用于生成随机数(模拟 D&D 掷骰子机制,如 4 个 6 面骰子)。- 测试目标是验证角色创建时的初始属性、升级(
levelUp)行为等,但随机值和日期会干扰测试结果。
测试策略与解决方案
Steve 展示了多种方法来应对随机性和非确定性问题,并强调测试设计应基于个人或团队的舒适度和代码可维护性。
-
简单方法:逐个属性验证
- 策略:不使用
.toEqual比较整个对象,而是逐个验证关键属性。 - 实现:
expect(character.firstName).toBe('Ada')expect(character.lastName).toBe('Lovelace')expect(character.role).toBe('Computer Scientist')
- 优点:简单直接,避免因随机属性或额外属性导致测试失败。
- 缺点:对于大对象,逐个验证可能繁琐,且容易漏掉属性。
- 适用场景:对象较小或只需验证少量关键属性时。
- 策略:不使用
-
使用
expect.objectContaining部分匹配- 问题:使用
.toEqual比较整个对象会失败,因为对象包含未断言的随机属性或额外属性。 - 策略:使用
expect.objectContaining只验证关心的属性,忽略其他属性。 - 实现:
expect(character).toEqual( expect.objectContaining({ firstName: "Ada", lastName: "Lovelace", role: "Computer Scientist", }) ); - 优点:适用于大对象或 API 响应,只需验证部分属性;测试不会因无关属性变化而失败。
- 适用场景:处理复杂对象或只需验证部分属性时(如 API 返回数据或对象添加新属性)。
- 问题:使用
-
使用
expect.any处理随机类型- 问题:随机值(如掷骰子结果)和日期(如
createdAt)无法固定断言。 - 策略:使用
expect.any(Type)验证值的类型,而不关心具体值。 - 实现:
expect(character).toEqual( expect.objectContaining({ strength: expect.any(Number), dexterity: expect.any(Number), intelligence: expect.any(Number), charisma: expect.any(Number), createdAt: expect.any(Date), lastModified: expect.any(Date), id: expect.stringMatching(/^character-/), // 验证 ID 格式 level: 1, // 固定值需明确断言 }) ); - 优点:能处理随机值和日期,只验证类型或格式,忽略具体内容。
- 缺点:测试可能过于宽松,无法捕获值的合理性问题(如掷骰子结果是否在合理范围内)。
- 适用场景:随机值或日期不可控,但需验证类型或格式时。
- 问题:随机值(如掷骰子结果)和日期(如
-
使用
.not和逻辑断言处理动态变化-
问题:角色升级(
levelUp)后,lastModified日期应更新,不能与createdAt或初始lastModified相同。 -
策略:保存初始值,执行操作后使用
.not.toBe验证值已变化,或使用逻辑断言(如toBeGreaterThan)验证值的变化方向。 -
实现:
const initialLastModified = character.lastModified; character.levelUp(); expect(character.lastModified).not.toBe(initialLastModified); // 验证日期更新 const initialLevel = character.level; character.levelUp(); expect(character.level).toBeGreaterThan(initialLevel); // 验证等级增加 -
优点:能验证动态行为(如日期更新、等级提升),对具体值不敏感。
-
适用场景:测试状态变化或值更新逻辑时。
-
-
测试设计考虑:AAA 模式与测试拆分
- AAA 模式:Arrange(准备)、Act(执行)、Assert(断言),帮助结构化测试代码。
- 测试拆分:将大测试拆分为小测试,每个测试聚焦一个行为(如等级初始化、升级后日期更新),失败时更容易定位问题。
- 优点:提高测试可读性和可维护性,避免一个测试失败导致难以调试。
- Steve 的建议:测试设计应基于“未来你”的需求,清晰命名和拆分测试永远不会出错。
高级策略:依赖注入(Dependency Injection)
-
问题:随机性(如
rollDice)和外部依赖(如 API 调用)嵌入在代码中,难以控制,导致测试不可预测。 -
策略:使用依赖注入,将外部行为(如掷骰子函数)作为参数传入,测试时可传入固定值函数。
-
实现:
// 修改 Character 构造函数,接受可选的 roll 函数 class Character { constructor(firstName, lastName, role, roll = rollDice) { this.firstName = firstName; this.lastName = lastName; this.role = role; this.strength = roll(); // 使用传入的 roll 函数 // 其他属性... } } // 测试时传入固定值函数 const fixedRoll = () => 15; const character = new Character( "Ada", "Lovelace", "Computer Scientist", fixedRoll ); expect(character.strength).toBe(15); // 可预测结果 -
优点:
- 使代码更可测试,无需模拟(Mock)底层函数(如
Math.random)。 - 提高代码灵活性,允许在不同上下文中传入不同实现。
- 使代码更可测试,无需模拟(Mock)底层函数(如
-
适用场景:
- 测试依赖外部行为(如 API 调用、随机数生成)的组件。
- Steve 举例:React 组件接受
fetchData属性,测试时传入假数据函数,避免真实 API 调用。
-
注意:过度模拟可能导致测试与现实脱节,依赖注入是更自然的控制方式。
总结与核心要点
- 测试随机性和非确定性:
- 使用
expect.objectContaining忽略无关属性,聚焦关键属性。 - 使用
expect.any(Type)验证类型,处理随机值和日期。 - 使用
.not和逻辑断言(如toBeGreaterThan)验证动态变化。
- 使用
- 测试设计:
- 遵循 AAA 模式(Arrange-Act-Assert)结构化测试。
- 拆分测试为小单元,便于调试和维护。
- 测试应基于团队舒适度和代码可维护性,而非固定规则。
- 依赖注入:
- 将不可控行为(如随机数生成、API 调用)作为参数传入,测试时传入固定值实现。
- 提高代码可测试性和灵活性,避免过度模拟导致测试脱离现实。
- Steve 的哲学:
- 测试的“正确答案”是让你和团队在重构时感到自信的方法。
- 测试应避免因无关原因(如拼写错误、随机值)失败,保护短期心理健康和长期代码健康。
- 后续内容将深入探讨模拟(Mocking)和时间控制等高级技术,当前策略已能解决大部分问题。
11-before-after-hooks-async-code
测试中的钩子函数(Before/After Hooks)与异步代码处理
- 背景:
- Steve 讨论了测试中两个常见主题:如何使用钩子函数(如
beforeEach)减少重复代码,以及如何处理异步代码。 - 他强调了清晰性和可维护性在测试中的重要性,同时指出现代测试框架如何简化异步代码的处理。
- Steve 讨论了测试中两个常见主题:如何使用钩子函数(如
钩子函数(Before/After Hooks)
- 概念:
- 测试框架(如 Jest)提供了钩子函数,如
beforeEach、afterEach、beforeAll和afterAll,用于在测试前后执行重复的设置或清理工作。 - 示例:Steve 提到在每个测试前创建
character对象,避免在每个测试用例中重复代码。let character; beforeEach(() => { character = new Character("Ada", "Lovelace", "Computer Scientist"); });
- 测试框架(如 Jest)提供了钩子函数,如
- 优点:
- 减少代码重复,符合 DRY(Don't Repeat Yourself)原则。
- 适合设置通用测试环境,如模拟网络请求、初始化数据等。
- 缺点与警告:
- 清晰性问题:如果测试文件较大,使用
beforeEach定义的变量(如character)来源可能不明显,难以追踪。 - 灵活性问题:如果某个测试需要不同的
character配置,必须覆盖钩子中定义的内容,增加复杂性。 - Steve 的观点:测试代码不需要“聪明”或过度抽象,优先考虑简单和直观。即使重复代码,只要测试清晰且易于调试,重复是可以接受的。
- 测试失败时,应能快速定位问题(“triage it real fast”),而抽象可能降低可读性。
- “You don’t need your tests to be clever. You want your tests, even if they’re repetitive, to be dumbly simple.”
- 清晰性问题:如果测试文件较大,使用
- 建议:
- 钩子函数适用于特定场景(如设置全局模拟环境),但不建议滥用。
- 如果钩子函数导致测试不清晰,宁愿在每个测试中显式编写重复代码。
异步代码的测试
- 背景:
- 异步代码是 JavaScript 测试中的常见挑战,因为测试框架需要等待异步操作完成才能正确断言结果。
- 过去,处理异步代码需要复杂的回调机制(如
done回调),增加了测试难度。
- 传统方法(过时):
- 早期测试框架要求测试函数接受一个
done回调,异步操作完成后手动调用done(),以通知测试框架测试已完成。test("async operation", (done) => { someAsyncFunction((result) => { expect(result).toBe("expected"); done(); // 通知测试完成 }); }); - 问题:
- 如果未调用
done(),测试会无条件通过,即使断言失败,因为测试框架无法检测异步操作的结果。 - 代码复杂,难以阅读和维护。
- 如果未调用
- 早期测试框架要求测试函数接受一个
- 现代方法(推荐):
- 现代测试框架(如 Jest)支持
async/await,极大地简化了异步代码的测试。 - 只需将测试函数标记为
async,并使用await等待异步操作完成,测试框架会自动等待断言执行。test("async operation", async () => { const result = await someAsyncFunction(); expect(result).toBe("expected"); }); - 优点:
- 代码更简洁,符合现代 JavaScript 语法。
- 避免手动管理回调,减少出错可能性。
- 注意事项:
- 如果忘记使用
await,可能导致断言操作在 Promise 尚未解析时执行,结果为未完成状态(如Promise对象),测试会失败或行为异常。 - Steve 建议:遇到旧式
done回调代码时,应重构为async/await以提高可读性和维护性。
- 如果忘记使用
- 现代测试框架(如 Jest)支持
- 总结:
- 异步测试在现代框架中已变得简单,只要正确使用
async/await,无需额外处理。 - 如果在代码库中遇到旧式异步测试(如
done回调),应优先重构为现代语法。
- 异步测试在现代框架中已变得简单,只要正确使用
核心要点
- 钩子函数:
beforeEach等钩子函数可以减少测试代码重复,但可能降低测试清晰性和灵活性。- 测试优先考虑简单和可读性,即使重复代码,也比抽象导致的复杂性更好。
- Steve 建议谨慎使用钩子函数,优先在测试中显式编写代码以提高可维护性。
- 异步代码:
- 现代测试框架通过
async/await简化了异步测试,无需手动回调(如done)。 - 确保使用
await等待异步操作完成,否则测试可能因未解析的 Promise 而失败。 - 遇到旧式异步代码时,应重构为
async/await以提高代码质量。
- 现代测试框架通过
- 总体哲学:
- 测试的目标是快速调试和保持代码稳定性(“stay green all the time”)。
- 避免过度优化或复杂化测试代码,简单和直观是关键。
12-dom-testing-tools
在 Node 环境中测试 DOM
- 背景:
- 前端工程师经常需要测试与 DOM(文档对象模型)相关的代码,但测试通常在 Node.js 环境中运行,而 Node.js 默认不提供浏览器 API(如 DOM)。
- Steve 讨论了如何通过模拟 DOM 环境来测试前端代码,并介绍了相关工具和注意事项。
问题:Node.js 中没有 DOM
- 核心问题:
- 测试通常在 Node.js 环境中运行,而 Node.js 没有内置的浏览器 API(如
document、window等),因此无法直接测试 DOM 操作。 - 尽管 Node.js 可以运行 JavaScript,但它不包含 DOM 或浏览器特定的功能(Steve 提到不讨论 BOM(浏览器对象模型)与 DOM 的区别,重点是 DOM 在 Node.js 中不可用)。
- 测试通常在 Node.js 环境中运行,而 Node.js 没有内置的浏览器 API(如
- 解决方案:
- 使用模拟 DOM 的库,在 Node.js 环境中提供类似浏览器的 API,从而允许测试 DOM 操作。
- Steve 提到可以通过“欺骗”(lying)的方式实现,即使用工具模拟 DOM 环境。
DOM 模拟工具
- JSDOM:
- 简介:JSDOM 是一个在 Node.js 中重新实现的 DOM 规范库,最初由 Dominic DiCola 开发,现在由社区维护。
- 特点:
- 提供了与规范兼容的浏览器 API 模拟,如
document.querySelector、document、window等。 - 更重、更全面,模拟更接近真实浏览器的行为。
- 提供了与规范兼容的浏览器 API 模拟,如
- 适用场景:
- 适用于需要更高精度模拟的场景,尤其是处理复杂 DOM 操作或边缘情况时。
- 缺点:
- 由于功能全面,运行时开销较大,测试速度可能稍慢。
- HappyDOM:
- 简介:HappyDOM 是另一个 DOM 模拟库,适用于 Vitest 等测试框架。
- 特点:
- 更轻量,运行速度更快。
- 模拟精度可能不如 JSDOM,但足以应对大多数基本 DOM 测试需求。
- 适用场景:
- 适用于简单 DOM 操作测试,如检查 DOM 节点是否存在或基本事件监听。
- Steve 的选择:
- Steve 坦言他选择 HappyDOM 主要是因为名字有趣(“I pick HappyDOM cuz of the name”),但也指出它适合轻量测试。
- 工具选择建议:
- 如果测试涉及基本 DOM 操作,HappyDOM 足够且更高效。
- 如果遇到复杂边缘情况或需要更准确的模拟,JSDOM 是更好的选择。
- Steve 强调他不特别偏好哪个工具,开发者可以根据需求选择。
DOM 模拟的局限性
- 非真实浏览器:
- 模拟 DOM 并非真实浏览器环境,无法捕获浏览器特定的行为差异(如 Safari 或 Firefox 的渲染特性)。
- 它只提供符合规范的 DOM 实现,无法完全模拟特定浏览器的怪异行为或性能特性。
- 性能考虑:
- Steve 提到他的性能原则:“不做事情比做事情更快”(“not doing stuff is faster than doing stuff”)。
- 加载 DOM 模拟(如 JSDOM 或 HappyDOM)会增加测试运行开销,因为它需要模拟整个 DOM 环境。
- 不过,Steve 指出这种性能差异几乎不可察觉(“imperceptibly slower”),不会显著影响测试体验。
- 如果测试套件运行时间过长,可能会导致开发者减少运行测试的频率,因此性能仍需关注。
- 浏览器测试的替代方案:
- 虽然可以启动真实浏览器(如通过工具进行端到端测试)来测试代码,但启动浏览器、加载页面等操作耗时较长。
- Steve 举例:如果只是为了检查一个字符串是否变化,启动整个浏览器测试是不划算的。
- DOM 模拟工具适用于“中间地带”:测试内容超出了简单函数逻辑,但又不需要完整浏览器环境(如测试单个组件或事件监听器)。
配置 DOM 模拟环境(以 Vitest 为例)
- 环境配置:
- 在 Vitest 中,默认测试环境是 Node.js,可以通过配置更改为 DOM 模拟环境。
- 示例:将配置文件中的
environment字段从node改为happy-dom或jsdom,即可加载相应的 DOM 模拟库。// vitest.config.js export default { test: { environment: "happy-dom", // 或 'jsdom' }, }; - 更改后,所有测试都会在模拟 DOM 环境中运行,具备
document和window等 API。
- 其他配置选项:
- Globals:Vitest 默认需要显式导入
describe、expect和it,但可以通过配置globals: true使这些函数全局可用,类似 Jest 的默认行为。- Steve 提到在示例中启用了
globals,以减少 Jest 和 Vitest 之间的差异。
- Steve 提到在示例中启用了
- Test Setup Files:Vitest 支持测试设置文件,用于在测试前运行初始化代码(后续会讨论)。
- Globals:Vitest 默认需要显式导入
DOM 测试的优势
- 适用场景:
- DOM 模拟工具非常适合测试与 DOM 交互的代码,如修改 DOM 结构、添加事件监听器或检查组件渲染结果。
- Steve 强调这种方法适用于不需要完整应用环境但又涉及 DOM 操作的场景,例如测试单个组件或特定功能。
- 效率:
- 相比启动真实浏览器,DOM 模拟工具提供了更高效的测试方式,避免了加载整个页面或应用的开销。
- 开发者可以在 CI/CD 流程中快速运行测试,并观察测试状态变为绿色(“you sit there and you stare at it until all those statuses turn green”)。
核心要点
- Node.js 与 DOM 测试:
- Node.js 默认不提供 DOM 或浏览器 API,需通过模拟工具(如 JSDOM 或 HappyDOM)在测试中启用 DOM 环境。
- DOM 模拟工具:
- JSDOM:更重、更准确,适合复杂 DOM 测试。
- HappyDOM:更轻、更快,适合基本 DOM 测试。
- 选择工具时根据测试需求权衡精度与性能,Steve 个人偏好 HappyDOM(因名字有趣)。
- 局限性:
- 模拟 DOM 并非真实浏览器,无法捕获浏览器特定问题(如 Safari 渲染差异)。
- 加载 DOM 模拟会增加轻微性能开销,但通常不明显。
- 配置:
- 在 Vitest 中,通过配置
environment字段启用 DOM 模拟(happy-dom或jsdom)。 - 可选配置
globals使测试函数全局可用,类似 Jest。
- 在 Vitest 中,通过配置
- 适用性:
- DOM 模拟适用于测试 DOM 操作代码(如组件渲染、事件处理),效率高于启动真实浏览器,适合单元或集成测试。
- Steve 的哲学:
- 测试工具选择和配置应基于实际需求,不必过度纠结于工具差异。
- DOM 模拟工具在测试前端代码时提供了高效折衷方案,避免了完整浏览器测试的高开销。
13-testing-buttons
测试 DOM 按钮元素
- 背景:
- Speaker 1(Steve)讨论了一个简单的 DOM 测试示例,使用原生 JavaScript(vanilla JavaScript)创建一个按钮并对其进行测试。
- 示例位于
element factory目录下(最初命名为button factory,但后来扩展到其他元素)。 - 目标是通过测试验证按钮的创建、初始文本以及点击后的行为。
示例概述
- 按钮功能:
- 使用原生 JavaScript 创建一个按钮元素,初始文本为 "Click me"。
- 添加点击事件监听器,点击后将文本更改为 "Clicked!"。
- 将创建的 DOM 节点传递给测试用例进行验证。
- 测试环境:
- 使用 Vitest 作为测试框架,配置了
happy-dom作为 DOM 模拟环境(在vitest.config.js中设置)。 happy-dom模拟了浏览器 API(如document、window),使得在 Node.js 环境中测试 DOM 成为可能。- Steve 提到后续会涉及 Svelte 和 React 组件的测试,但当前专注于简单的 DOM 操作。
- 使用 Vitest 作为测试框架,配置了
测试内容
- 测试目标:
- 验证按钮元素是否正确创建(
it should create a button element)。 - 验证按钮初始文本是否为 "Click me"(
it should have the text click me)。 - 验证点击按钮后文本是否变为 "Clicked!"(
it should change the text to clicked! when clicked)。
- 验证按钮元素是否正确创建(
- 测试步骤:
- 创建按钮:
- 使用
createButton函数(未在文本中显示具体实现)创建一个按钮元素。 - 测试验证该元素是一个
HTMLButtonElement,并且标签名为BUTTON(大写)。test("should create a button element", () => { const button = createButton(); expect(button).toBeInstanceOf(HTMLButtonElement); expect(button.tagName).toBe("BUTTON"); });
- 使用
- 验证初始文本:
- 检查按钮的
textContent是否为 "Click me"。test("should have the text click me", () => { const button = createButton(); expect(button.textContent).toBe("Click me"); });
- 检查按钮的
- 验证点击行为:
- 创建按钮,模拟点击事件(使用
button.click()),然后验证文本是否变为 "Clicked!"。 - Steve 提到这是最简单(甚至是“愚蠢”)的实现方式,后续会改进。
test("should change the text to clicked! when clicked", () => { const button = createButton(); button.click(); // 模拟点击 expect(button.textContent).toBe("Clicked!"); });
- 创建按钮,模拟点击事件(使用
- 创建按钮:
- 测试运行:
- 使用
npm test运行所有测试,或使用npm test button仅运行与按钮相关的测试(例如alert button和button)。 - 测试通过后,Steve 庆祝“小胜利”(small wins),包括成功渲染 DOM 元素、验证属性、模拟交互并确认状态变化。
- 使用
DOM 模拟环境的重要性
- 问题与解决:
- Node.js 环境中默认没有 DOM API,如果没有配置 DOM 模拟(如
happy-dom),测试会失败,因为document等对象未定义。 - 示例中,Steve 提到如果删除
happy-dom配置,测试会因为找不到document而失败。 - DOM 模拟工具提供了规范兼容的浏览器 API(如
document、window、navigator、localStorage等),解决了这一问题。
- Node.js 环境中默认没有 DOM API,如果没有配置 DOM 模拟(如
- 配置确认:
- 在
vitest.config.js中设置environment: 'happy-dom',确保测试运行时加载 DOM 模拟环境。 - 如果测试因缺少 DOM API 而失败,检查是否正确配置了
happy-dom或jsdom。
- 在
关于 DOM 模拟工具的讨论
- 是否需要单独安装
happy-dom或jsdom?- 回答:需要(“Yes-ish”)。如果未安装,Vitest 会提示是否安装(如
happy-dom),用户可以选择同意后自动安装。 - 目的是避免同时安装不必要的工具(如
happy-dom和jsdom),节省资源。
- 回答:需要(“Yes-ish”)。如果未安装,Vitest 会提示是否安装(如
- 是否需要为 Vue 等框架设置额外的 DOM 模拟环境?
- 回答:不需要。
happy-dom或jsdom是框架无关的,适用于任何在 DOM 中运行的代码(如 React、Svelte、Vue、Angular)。 - Steve 提到当前配置(
happy-dom)适用于所有框架,唯一限制是示例中未包含 Vue 组件(因为他不熟悉 Vue),但测试原理同样适用。 - 测试设置文件(
test setup files)后续会讨论,当前未使用。
- 回答:不需要。
测试进展与反思
- 当前成果:
- 通过简单的测试,验证了按钮元素的创建、初始状态和点击后的行为。
- 这是迈向测试复杂前端应用(如 Vue、React、Svelte)的“baby steps”(小步骤)。
- 改进空间:
- Steve 指出当前点击测试的实现方式过于简单(“the stupidest, dumbest way”),后续会优化。
- 他警告不要过早采用这种粗糙方法,以免在实际项目中被“嘲笑”(ridiculed),并承诺后续会逐步改进测试代码。
- 目标:
- 当前测试虽简单,但为测试更复杂的 DOM 交互和框架组件奠定了基础。
- Steve 鼓励逐步构建测试技能,从基本 DOM 操作开始,逐步过渡到完整应用测试。
核心要点
- 测试内容:
- 使用原生 JavaScript 创建并测试按钮元素,验证其类型、初始文本和点击行为。
- 测试包括创建元素(Arrange)、模拟点击(Act)和断言结果(Assert)。
- DOM 模拟环境:
- 使用
happy-dom在 Node.js 中模拟 DOM API(如document),确保测试可运行。 - 未配置 DOM 模拟会导致测试失败,需在
vitest.config.js中正确设置environment。
- 使用
- 工具安装:
happy-dom或jsdom需要作为依赖安装,Vitest 会提示并支持自动安装。- DOM 模拟工具与框架无关,适用于 React、Svelte、Vue 等。
- 进展与展望:
- 当前完成了基本的 DOM 测试,验证了元素创建和交互,是测试复杂应用的第一步。
- Steve 指出测试方法有待改进,后续会优化点击事件测试等实现方式。
- Steve 的风格:
- 通过“小胜利”激励学习,强调从简单案例入手,逐步解决复杂问题。
- 幽默地提醒避免粗糙实现,鼓励持续改进测试代码。
14-using-testing-library-utilities
使用 Testing Library 进行 DOM 测试
- 背景:
- Steve Kinney 介绍了 Testing Library,一个用于辅助 DOM 测试的库,旨在提供更接近用户交互的测试方法。
- Testing Library 已在 Vitest 配置中启用(之前提到),并且与 Vitest 和 Jest 等测试框架高度兼容。
- 本节重点是如何利用 Testing Library 改进 DOM 测试,特别是针对按钮元素的交互测试。
Testing Library 简介
- 作用:
- Testing Library 提供了一组工具和辅助函数,帮助开发者在 DOM 中查询元素、模拟用户交互,并以更符合用户体验的方式编写测试。
- 它弥补了直接使用原生 DOM API(如
document.querySelector)或简单调用.click()方法的不足,提供更贴近真实用户行为的测试工具。
- 核心特点:
- 基于可访问性(Accessibility):Testing Library 强制开发者使用基于屏幕阅读器(screen reader)逻辑的查询方法,鼓励编写更具可访问性的代码和测试。
- 框架无关性:Testing Library 不仅适用于原生 DOM,还支持多种框架(如 React、Svelte、Vue),只需要引入对应的模块(例如
testing-library/react或testing-library/svelte)。- 原生 DOM:使用
testing-library/dom。 - React 组件:使用
testing-library/react。 - Svelte 组件:使用
testing-library/svelte。 - Vue 组件:使用
testing-library/vue。
- 原生 DOM:使用
- Steve 提到,不同框架的 Testing Library 版本差异很小,使用方式基本一致。
使用 Testing Library 查询 DOM 元素
- 引入
screen:- 从
testing-library/dom中引入screen对象,代表浏览器窗口(browser window),用于查询页面上的元素。 screen提供了多种查询方法(如getByRole、getByText),这些方法基于可访问性角色(ARIA roles)或文本内容查找元素。import { screen } from "@testing-library/dom";
- 从
- 将按钮挂载到页面:
- 在测试中,将按钮元素挂载到
document.body上,模拟真实页面渲染。 - Steve 使用
replaceChild()而非appendChild(),以避免多次测试时页面上累积多个按钮。document.body.replaceChild(button, document.body.firstChild); - 如果需要在每个测试中使用相同的按钮,可以将挂载逻辑放入
beforeEach钩子中,但 Steve 选择内联编写。
- 在测试中,将按钮元素挂载到
- 查询按钮:
- 使用
screen.getByRole('button', { name: 'Click me' })查询页面上的按钮。 getByRole基于 ARIA 角色查找元素,name参数指定按钮的文本内容或可访问性标签。- 如果页面上找不到符合条件的按钮,
getByRole会抛出错误,因此这一步本身就是一个隐式断言。const button = screen.getByRole("button", { name: "Click me" });
- 使用
- 断言:
- 确认查询到的按钮存在于页面中,可以直接使用
expect(button).toBeInTheDocument()(Steve 提到也可以用screen对象)。 - 这验证了按钮是否正确挂载到 DOM 中。
- 确认查询到的按钮存在于页面中,可以直接使用
改进点击交互测试
- 问题:
- 之前的测试中使用
button.click()直接调用点击方法,这种方式过于简单,无法模拟真实用户交互(如鼠标悬停、点击等一系列事件)。
- 之前的测试中使用
- 解决方案 1:使用
fireEvent:- 从
testing-library/dom引入fireEvent,用于模拟 DOM 事件。 - 使用
fireEvent.click(button)触发点击事件,比直接调用.click()更接近浏览器行为,因为它会触发一个真实的 DOM 事件。import { fireEvent } from "@testing-library/dom"; // ... fireEvent.click(button);
- 从
- 解决方案 2:使用
user-event:- Steve 提到还有一个更高级的库
user-event,它是 Testing Library 的配套工具,专门用于模拟用户交互。 user-event会模拟真实用户行为,包括鼠标悬停(mouseenter)、点击等一系列事件,比fireEvent更真实。- 使用
user-event需要将测试函数设为异步(async),因为它返回 Promise。import userEvent from "@testing-library/user-event"; // ... await userEvent.click(button); - Steve 指出,对于简单测试(如本例),
fireEvent和user-event的结果可能相同,但对于复杂交互,user-event更适合。
- Steve 提到还有一个更高级的库
- 清理 DOM:
- 在测试间清理 DOM 内容,以避免多个按钮累积在页面上。
- Steve 提到对于 React 等框架,Testing Library 会自动清理 DOM,但对于原生 DOM 测试可能需要手动清理(例如清空
document.body.innerHTML)。 - 示例中未明确实现清理,但 Steve 表示会处理这一问题。
测试流程:Arrange-Act-Assert
- Arrange(准备):
- 将按钮挂载到页面(
document.body),模拟真实渲染。
- 将按钮挂载到页面(
- Act(操作):
- 使用
screen.getByRole查询按钮,确保其正确挂载。 - 使用
fireEvent.click或userEvent.click模拟点击。
- 使用
- Assert(断言):
- 验证点击后按钮文本是否变为预期值(如 "Clicked!")。
- 可以再次查询按钮(例如
screen.getByRole('button', { name: 'Clicked!' })),以确保状态变化。
Testing Library 的优势
- 基于用户视角:
- Testing Library 鼓励从用户或屏幕阅读器的视角编写测试,关注页面上的内容和角色,而非具体的 DOM 结构。
- 这有助于开发者编写更具可访问性的代码。
- 模拟真实交互:
- 通过
fireEvent和user-event,Testing Library 提供了比直接调用 DOM 方法(如.click())更真实的交互模拟。 user-event尤其适合复杂交互场景,模拟用户完整行为。
- 通过
- 框架兼容性:
- Testing Library 支持多种框架,只需引入对应模块即可,测试方法基本一致,降低了学习成本。
当前进展与展望
- 当前成果:
- 使用 Testing Library 改进了按钮测试,从简单的
.click()升级到fireEvent和user-event,更接近真实用户行为。 - 通过
screen.getByRole查询元素,确保按钮正确挂载,并基于可访问性角色验证。
- 使用 Testing Library 改进了按钮测试,从简单的
- 未来改进:
- Steve 提到后续会通过更多示例(如 React、Svelte 组件)进一步展示 Testing Library 的用法,强化测试技能。
- 当前测试虽然简单,但为更复杂的 DOM 和组件测试奠定了基础。
核心要点
- Testing Library 简介:
- 一个用于 DOM 和组件测试的库,提供查询和交互辅助工具,强调可访问性和用户视角。
- 支持原生 DOM(
testing-library/dom)及多种框架(如 React、Svelte、Vue)。
- 查询元素:
- 使用
screen.getByRole等方法基于角色和文本查询元素,确保测试关注用户体验。 - 查询本身可作为隐式断言,若元素不存在则抛出错误。
- 使用
- 模拟交互:
fireEvent.click模拟 DOM 事件,比直接.click()更接近浏览器行为。user-event.click进一步模拟用户完整交互(如鼠标悬停+点击),需异步调用。
- 测试流程:
- 遵循 Arrange-Act-Assert 模式:挂载元素、查询并操作、验证结果。
- 优势:
- 鼓励编写可访问性代码,模拟真实交互,支持多框架,降低测试复杂性。
- Steve 的教学风格:
- 通过逐步改进测试代码(如从
.click()到user-event),展示工具优势和最佳实践。 - 强调从简单案例入手,逐步扩展到复杂场景,鼓励持续学习和实践。
- 通过逐步改进测试代码(如从
15-exploring-the-accident-counter-project
探索事故计数器项目(Accident Counter Project)
- 背景:
- Steve Kinney 介绍了一个名为“Accident Counter”的项目,这是一个简单的计数器应用,用于记录自上次 JavaScript 相关事故以来的天数。
- 该项目旨在通过一个具体的 React 组件示例,展示如何使用 Testing Library 进行测试,强调可访问性查询和测试的最佳实践。
- Steve 还推荐了一些工具(如 Chrome 扩展 Testing Playground),以帮助开发者找到合适的元素选择器。
项目概述
- 功能描述:
- 应用包含一个计数器,显示自上次事故以来的天数(初始值为 0)。
- 提供三个按钮:Increment(增加天数)、Decrement(减少天数)、Reset(重置为 0)。
- 当计数为 0 时,Decrement 和 Reset 按钮被禁用(disabled)。
- 页面标题会随计数变化而更新,显示天数(包括单复数形式“day/days”的正确处理)。
- 运行项目:
- 使用
npm start启动项目,可以在浏览器中查看和交互。 - Steve 强调通过实际运行项目来理解测试目标,比仅在终端中查看代码更直观。
- 使用
- 技术栈:
- 该项目使用 React 实现,但 Steve 指出测试原理适用于任何框架(如 Svelte、Vue),无需深入了解 React 本身。
- 测试使用
testing-library/react,与原生 DOM 测试(testing-library/dom)略有不同,主要是提供了render方法来渲染 React 组件。
测试设置与框架无关性
- Testing Library 的框架支持:
- React:使用
testing-library/react,提供render方法渲染 JSX 组件。 - Svelte:使用
testing-library/svelte,render方法接受类名和 props。 - Vue:使用
testing-library/vue,同样提供类似render方法。 - Steve 强调,Testing Library 的核心测试方法不依赖具体框架,测试逻辑(如查询、断言)基本一致。
- React:使用
- 测试文件:
- 测试文件位于项目中,Steve 展示如何为“Accident Counter”编写测试。
- 重点是测试初始状态(计数为 0)以及按钮的禁用状态。
测试用例 1:计数器初始值为 0
-
目标:
- 验证计数器组件渲染后,初始计数显示为 0。
-
实现:
-
使用
render方法渲染计数器组件(React 组件)。 -
使用
screen.getByTestId('counter-count')查询计数显示元素。data-testid是一个自定义属性,用于标识测试中的元素,特别适用于没有明显角色或文本的元素。- Steve 解释,
data-testid比使用 CSS 类(class)或普通 ID 更适合测试,因为它不会因样式或设计变更而受影响。
-
使用扩展的匹配器(如
toHaveTextContent)断言文本内容为 "0"。import { render, screen } from "@testing-library/react"; import Counter from "./Counter"; // 假设组件文件路径 test("counter renders with an initial count of 0", () => { render(<Counter />); const count = screen.getByTestId("counter-count"); expect(count).toHaveTextContent("0"); });
-
-
扩展匹配器:
- Steve 提到测试设置文件中添加了额外的
expect匹配器(如toHaveAttribute、toHaveClass、toHaveFocus、toHaveFormValue、toHaveTextContent)。 - 这些匹配器简化了 DOM 元素的属性和状态验证。
- Steve 提到测试设置文件中添加了额外的
测试用例 2:初始状态下禁用 Decrement 和 Reset 按钮
- 目标:
- 验证当计数为 0 时,Decrement 和 Reset 按钮被禁用。
- 实现:
- 使用
screen.getByRole('button', { name: /decrement/i })查询 Decrement 按钮。- 使用正则表达式
/decrement/i(不区分大小写),以避免因文本大小写变化导致测试失败。 - Steve 提到这是常见模式,增加测试健壮性。
- 使用正则表达式
- 同样查询 Reset 按钮:
screen.getByRole('button', { name: /reset/i })。 - 使用
expect().toBeDisabled()断言按钮被禁用。test("disables the decrement and reset buttons when the count is 0", () => { render(<Counter />); const decrementButton = screen.getByRole("button", { name: /decrement/i, }); const resetButton = screen.getByRole("button", { name: /reset/i }); expect(decrementButton).toBeDisabled(); expect(resetButton).toBeDisabled(); });
- 使用
- 查询工具支持:
- 如果忘记
getByRole语法,可以使用 Testing Playground 扩展查看建议的选择器。 - Steve 鼓励依赖工具来简化查询过程。
- 如果忘记
测试与组件无关性
- 组件无关:
- Steve 强调,测试者无需深入了解组件实现细节(如 React 代码)。
- 测试关注的是组件的行为和用户界面(UI),如按钮是否禁用、计数是否正确显示。
- 组件可以是应用中的一个小部件,也可以是整个应用,测试逻辑不变(不过他建议测试较小的组件,以便后续浏览器测试更高效)。
- 可访问性查询的优势:
- 使用
screen和可访问性查询(如getByRole)比直接用document.querySelector更好,因为它强制开发者关注用户体验和可访问性。 - Steve 提到,如果不喜欢这种方式,可以使用
document.querySelector,但他推荐 Testing Library 的方法,因为它在可访问性方面有额外好处。
- 使用
测试流程与核心理念
- 测试流程:
- Arrange(准备):使用
render渲染组件(在beforeEach或测试中)。 - Act(操作):使用
screen查询元素(隐式验证元素存在)。 - Assert(断言):使用匹配器验证元素状态(如文本内容、是否禁用)。
- 测试逻辑与之前的简单示例(如加减函数)类似,只是增加了渲染和查询的步骤。
- Arrange(准备):使用
- 核心理念:
- 测试不应依赖框架,Testing Library 提供一致的 API(如
render、screen)。 - 使用
data-testid标识测试元素,避免因样式或设计变化导致测试失败。 - 可访问性查询(如
getByRole)不仅帮助测试,还提升代码质量。 - 测试关注用户可见的行为,而非内部实现。
- 测试不应依赖框架,Testing Library 提供一致的 API(如
当前进展与反思
- 成果:
- 成功编写了两个测试用例:验证初始计数为 0 以及按钮禁用状态。
- 使用了 Testing Library 的
render和screen,结合data-testid和可访问性查询。 - 引入了工具(如 Testing Playground)简化选择器查找。
- 反思:
- 测试逻辑与之前的简单 DOM 测试(如按钮点击)基本一致,只是增加了框架特定的渲染步骤。
- Steve 强调从小组件测试开始,为后续更复杂的浏览器测试(如端到端测试)奠定基础。
核心要点
- 项目简介:
- “Accident Counter”是一个 React 计数器应用,测试其初始状态和按钮行为。
- 使用
testing-library/react渲染组件并测试。
- 测试工具:
- Testing Playground 扩展帮助找到最佳可访问性选择器,简化测试编写。
- 提供在线工具,粘贴 HTML 即可获取选择器建议。
- 测试实现:
- 测试初始计数为 0,使用
data-testid查询计数元素,断言文本内容。 - 测试按钮禁用状态,使用
getByRole查询按钮,结合正则表达式增加健壮性。
- 测试初始计数为 0,使用
- 框架无关性:
- Testing Library 支持多种框架,测试方法一致,关注用户行为而非实现细节。
- 可访问性查询:
- 使用
screen和getByRole等方法,强制关注用户体验,提升代码可访问性。 - 提供额外匹配器(如
toBeDisabled、toHaveTextContent)简化断言。
- 使用
- 测试理念:
- 测试应从小组件开始,遵循 Arrange-Act-Assert 模式。
- 使用
data-testid避免测试依赖样式或设计变化。
- Steve 的教学风格:
- 通过具体项目和工具展示测试方法,强调实践和用户视角。
- 鼓励使用工具简化学习曲线,逐步从简单测试过渡到复杂场景。
16-accident-counter-exercise
事故计数器练习(Accident Counter Exercise)
- 背景:
- Steve Kinney 引导参与者完成“Accident Counter”项目的测试练习,目标是通过编写多个测试用例来熟悉 Testing Library 的使用。
- 重点是测试计数器组件的各种行为,如初始显示、按钮交互等,同时解决可能遇到的难点(如异步操作和 DOM 渲染时机)。
- 本节采用实践驱动的方式,鼓励参与者尝试编写测试,并通过讨论解决遇到的痛点。
练习目标与方法
- 目标:
- 完成尽可能多的测试用例,覆盖计数器组件的不同功能。
- 识别测试中的难点和易点,为后续深入讲解提供反馈。
- 方法:
- 使用 Testing Library(
testing-library/react)和user-event库进行测试。 - 记住
user-event需要异步操作(async/await),因为它模拟真实用户交互并返回 Promise。 - 参与者可以自由选择停止点,尝试几个测试后与小组讨论,分享成功和困难的地方。
- Steve 强调这是首次尝试,无压力,重点是发现问题并针对性解决。
- 使用 Testing Library(
测试用例 1:当计数为 0 时显示“days”(复数)
-
目标:
- 验证计数器初始值为 0 时,显示单位为“days”(复数形式)。
-
实现:
-
使用
data-testid属性标识单位文本元素(Steve 命名为counter-unit),以便查询。 -
虽然 Testing Playground 扩展建议使用
getByText('days'),但 Steve 指出这种方式在页面上可能有多个“days”文本时会产生歧义,因此选择更具体的data-testid。 -
使用
screen.getByTestId('counter-unit')查询单位元素,并断言其文本内容为“days”。import { render, screen } from "@testing-library/react"; import Counter from "./Counter"; test("displays days plural when the count is zero", () => { render(<Counter />); const unit = screen.getByTestId("counter-unit"); expect(unit).toHaveTextContent("days"); }); -
测试通过,验证了初始状态下单位文本正确显示为复数。
-
-
反思:
- Steve 强调选择具体标识(如
data-testid)的重要性,避免因页面内容重复导致测试不稳定。 - 这种简单测试与之前示例类似,主要是查询和断言静态内容。
- Steve 强调选择具体标识(如
测试用例 2:点击 Increment 按钮时增加计数
-
目标:
- 验证点击 Increment 按钮后,计数器值从 0 增加到 1。
-
实现:
-
查询 Increment 按钮,使用
screen.getByRole('button', { name: /increment/i })。- 使用正则表达式
/increment/i(不区分大小写),增加测试健壮性,避免因按钮文本大小写变化导致失败。 - Steve 提到虽然文档中推荐正则表达式,但他个人有时直接使用字符串,但建议根据经验选择更适合的方式。
- 使用正则表达式
-
查询计数显示元素,使用
screen.getByTestId('counter-count')。 -
使用
userEvent.click(incrementButton)模拟点击按钮,注意需要await因为user-event是异步的。 -
断言计数文本内容变为 "1"。
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import Counter from "./Counter"; test("increments the count when the increment button is clicked", async () => { render(<Counter />); const incrementButton = screen.getByRole("button", { name: /increment/i, }); const count = screen.getByTestId("counter-count"); await userEvent.click(incrementButton); expect(count).toHaveTextContent("1"); }); -
测试通过,验证了点击按钮后计数增加。
-
-
代码组织讨论:
- Steve 提到可以在
beforeEach钩子中设置通用查询(如按钮和计数元素),以减少重复代码。 - 然而,他也指出过度优化可能导致测试可读性下降,建议根据实际情况权衡。
- 他强调测试代码应保持清晰,尤其是在测试失败时便于调试,因此有时在每个测试中显式查询元素更直观。
- Steve 坦言自己对
beforeEach的使用也不完全确定,提醒参与者工程中充满权衡,没有绝对规则。
- Steve 提到可以在
异步与 DOM 渲染时机问题
- 潜在问题:
- 当前测试中,点击按钮后计数立即更新,测试通过。
- 但 Steve 提出一个常见问题:如果操作涉及异步行为(如 API 请求)或框架渲染延迟(如 React 状态更新后的 DOM 重渲染),测试可能在 DOM 更新前就进行断言,导致失败。
- 示例场景:用户点击提交按钮,触发 API 请求并渲染结果,若测试在结果渲染前断言,会找不到预期内容。
- 解决方案:使用
act:- 对于框架(如 React),Testing Library 提供了
act工具,强制等待 DOM 重新渲染完成后再进行断言。 act接受一个异步函数,执行其中的操作(如点击按钮),并等待框架完成状态更新和 DOM 重渲染。import { act } from "react-dom/test-utils"; // 或 testing-library/react 提供的类似工具 // ... test("increments the count when the increment button is clicked", async () => { render(<Counter />); const incrementButton = screen.getByRole("button", { name: /increment/i, }); const count = screen.getByTestId("counter-count"); await act(async () => { await userEvent.click(incrementButton); }); expect(count).toHaveTextContent("1"); });- Steve 提到当前测试无需
act(因为更新是同步的),但展示这一方法是为了预防参与者在真实项目中遇到“时机问题”(timing issues)。
- 对于框架(如 React),Testing Library 提供了
- 其他解决方法:
- Steve 提到后续会展示更多处理异步渲染的方法(如
waitFor),当前仅介绍act作为初步解决方案。 - 他强调如果参与者在实际应用中遇到测试失败,异步时机很可能是首要问题,
act或类似工具可以解决。
- Steve 提到后续会展示更多处理异步渲染的方法(如
测试流程与理念
- 测试流程(Arrange-Act-Assert):
- Arrange(准备):使用
render渲染组件。 - Act(操作):查询元素并执行交互(如
userEvent.click)。 - Assert(断言):验证结果(如文本内容是否更新)。
- Steve 提到“Act”是测试中执行操作的阶段,与之前讨论的三步模式一致。
- Arrange(准备):使用
- 测试理念:
- 测试应清晰易读,便于调试,代码组织(如是否使用
beforeEach)需权衡可读性和复用性。 - 使用
user-event模拟真实用户交互,确保测试接近用户行为。 - 针对异步操作或框架渲染,需考虑时机问题,使用
act或其他工具等待 DOM 稳定。
- 测试应清晰易读,便于调试,代码组织(如是否使用
当前进展与反思
- 成果:
- 完成了两个测试用例:验证初始单位文本为“days”和点击 Increment 按钮后计数增加。
- 使用了
data-testid和getByRole查询元素,结合user-event模拟交互。 - 讨论了代码组织和异步时机的潜在问题,提供了预防性解决方案。
- 反思:
- 简单测试(如文本内容验证)相对直观,与之前示例类似。
- 交互测试引入了异步操作(
user-event),并可能遇到 DOM 渲染时机问题,需要额外工具(如act)。 - Steve 鼓励参与者尝试更多测试用例,通过实践发现问题,并承诺针对难点深入讲解。
核心要点
- 练习目标:
- 通过编写“Accident Counter”测试用例,熟悉 Testing Library 和
user-event的使用。 - 识别测试中的易点和难点,与小组讨论并解决。
- 通过编写“Accident Counter”测试用例,熟悉 Testing Library 和
- 测试实现:
- 测试单位文本显示为“days”,使用
data-testid避免歧义。 - 测试 Increment 按钮点击,使用
user-event.click模拟交互,断言计数增加。
- 测试单位文本显示为“days”,使用
- 代码组织:
- 可在
beforeEach中复用查询逻辑,但需权衡测试可读性。 - 测试应保持清晰,便于失败时调试,工程中充满权衡。
- 可在
- 异步与时机问题:
- 异步操作(如 API 请求)或框架渲染可能导致测试在 DOM 更新前失败。
- 使用
act等待 DOM 重新渲染,确保断言时状态稳定。
- 测试理念:
- 遵循 Arrange-Act-Assert 模式,使用
user-event模拟真实交互。 - 测试应关注用户行为,考虑异步时机对结果的影响。
- 遵循 Arrange-Act-Assert 模式,使用
- Steve 的教学风格:
- 鼓励实践驱动学习,通过具体测试用例展示方法和问题。
- 提供预防性解决方案(如
act),帮助参与者避免常见陷阱。 - 强调权衡和清晰性,承认工程中无绝对规则,需根据经验调整。
17-accident-counter-solution
主要内容概述
- 主题:为计数器组件编写单元测试,使用 React Testing Library 和
userEvent模拟用户交互。 - 测试内容:
- 计数器显示“day”或“days”的逻辑(单数/复数形式)。
- 递增(
increment)和递减(decrement)按钮的功能。 - 初始计数(
initialCount)属性的设置及其影响。 - 禁用按钮(
disabled)的行为验证。 - 页面标题(
document.title)随计数变化的更新。
- 讨论重点:测试策略、测试用例设计、测试覆盖率与可读性的平衡。
详细笔记
1. 测试单数形式“day”
- 目标:验证计数器在计数为 1 时显示“day”。
- 实现:
- 使用
userEvent.click模拟用户点击递增按钮(incrementButton)。 - 断言计数器文本内容为“day”。
- 使用
- 工具特性:
userEvent支持多种用户交互模拟(如点击、键盘事件、复制粘贴等),比直接触发事件(如fireEvent)更贴近真实用户行为。 - 注意点:
- 测试应尽可能模拟真实用户行为。
- 使用
await确保异步操作(如点击)完成。 - 测试需具备韧性(resilient),以应对代码中的已知变化。
2. 递减按钮测试与哲学思考
- 问题:递减按钮在计数为 0 时被禁用,如何测试递减功能?
- 两种解决方案:
- 先递增计数(通过点击递增按钮),再递减计数。
- 设置初始计数属性(
initialCount)为 1,直接测试递减功能。
- 最终选择:使用
initialCount属性,默认值为 0。 - 讨论:
beforeEach钩子可能限制测试灵活性,因为不同测试可能需要不同的初始状态。- 测试通过后,可放心重构代码(如设置默认值),测试提供了保障。
- 测试是“对未来的投资”,尽管过程繁琐,但对后续维护有价值。
- 关键点:
- 测试设计需权衡:是通过交互修改状态,还是通过属性设置初始状态。
- 避免过度依赖
beforeEach,以支持多样化的测试场景。
3. 验证递减按钮状态
- 目标:验证计数为 1 时,递减按钮不应被禁用。
- 实现:
- 使用
screen.getByRole获取递减按钮(匹配名称/decrement/i)。 - 断言按钮状态为
not.toBeDisabled。
- 使用
- 调试:测试失败时,发现问题在于按钮名称错误(应为
decrementButton而非decrement)。 - 关键点:
- 确保按钮状态与计数状态一致(计数为 0 时禁用,非 0 时启用)。
- 测试失败后可定位问题并修复,随后可放心重构代码。
4. 测试递减功能
- 目标:验证点击递减按钮时计数减少。
- 实现:
- 设置
initialCount为 1,渲染组件。 - 使用
userEvent.click模拟点击递减按钮。 - 断言计数减少到 0。
- 设置
- 额外测试建议:
- 验证初始计数是否正确显示在屏幕上。
- 验证计数为 1 时,递减和重置按钮是否启用。
- 验证计数回到 0 时,按钮是否再次禁用。
- 关键点:
- 养成使用
act包裹交互操作的习惯,以避免未来可能的测试问题。 - 将多个断言拆分为小测试,便于失败时快速定位问题。
- 养成使用
5. 测试覆盖率与测试设计权衡
- 讨论:
- 可以将多个断言合并到一个测试中(如设置计数为 1,验证按钮启用,点击递减,验证计数为 0,验证按钮禁用),以获得较高覆盖率。
- 但合并测试可能导致失败时难以定位具体问题,建议拆分为小测试。
- 与 Playwright 等端到端测试工具不同,单元测试(如 React Testing Library)更适合拆分小测试,而端到端测试因浏览器启动成本高,倾向于完整流程测试。
- 关键点:
- 测试设计没有绝对规则,需根据具体场景权衡覆盖率与可读性。
- 测试目标是为开发者提供便利和信心。
6. 测试禁用按钮点击行为
- 目标:验证点击禁用状态的递减按钮时,计数不变且无异常。
- 实现:
- 计数为 0 时,获取递减按钮(已知为禁用状态)。
- 使用
userEvent.click模拟点击。 - 断言计数仍为 0(不变为负数)。
- 关键点:
- 即使按钮禁用,也应测试点击行为,确保不会引发错误。
- 测试库并非唯一工具,若需要,可使用原生方法(如
document.querySelector)。
7. 测试页面标题更新
- 目标:验证计数变化时页面标题(
document.title)更新为相应值(如“1 day”)。 - 实现:
- 渲染组件,获取递增按钮。
- 使用
userEvent.click模拟点击递增按钮。 - 断言
document.title包含“1 day”(可点击多次验证“2 days”等)。
- 调试:测试失败因未重新渲染组件,需确保组件渲染后再测试。
- 技巧:
- 可使用部分匹配(如检查标题是否包含“1 day”),以适应标题格式变化。
- 可将查询范围限定到特定组件(而非整个
screen),提高测试精度(如getByRole限定作用域)。
- 关键点:
- 测试应覆盖组件的副作用(如标题更新)。
- 作用域限定有助于处理页面中多个相似元素的情况。
总结与核心理念
- 测试目标:模拟真实用户行为,验证组件功能,保障代码重构安全。
- 工具使用:优先使用
userEvent模拟交互,使用screen查询元素,必要时可结合原生 DOM 方法。 - 设计权衡:
- 初始状态:通过交互还是属性设置,需根据测试需求选择。
- 测试粒度:拆分小测试便于定位问题,但合并测试可提高覆盖率。
- 覆盖范围:不仅测试核心功能,也需覆盖边缘情况(如禁用按钮点击)和副作用(如标题更新)。
- 哲学思考:测试是为未来自己投资,规则并非绝对,需根据场景和个人习惯选择最适合的策略。
18-searching-dom
主要内容概述
- 主题:介绍 React Testing Library 中用于查询 DOM 元素的各种选择器和方法。
- 重点:
- 不同类型的选择器(如
getBy,getAllBy,findBy,findAllBy)及其功能。 - 选择器的适用场景(如基于标签、占位符、角色、测试 ID 等)。
get和find方法的区别(同步与异步)。
- 不同类型的选择器(如
- 目标:帮助开发者根据测试需求选择合适的查询方法,避免测试因时机问题失败。
详细笔记
1. 常见选择器类型
- 选择器种类:
findByAltText:用于查找带有特定alt文本的图像,非常适合测试图片元素。findByLabelText:用于查找与特定标签(label)关联的输入框(input)。例如,若输入框有标签“Street Address”,可以通过标签文本找到对应的输入框,而无需知道输入框的testId。findByPlaceholderText:通过输入框的占位符文本查找元素,但不推荐过度依赖,因为对于使用辅助设备(如屏幕阅读器)的用户,占位符可能不够友好。建议使用屏幕阅读器专用的隐藏标签(screen-reader-only label)。
- 注意点:
- 优先考虑可访问性(accessibility),选择对辅助设备友好的方法。
findByPlaceholderText使用时需谨慎,可能不适用于所有用户场景。
2. get 与 getAll 的区别
- 功能区分:
getBy*:查询单个匹配元素。如果找到多个匹配项,会抛出错误(有助于发现意外的重复元素)。getAllBy*:查询所有匹配元素,返回一个数组。
- 查询依据:
LabelText:基于标签文本。PlaceholderText:基于占位符文本。Role:基于元素的角色(如button)。TestId:基于测试 ID。Text:基于文本内容。Title:基于标题属性。
- 关键点:
getBy*确保唯一性,若有多个匹配项会报错,有助于调试。- 根据测试需求选择
getBy*或getAllBy*。
3. get 与 find 的区别(同步与异步)
- get 方法:
- 特点:同步操作,立即从 DOM 中查找元素。
- 适用场景:当确信元素已经在页面上时使用(如组件刚挂载)。
- 缺点:如果元素未立即出现在页面上,测试会立即失败。
- find 方法:
- 特点:异步操作,会在一段时间内持续尝试查找元素,直到超时(默认超时约 300 毫秒,可配置)。
- 适用场景:适用于元素可能因网络请求、动画等原因延迟出现的情况。
find会等待元素出现在页面上。 - 行为:返回一个 Promise,成功时解析为找到的元素,超时后未找到则拒绝(测试失败)。
- 关键点:
- 使用
find可以避免因“时间空间连续体”(timing issues)导致的测试失败。 - 如果元素确定不会出现,
find会因等待而减慢测试速度,需根据场景选择合适的工具。
- 使用
4. 使用建议与权衡
- 选择依据:
- 如果元素已知在页面上,使用
get方法(更快)。 - 如果元素可能延迟出现(如等待网络请求或动画完成),使用
find方法。
- 如果元素已知在页面上,使用
- 注意事项:
- 过度使用
find可能因等待超时而拖慢测试速度。 - 合理配置
find的超时时间(通常使用默认值即可)。
- 过度使用
- 关键点:
- 根据测试的具体需求选择合适的查询方法,避免不必要的等待或立即失败。
总结与核心理念
- 选择器多样性:React Testing Library 提供了多种查询方法(如
getBy,getAllBy,findBy,findAllBy),基于不同属性(如标签、占位符、角色、文本等)查找元素。 - 同步与异步:
get是同步的,适合元素已存在的情况;find是异步的,适合处理可能延迟出现的元素。 - 可访问性优先:选择查询方法时,考虑对辅助设备(如屏幕阅读器)的友好性,避免依赖不友好的属性(如占位符)。
- 权衡与实践:根据测试场景选择合适的工具,避免因时机问题导致测试失败,同时注意测试效率。
19-test-doubles
主要内容概述
- 主题:介绍测试替身(Test Doubles),这是测试中用于模拟外部依赖的广义概念,包含 mocks、spies 和 stubs 等。
- 核心内容:
- 测试替身的定义和目的:隔离测试,避免外部依赖(如 API、时间、第三方库)对测试的干扰。
- 测试替身的类型(如 mock、spy)及其功能。
- 使用测试替身时的注意事项和权衡。
- 测试替身的管理方法(如 restore、reset、clear)及现代测试环境中的隔离特性。
- 目标:帮助开发者理解测试替身的作用,合理使用它们以提高测试可靠性,同时避免过度依赖。
详细笔记
1. 测试替身(Test Doubles)的概念与重要性
- 定义:测试替身是一个广义术语,涵盖了 mocks、spies、stubs 等,用于在测试中模拟或替换真实的对象或功能。
- 目的:测试应在隔离环境中进行,避免外部依赖(如 API 调用、时间、第三方库)的影响,确保测试结果仅反映被测代码的行为。
- 注意:术语(如 mock 和 spy)的具体差异不重要,关键是理解其核心概念和应用场景。讲者强调不会考查词汇,重点在于理解。
2. 测试替身的必要性与应用场景
- 测试挑战:
- 应用常涉及外部依赖(如 API 调用、时间、console.log、fetch、第三方库)。
- 测试不应验证他人代码,而是确保自身代码变更后仍能正常工作。
- 测试替身的作用:
- 模拟外部依赖,控制测试环境,验证特定行为(如是否调用了
alert或fetch,以及调用的参数是否正确)。 - 处理副作用(side effects):如 DOM 修改、日志输出等无法通过返回值直接验证的行为。
- 模拟外部依赖,控制测试环境,验证特定行为(如是否调用了
- 典型场景:
- 随机值(如 ID 生成器、掷骰子):用测试替身固定随机结果,确保测试可重复。
- 网络请求:模拟请求结果,避免真实调用 API。
- 警告:过度使用测试替身可能导致测试失去意义,需谨慎使用(“能力越大,责任越大”)。
3. 测试替身的类型
- Mock:伪造外部依赖,模拟其行为(如模拟 fetch 请求的返回结果)。
- Spy:监视函数调用,记录调用情况(如参数、次数),适用于无法通过返回值验证行为的情况(如调用内置函数或库函数)。
- 注意:
- 讲者提到依赖注入(dependency injection)是更好的替代方案,应优先考虑。
- 测试替身应在确实需要时使用,避免不必要的复杂性。
4. 测试替身的管理与清理
- 管理方法:
- Restore:将模拟的函数恢复为原始实现,同时包含 reset 和 clear 的功能。讲者推荐默认使用 restore,因为它最全面。
- Reset:保留模拟实现,但重置其状态(如调用记录)。
- Clear:清除记录的调用数据,但保留模拟实现。
- 建议:
- 如果不确定使用哪种方法,直接用
restore,它能满足大部分需求。 - 可在每个测试前设置模拟(
beforeEach),测试后恢复原状,确保测试隔离。
- 如果不确定使用哪种方法,直接用
- 历史背景:
- 早期测试运行在同一进程中,未清理的模拟可能污染后续测试。
- 现代测试框架(如 Vitest、Jest)提供测试隔离,每个测试在独立环境中运行,减少污染风险。
5. 使用测试替身的权衡与注意事项
- 权衡:
- 真实性 vs 隔离:模拟网络请求或后端环境能提高测试控制,但可能降低真实性;直接使用真实后端则可能因外部变化(如 API 变更)导致测试失败。
- 测试速度:避免启动服务器等复杂操作,模拟可以加快测试速度,但过度等待模拟结果可能拖慢测试。
- 问题与挑战:
- 测试可能因非代码问题(如后端 API 变更、CSS 调整)失败,需权衡隔离与真实性。
- 讲者强调“视情况而定”(it depends),没有绝对答案,需根据具体场景选择。
- 建议:
- 如果能快速搭建并重置后端环境,直接使用真实环境更好。
- 测试替身适用于无法控制外部依赖或需快速测试的场景。
6. 测试替身的底层实现与历史
- 底层库:
- 现代测试框架(如 Vitest、Jest)基于老牌库:
- Sinon:用于模拟和监视的库,已有十年历史。
- Chai:断言库,同样历史悠久。
- Jasmine:测试运行器,历史超过 15 年。
- 现代测试框架(如 Vitest、Jest)基于老牌库:
- 意义:
- 这些库是现代框架的基础,核心概念未变,差异不重要。
- 如果代码库中使用 Sinon,相关知识直接适用。
总结与核心理念
- 测试替身的作用:通过模拟外部依赖(如 API、时间、第三方库),实现测试隔离,确保测试结果仅反映被测代码的行为。
- 类型与功能:包括 mock(伪造行为)、spy(监视调用)等,具体术语差异不重要,重点是场景应用。
- 使用原则:
- 谨慎使用,避免过度模拟导致测试无效。
- 优先考虑依赖注入等替代方案。
- 测试后使用
restore恢复原状,确保隔离。
- 权衡与实践:
- 需在测试真实性、速度和隔离间平衡。
- 现代测试框架提供隔离环境,减少模拟清理的复杂性。
- 历史背景:测试替身概念和技术基于老牌库(如 Sinon、Chai),核心理念稳定,学习成本可控。
20-spies
主要内容概述
- 主题:介绍测试替身中的 Spy,一种用于监视函数调用而不改变其行为的工具。
- 核心内容:
- Spy 的定义与作用:监控现有函数的调用情况,记录调用次数和参数等信息。
- Spy 与 Mock 的区别:Spy 不替换原函数,而是在其上添加监视功能。
- Spy 的实际应用场景:验证函数是否被调用、调用参数是否正确、调用次数是否符合预期。
- 目标:帮助开发者理解 Spy 的使用场景和操作方法,以验证代码中的副作用(side effects)。
详细笔记
1. Spy 的概念与 Mock 的区别
- Spy 的定义:
- Spy 是一种测试替身,用于监视现有函数的调用情况,而不改变其原有行为。
- 它在原函数上“包裹”一层监视功能,记录调用信息(如调用次数、参数)。
- 与 Mock 的区别:
- Mock:替换原函数,作为占位符,提供自定义行为。
- Spy:保留原函数行为,仅添加监视能力,用于后续检查。
- 讲者观点:理论讲解不如实践直观,接下来通过代码示例展示 Spy 的用法。
2. Spy 的代码实现与基本用法
- 工具与设置:
- 使用
vi.spyOn(Vitest 中的方法)创建 Spy。 - 如果未启用全局变量(globals),需手动导入
vi;讲者建议导入以获得更好的 IntelliSense 支持。
- 使用
- 示例:监视
console.log:- 代码:
const logSpy = vi.spyOn(console, 'log'); - 作用:监视
console.log函数的调用,但不改变其行为,仍然会输出日志。
- 代码:
- 结果:
- 原函数行为不变(日志仍会输出)。
- Spy 记录了调用信息,可供后续验证。
3. Spy 的验证功能
- 验证是否被调用:
- 使用 Spy 对象(如
logSpy)检查函数是否被调用。 - 示例:如果
console.log未被调用,测试会失败;如果被调用,测试通过。 - 适用场景:验证副作用(如写入 Canvas、触发
alert、输出日志)是否发生。
- 使用 Spy 对象(如
- 验证调用参数:
- 检查函数是否以预期参数被调用。
- 示例:验证
console.log是否以特定文本(如"Hello")被调用。 - 作用:确保函数不仅被调用,而且调用内容正确。
- 验证调用次数:
- 检查函数调用次数是否符合预期。
- 适用场景:避免 API 被过度调用,确保代码性能或逻辑正确。
- 核心作用:
- Spy 适用于验证无法通过返回值直接检查的副作用。
- 提供对函数调用行为的深入洞察(如是否调用、调用参数、调用次数)。
总结与核心理念
- Spy 的作用:一种测试替身工具,用于监视函数调用而不改变其行为,记录调用信息以供验证。
- 与 Mock 的区别:Spy 保留原函数,Mock 替换原函数。
- 应用场景:
- 验证副作用是否发生(如
console.log、alert)。 - 检查调用参数是否正确。
- 确保调用次数符合预期(如避免 API 过度调用)。
- 验证副作用是否发生(如
- 使用方法:
- 通过
vi.spyOn创建 Spy,指定目标对象和方法(如console.log)。 - 使用 Spy 对象提供的 API 验证调用情况(是否调用、参数、次数)。
- 通过
- 价值:Spy 帮助开发者在测试中确认代码行为,特别是那些无法通过返回值直接验证的副作用,确保代码逻辑的正确性。
21-mocks
主要内容概述
- 主题:介绍测试替身中的 Mock,一种用于替换函数或行为以控制测试环境的工具。
- 核心内容:
- Mock 的定义与作用:替换原函数,提供自定义行为,同时记录调用信息。
- Mock 与 Spy 的区别:Mock 替换原函数,Spy 仅监视而不改变行为。
- Mock 的应用场景:控制不可控的外部依赖(如随机数)、验证函数调用情况。
- 使用 Mock 的风险:可能导致测试与现实脱节,需谨慎使用。
- 目标:帮助开发者理解 Mock 的使用场景、操作方法及潜在风险,以在测试中有效控制环境并验证行为。
详细笔记
1. Mock 的概念与 Spy 的区别
- Mock 的定义:
- Mock 是一种测试替身,用于替换现有函数或创建一个匿名函数,提供自定义行为。
- 它不仅替代原函数,还能记录调用信息(如调用次数、参数)。
- 与 Spy 的区别:
- Spy:监视现有函数,不改变其行为,仅记录调用信息。
- Mock:替换函数,提供自定义实现,同时保留记录调用信息的能力。
- 讲者观点:术语差异不重要,重点在于功能和应用场景,通过代码示例更直观。
2. Mock 的基本用法与功能
- 创建 Mock 函数:
- 使用
vi.fn()(Vitest 中)或jest.fn()(Jest 中)创建一个 Mock 函数。 - 示例:创建一个匿名函数作为占位符,可传递给其他函数或组件。
- 使用
- 功能:
- 替代原函数,提供自定义行为。
- 记录调用信息(如调用次数、参数),用于后续验证。
- 应用场景:
- 传递函数:将 Mock 函数作为回调传递给被测代码,验证是否被调用、调用参数是否正确。
- React 组件测试:模拟事件处理函数(如
onSubmit或onClick),验证组件行为。
- 比喻:Mock 函数像“科学实验工具”,将其“丢入”代码中,事后检查其被调用的情况(谁调用、调用参数、次数)。
3. Mock 的高级用法:替换标准库函数
- 示例:模拟
Math.random:- 代码:
const randomSpy = vi.spyOn(Math, 'random').mockImplementation(() => 0.5); - 作用:替换
Math.random的行为,始终返回固定值(如 0.5),消除随机性。
- 代码:
- 效果:
- 测试中
Math.random的结果可控,确保测试一致性。 - 可用于依赖随机数的场景(如掷骰子、游戏逻辑)。
- 测试中
- 风险:
- 过度修改函数行为可能导致测试与现实脱节。
- 示例:将
Math.random设为非合理值(如 10)可能导致测试通过但实际代码失效。
- 讲者警告:Mock 很强大但危险,需谨慎使用,确保模拟行为接近现实。
4. Mock 在特定场景中的应用
- 场景 1:控制随机性:
- 示例:在掷骰子测试中,模拟
Math.random始终返回 0.5,确保结果一致(如每次结果为 12)。 - 价值:测试结果可重复,验证逻辑是否正确。
- 问题:是否比直接断言“任意数字”更有价值?讲者表示不确定,需权衡。
- 示例:在掷骰子测试中,模拟
- 场景 2:游戏逻辑:
- 示例:在猜词游戏(如 Hangman)中,模拟随机词选择,始终返回固定词,便于测试。
- 价值:控制不可控的外部行为,确保测试可控。
- 附加功能:
- Mock 提供内省能力,验证调用次数、参数等。
- 示例:检查
Math.random被调用的次数是否正确。
5. Mock 与依赖注入的结合
- 依赖注入的优势:
- 讲者强调:如果可以,优先通过依赖注入传递函数(如将随机数生成器作为参数),而不是直接 Mock 标准库。
- 示例:默认使用
Math.random,但允许传入自定义函数,实现相同效果。
- 风险提醒:
- Mock 外部依赖可能导致测试与现实脱节,需谨慎。
- 依赖注入是更安全、更可控的方式,建议优先使用。
- 主题总结:课程中反复强调,如果能通过传递外部依赖控制行为,应优先选择,而不是依赖 Mock。
6. Mock 的实际测试应用
- 示例:模拟掷骰子函数:
- 代码:创建一个 Mock 函数替代
rollDice,始终返回 15。 - 应用:将其传递给角色创建逻辑,验证角色属性(如力量值)是否为 15。
- 代码:创建一个 Mock 函数替代
- 验证调用情况:
- 检查 Mock 函数是否被调用、调用参数是否正确(如
rollDice(4, 6))。 - 示例:验证
rollDice被调用 6 次(对应角色 6 个属性:力量、智力等)。
- 检查 Mock 函数是否被调用、调用参数是否正确(如
- 内省能力:
- 使用
mock.calls查看每次调用的参数。 - 使用
mock.results查看每次调用的返回值。 - 价值:了解被测代码如何使用传入函数,深入验证逻辑。
- 使用
- 实用场景:
- React 组件测试:传递 Mock 函数作为
onClick或onSubmit,验证事件触发时是否以正确参数调用。
- React 组件测试:传递 Mock 函数作为
- 讲者技巧:
- 使用
only运行特定测试,便于调试,但需注意不要提交含only的代码。 - Mock 函数通过
vi.fn()创建,提供强大的调用记录和验证能力。
- 使用
总结与核心理念
- Mock 的作用:一种测试替身工具,用于替换函数行为或创建匿名函数,提供自定义逻辑,同时记录调用信息。
- 与 Spy 的区别:Mock 替换函数行为,Spy 仅监视而不改变。
- 应用场景:
- 控制不可控依赖(如
Math.random),确保测试一致性。 - 模拟回调函数或事件处理(如 React 组件的
onSubmit),验证调用情况。 - 通过依赖注入传递 Mock 函数,验证内部逻辑。
- 控制不可控依赖(如
- 使用方法:
- 使用
vi.fn()或jest.fn()创建 Mock 函数。 - 使用
vi.spyOn().mockImplementation()替换现有函数行为。 - 通过
mock.calls、mock.results等 API 验证调用次数、参数和返回值。
- 使用
- 风险与注意事项:
- Mock 可能导致测试与现实脱节,需确保模拟行为合理。
- 优先考虑依赖注入,避免直接 Mock 标准库或外部依赖。
- 价值:Mock 提供强大控制和验证能力,帮助测试不可控行为,但需谨慎使用,确保测试真实性。
22-alert-spy-exercise
主要内容概述
- 主题:通过一个 React 组件的测试示例,展示如何使用 Spy 监视内置函数(如
alert),并探讨直接 Mock 外部依赖的局限性,提倡使用依赖注入来提高测试可控性。 - 核心内容:
- 使用
vi.spyOn监视alert函数,验证其是否被调用及调用参数是否正确。 - 探讨直接 Mock 外部依赖(如
window.alert)的风险和复杂性。 - 提倡通过依赖注入传递自定义函数(如
onSubmit),以替代直接 Mock 内置函数。
- 使用
- 目标:帮助开发者理解 Spy 的使用方法,同时认识到依赖注入在测试中的重要性,以避免测试与现实脱节。
详细笔记
1. 测试目标与初始设置
- 测试目标:
- 测试一个 React 组件,确保点击按钮时会触发
alert,并验证alert的调用内容是否正确。 - 组件包含一个输入框(
input)和一个按钮(Trigger Alert)。
- 测试一个 React 组件,确保点击按钮时会触发
- 初始设置:
- 使用
@testing-library/react渲染组件。 - 使用
screen.getByLabelText获取输入框,通过screen.getByRole获取按钮。 - 使用
userEvent.type模拟用户在输入框中输入内容。
- 使用
- 讲者说明:
- 测试的基本 DOM 操作已完成(如输入内容、获取按钮),接下来需使用 Spy 验证
alert是否被调用。
- 测试的基本 DOM 操作已完成(如输入内容、获取按钮),接下来需使用 Spy 验证
2. 使用 Spy 监视 alert 函数
- Spy 的设置:
- 代码:
const alertSpy = vi.spyOn(window, 'alert'); - 作用:监视
window.alert函数,记录其调用情况,但不改变其行为。 - 讲者备注:在 JSDOM 环境中,
alert可能无实际效果,但仍可通过 Spy 验证调用。
- 代码:
- 测试步骤:
- 使用
userEvent.click模拟点击按钮。 - 使用
expect(alertSpy).toHaveBeenCalled()验证alert是否被调用。 - 使用
expect(alertSpy).toHaveBeenCalledWith('Alert! Hello')验证调用参数是否正确。
- 使用
- 额外操作:
- 使用
userEvent.clear清空输入框内容,验证alert调用内容是否随之变化。
- 使用
- 结果:
- Spy 成功记录
alert调用情况,测试通过。 - 讲者指出,Spy 帮助验证副作用(如
alert)是否发生及其参数是否正确。
- Spy 成功记录
3. 直接 Mock 外部依赖的局限性
- 问题与风险:
- 直接 Mock 或 Spy 外部依赖(如
window.alert)存在不确定性,可能因环境(如 JSDOM)而表现不同。 - 讲者表示,这种方式“希望控制第三方内容并祈祷最好结果”,增加了测试复杂性和不可靠性。
- 直接 Mock 或 Spy 外部依赖(如
- 讲者感受:
- 直接操作外部依赖让人不安,容易导致测试与实际行为脱节。
- 模拟外部世界越复杂,测试越难维护。
4. 依赖注入作为更优解
- 依赖注入的概念:
- 讲者建议:不要直接 Mock 内置函数(如
alert),而是通过 props 或参数传递自定义函数。 - 示例:为组件添加
onSubmitprop,默认调用alert,但允许测试时传入自定义函数。
- 讲者建议:不要直接 Mock 内置函数(如
- 实现步骤:
- 创建一个 Mock 函数
handleSubmit = vi.fn()。 - 将其作为
onSubmit传递给组件。 - 修改组件逻辑,使用
onSubmit而非直接调用alert。 - 测试中验证
handleSubmit是否被调用及参数是否正确(如expect(handleSubmit).toHaveBeenCalledWith('Hello'))。
- 创建一个 Mock 函数
- 结果:
- 测试通过,且逻辑更清晰,避免直接操作外部依赖。
- 讲者强调:依赖注入使测试更可控,代码更易维护。
- 优点:
- 组件或函数可默认使用内置行为(如
alert、Math.random),但允许测试时传入自定义实现。 - 减少对外部世界的模拟,降低测试复杂性。
- 组件或函数可默认使用内置行为(如
5. 总结与建议
- 核心理念:
- 如果 Mock 或 Spy 外部依赖让人困惑或复杂,正确的答案是“不要这样做”。
- 通过依赖注入传递函数或行为,增加代码控制力,避免未来维护难题。
- 讲者建议:
- 设计代码时,优先考虑可注入依赖(如随机数生成、事件处理)。
- 测试时,使用 Mock 函数验证传入函数的调用情况,而非直接操作内置对象。
- 这样既简化测试,也提高代码可维护性。
- 比喻:
- 如果测试逻辑不简单,几个月后的自己会感到困惑,因此应追求简洁和可控的设计。
总结与核心理念
- Spy 的作用:使用
vi.spyOn监视内置函数(如window.alert),验证其调用情况和参数,适用于测试副作用。 - 应用场景:
- 验证外部依赖(如
alert)是否被调用及调用内容是否正确。 - 结合 DOM 测试库,模拟用户交互(如输入、点击)并验证结果。
- 验证外部依赖(如
- 局限性与风险:
- 直接 Mock 或 Spy 外部依赖可能因环境不同而失效,增加测试复杂性和不可靠性。
- 模拟外部世界越复杂,测试越难维护。
- 依赖注入的优势:
- 通过 props 或参数传递自定义函数(如
onSubmit),替代直接操作外部依赖。 - 提高测试可控性和代码可维护性,避免测试与现实脱节。
- 通过 props 或参数传递自定义函数(如
- 使用建议:
- 优先设计可注入依赖的代码结构,默认使用内置行为,但允许测试时自定义。
- 如果 Mock 或 Spy 复杂,考虑重构代码以支持依赖注入。
- 价值:Spy 适合验证副作用,但依赖注入是更安全、更可持续的测试策略,确保测试简单且贴近现实。
23-mocking-dependencies
主要内容概述
- 主题:通过一个日志函数的测试示例,展示如何处理依赖问题,探讨直接 Mock 外部依赖的局限性与风险,并提倡通过依赖注入提高测试可控性和代码灵活性。
- 核心内容:
- 分析难以测试的代码问题:环境变量(如
MODE)和硬编码依赖(如sendToServer)导致测试复杂。 - 使用环境变量 Stub 和 Mock 解决部分问题,但强调其复杂性和潜在风险。
- 提倡依赖注入,通过传递函数或配置对象替代硬编码依赖,简化测试并提高代码复用性。
- 分析难以测试的代码问题:环境变量(如
- 目标:帮助开发者理解 Mock 依赖的挑战,学习如何通过依赖注入设计更易测试的代码,避免测试与现实脱节。
详细笔记
1. 问题背景:难以测试的代码
- 示例代码:一个日志函数
log,根据环境变量MODE(development、test、production)决定行为:- 在
development模式下调用console.log。 - 在
production模式下调用sendToServer函数发送日志到服务器。
- 在
- 问题:
- 环境变量限制:测试环境始终是
test,无法直接测试development或production模式的行为。 - 硬编码依赖:
log函数直接导入sendToServer,无法控制其行为,测试时可能调用真实服务器(或导致错误)。
- 环境变量限制:测试环境始终是
- 讲者观点:
- 这种代码设计难以测试,不是测试本身的问题,而是代码结构的问题。
- 解决方法包括使用 Mock 绕过问题,但更推荐从设计上改进代码。
2. 解决方案 1:Stub 环境变量
- 方法:通过临时修改环境变量(如
MODE)来模拟不同环境。- 代码示例:
beforeEach(() => { vi.stubEnv('MODE', 'development'); });afterEach(() => { vi.unstubAllEnvs(); });
- 作用:将测试环境临时设置为
development或production,测试对应行为。
- 代码示例:
- 测试示例:
- 在
development模式下,验证console.log被调用。 - 在
production模式下,验证console.log未被调用。
- 在
- Stub 的定义(讲者解释):
- Stub 是临时替换真实值(如环境变量)为假值,测试结束后恢复原值。
- 与 Mock 的区别:Stub 通常用于值(如环境变量),Mock 通常用于函数。
- 讲者强调:术语区别不重要,核心是“临时替换并恢复”。
- 讲者评价:
- 这种方法相对安全,因为未改变函数行为,仅调整环境变量。
- 但仍需谨慎,确保
afterEach恢复环境,避免测试间干扰。
3. 解决方案 2:Mock 外部依赖
- 问题:
sendToServer函数是硬编码依赖,测试时可能调用真实服务器,导致错误或不可控行为。 - 方法:使用
vi.mock替换依赖模块。- 代码示例:
vi.mock('../send-to-server', { sendToServer: vi.fn(() => 'mocked response') });
- 作用:当任何代码尝试导入
send-to-server时,返回一个假的 Mock 函数,阻止真实调用。
- 代码示例:
- 自动 Mock:
- 使用
vi.mock('../send-to-server')自动生成空实现,阻止真实模块加载。 - 讲者指出:这虽然有效,但可能导致测试无意义,因为完全隔离了真实行为。
- 使用
- 测试示例:
- 验证 Mock 后的
sendToServer是否被调用,或验证其不影响其他行为。
- 验证 Mock 后的
- 讲者评价与风险:
- 这种方法“令人不安”,因为它完全替换了依赖,可能导致测试与现实脱节。
- 过度依赖 Mock 会让测试变得复杂且不可靠,例如 Mock
axios时需要处理大量细节,容易出错。 - 讲者警告:如果 Mock 让你感到不适,那是正确的直觉,应该寻找更好的解决方案。
4. 解决方案 3:依赖注入(推荐)
- 方法:重构代码,通过参数传递依赖函数或配置,而不是硬编码。
- 代码示例:
log(message, mode = 'development', productionCallback = sendToServer) { ... }- 或者使用配置对象:
log(message, { mode = 'development', productionCallback = sendToServer }) { ... }
- 作用:测试时可传入 Mock 函数或自定义行为,无需 Mock 外部模块。
- 代码示例:
- 优点:
- 测试更简单:直接传递 Mock 函数,验证调用情况,无需模拟整个外部世界。
- 代码更灵活:用户可以自定义行为(如在
production使用不同的回调,或在staging添加额外逻辑)。 - 减少 Mock 风险:避免测试与现实脱节,因为测试逻辑更贴近实际代码结构。
- 测试示例:
- 传递一个
vi.fn()作为productionCallback,验证其是否被调用及参数是否正确。
- 传递一个
- 讲者建议:
- 删除复杂的 Mock 代码,改为传递依赖。
- 使用配置对象处理参数过多问题,尤其在 TypeScript 中可通过类型定义提高代码清晰度。
- 适用范围:
- 适用于 React 组件、普通函数、类等各种场景。
- 不仅简化测试,还提高代码复用性。
5. 总结与核心理念
- Mock 的局限性与风险:
- Mock 外部依赖(如
sendToServer)虽然能解决问题,但复杂且易导致测试与现实脱节。 - 过度使用 Mock 会让测试变得难以维护,尤其在处理复杂依赖(如
axios)时。
- Mock 外部依赖(如
- 依赖注入的优势:
- 通过参数或配置对象传递依赖,替代硬编码,提高测试可控性和代码灵活性。
- 测试时可直接传递 Mock 函数作为“科学探针”,验证内部行为,而无需模拟外部世界。
- 讲者建议:
- Mock 和 Spy 是可用工具,适用于特定场景(如随机数、日期),但应尽量避免。
- 优先设计易于测试的代码结构,传递函数所需的一切依赖(如测试中的
add(2, 2),直接传递参数)。 - 如果依赖复杂且难以控制,测试会变得困难;通过依赖注入可让测试像简单函数调用一样直观。
- 核心理念:
- 测试应简单易懂,复杂的 Mock 表明代码设计需要改进。
- 依赖注入不仅解决测试问题,还提升代码在日常使用中的灵活性和复用性。
总结与核心理念
- 问题背景:硬编码依赖和环境变量导致代码难以测试,需通过 Mock 或 Stub 解决,但这些方法有风险。
- Mock 和 Stub 的应用:
- Stub 环境变量:临时修改环境(如
MODE)以测试不同行为,相对安全但需恢复原状态。 - Mock 外部依赖:使用
vi.mock替换模块(如sendToServer),阻止真实调用,但可能导致测试无意义。
- Stub 环境变量:临时修改环境(如
- 风险与局限性:
- Mock 外部依赖复杂且易出错,可能导致测试与现实脱节。
- 过度依赖 Mock 会增加维护成本,尤其在处理复杂依赖时。
- 依赖注入的优势:
- 通过参数或配置对象传递依赖,替代硬编码,简化测试并提高代码灵活性。
- 测试时直接传递 Mock 函数,验证调用情况,无需模拟外部世界。
- 使用建议:
- 优先设计易于测试的代码结构,传递所需依赖。
- 仅在必要时(如随机数、日期)使用 Mock 和 Spy,避免过度依赖。
- 使用配置对象处理多参数问题,结合 TypeScript 提高代码清晰度。
- 价值:依赖注入是更可持续的测试策略,确保测试简单、贴近现实,同时提升代码复用性。
24-mocking-time
主要内容概述
- 主题:通过一个简单的延迟回调函数示例,展示如何在测试中控制时间(Mocking Time),以避免测试等待真实时间流逝,同时探讨代码设计对测试的影响。
- 核心内容:
- 分析时间相关逻辑(如
setTimeout)在测试中的挑战。 - 使用
vi.useFakeTimers和相关方法(如vi.advanceTimersByTime)控制时间,模拟时间流逝。 - 强调代码设计(如分离关注点、依赖注入)对简化测试的重要性。
- 分析时间相关逻辑(如
- 目标:帮助开发者理解如何在测试中处理时间相关逻辑,避免测试时间过长,同时通过设计更易测试的代码减少 Mock 的复杂性。
详细笔记
1. 问题背景:时间相关逻辑的测试挑战
- 示例代码:一个简单的函数,使用
setTimeout在 1 秒后调用回调函数。 - 应用场景:
- 类似场景包括显示通知并在几秒后隐藏、显示横幅(banner)并在指定时间后消失。
- 其他时间相关逻辑,如格式化“发布时间”(如“2 小时 3 分钟前”)。
- 测试挑战:
- 如果测试真实等待时间(如 3 秒、10 秒),测试套件会变得非常慢,导致测试时间过长(如 10 分钟)。
- 时间不断流逝,难以固定测试条件(如测试“2 小时前”的格式化结果)。
- 讲者观点:
- 不能让测试套件真实等待时间流逝,需通过控制时间来模拟时间变化。
- 时间相关测试是 Mock 的合理应用场景,因为目标是控制现实以测试代码,而不是替换整个模块。
2. 解决方案:控制时间(Mocking Time)
- 方法:使用 Vitest/Jest 提供的假定时器(Fake Timers)功能控制时间。
- 核心 API:
vi.useFakeTimers():启用假定时器,冻结时间,阻止真实时间流逝。vi.advanceTimersByTime(ms):手动推进时间指定的毫秒数,触发相关定时器(如setTimeout)。vi.advanceTimersToNextTimer():推进时间到下一个定时器触发点。vi.useRealTimers():在测试结束后恢复真实时间。
- 代码示例:
beforeEach(() => { vi.useFakeTimers(); });afterEach(() => { vi.useRealTimers(); });
- 核心 API:
- 测试步骤:
- 创建一个 Mock 回调函数:
const callback = vi.fn();。 - 调用目标函数(如
delayedCallback(callback)),该函数内部使用setTimeout。 - 使用
vi.advanceTimersByTime(1000)推进时间 1 秒(或vi.advanceTimersToNextTimer()推进到下一个定时器)。 - 验证回调是否被调用:
expect(callback).toHaveBeenCalled();。
- 创建一个 Mock 回调函数:
- 额外功能:
- 可以设置特定时间点(如
vi.setSystemTime(new Date('2023-01-01T00:00:00Z'))),冻结时间以测试时间格式化逻辑(如“2 小时前”)。 - 推进时间后验证结果(如推进 2 小时,验证显示“2 hours ago”)。
- 可以设置特定时间点(如
- 讲者评价:
- 控制时间是 Mock 的合理应用,因为它不涉及替换整个模块,只是控制现实以便测试代码。
- 这种方法避免了测试套件真实等待时间,确保测试高效。
- 对于时间格式化逻辑,冻结和推进时间可以轻松模拟不同时间点,非常实用。
3. 代码设计对测试的影响
- 问题:复杂的代码设计会导致测试困难。
- 示例:如果组件内部直接处理数据获取(如
fetch请求),测试时需 Mock 整个请求流程(包括response.json等),非常复杂。 - 讲者指出:这不是测试问题,而是代码设计问题。
- 示例:如果组件内部直接处理数据获取(如
- 解决方案:分离关注点,通过依赖注入简化测试。
- 将数据获取逻辑提取到独立函数,组件只负责调用该函数。
- 测试时:
- 组件测试:验证是否正确调用传入的数据获取函数。
- 数据获取函数测试:验证是否正确调用底层依赖(如
axios),并可传入 Mock 依赖。
- 讲者建议:默认使用真实依赖(如
axios),但允许测试时传入 Mock。
- 优点:
- 分离关注点使测试更简单,每个部分只需关注自己的职责。
- 减少 Mock 的复杂性,避免模拟整个外部世界。
4. 总结与核心理念
- Mocking Time 的价值:
- 控制时间是测试时间相关逻辑(如
setTimeout、时间格式化)的有效方法。 - 使用
vi.useFakeTimers()和vi.advanceTimersByTime()等 API,可以冻结和推进时间,避免真实等待,确保测试高效。
- 控制时间是测试时间相关逻辑(如
- 适用场景:
- 测试延迟逻辑(如通知显示后隐藏)。
- 测试时间格式化逻辑(如“2 小时前”)。
- 代码设计的重要性:
- 复杂的代码设计(如组件内直接处理数据获取)会导致测试困难,需 Mock 过多细节。
- 通过分离关注点和依赖注入,简化测试逻辑,减少 Mock 复杂性。
- 讲者建议:
- 控制时间是 Mock 的合理应用,因为目标是控制现实以测试代码,而不是替换模块。
- 避免在代码中硬编码复杂依赖,优先提取独立函数,通过参数传递依赖。
- 测试应关注单一职责,组件和依赖分别测试,确保逻辑清晰。
- 核心理念:
- 测试应高效、简单,Mocking Time 是解决时间相关测试挑战的有效工具。
- 代码设计直接影响测试难度,通过分离关注点和依赖注入,可以显著降低测试复杂性。
总结与核心理念
- 问题背景:时间相关逻辑(如
setTimeout、时间格式化)在测试中会导致等待时间过长或条件难以固定。 - Mocking Time 的应用:
- 使用
vi.useFakeTimers()冻结时间,使用vi.advanceTimersByTime()或vi.advanceTimersToNextTimer()模拟时间流逝。 - 适用于测试延迟逻辑和时间格式化,确保测试高效。
- 使用
- 优点:
- 避免真实等待时间,防止测试套件过慢。
- 冻结时间便于测试特定时间点逻辑(如“2 小时前”)。
- 代码设计的影响:
- 硬编码复杂依赖(如直接在组件内处理数据获取)会导致测试困难,需 Mock 过多细节。
- 分离关注点和依赖注入可简化测试,减少 Mock 复杂性。
- 使用建议:
- 控制时间是 Mock 的合理场景,使用 Vitest/Jest 提供的假定时器 API。
- 设计代码时,优先分离关注点,将复杂逻辑提取为独立函数,通过参数传递依赖。
- 测试时关注单一职责,分别验证组件和依赖的行为。
- 价值:Mocking Time 有效解决时间相关测试挑战,而良好的代码设计是简化测试的基础,确保测试高效且贴近现实。
25-playwright
主要内容概述
- 主题:介绍 Playwright 作为端到端(E2E)测试工具的作用,用于验证整个应用的功能,尤其在代码难以拆分或单元测试覆盖不足时,提供快速测试覆盖。
- 核心内容:
- 分析代码库中难以测试的部分,强调现实与理想实践之间的平衡。
- 介绍 Playwright 的基本功能:通过控制真实浏览器测试应用,模拟用户行为。
- 探讨 Playwright 的优势、局限性及适用场景。
- 目标:帮助开发者理解 E2E 测试在处理复杂代码库中的作用,学习 Playwright 的基本使用场景,并认识其与单元测试的互补性。
详细笔记
1. 问题背景:代码库的复杂性与测试挑战
- 现实情况:
- 代码库中常存在复杂、未拆分的代码,难以通过单元测试覆盖。
- 讲者坦言:自己的代码库中既有易于测试的部分(100% 测试覆盖),也有难以测试的部分。
- 复杂性来源:时间压力、代码随时间退化、历史遗留问题等。
- 挑战:
- 完全重构代码以便于单元测试通常不现实(“不会给你六个月去解开所有问题”)。
- 即使代码设计不佳,也需要确保应用整体功能未被破坏。
- 讲者观点:
- 测试的目标是降低压力,确保功能正常,而不仅仅是追求最佳实践(如依赖注入)。
- 面对复杂代码库,不能因无法拆分而放弃测试,需寻找替代策略。
- 现实与理想实践之间需平衡,复杂代码的存在不应被苛责。
2. 解决方案:端到端测试与 Playwright 简介
- 端到端(E2E)测试:
- 不同于单元测试(快速、聚焦单一功能)和组件测试(模拟 DOM 测试组件),E2E 测试通过控制真实浏览器,指向应用,模拟用户行为,验证整体功能。
- 工具选择:Playwright(讲者当前使用)或 Cypress(另一常见选择)。
- Playwright 简介:
- Playwright 是一个自动化测试工具,支持多个浏览器(如 Chrome、Firefox、Safari)。
- 功能:启动浏览器,导航到指定页面,执行用户操作(如点击、输入),验证页面状态。
- 讲者说明:
- Playwright 内容丰富,可独立成一天课程,此处仅做高层次概述。
- 后续可根据兴趣深入探讨,并提供进一步阅读材料。
3. Playwright 的特点与测试库的相似性
- 与 Testing Library 的相似性:
- Playwright 的测试语法和 API(如
getByPlaceholderText、getByLabelText)与 Testing Library 类似。 - 区别:
- Testing Library:测试单个组件,加载到模拟 DOM,快速但无法覆盖整个应用。
- Playwright:测试整个应用,运行在真实浏览器,验证整体行为。
- Playwright 的测试语法和 API(如
- 讲者观点:
- 单元测试和组件测试快速、精准,能定位具体错误行。
- E2E 测试关注“大局”,验证应用整体是否工作,适合补充单元测试的不足。
4. 示例:使用 Playwright 测试待办事项应用
- 测试场景:
- 测试一个待办事项(To-Do List)应用,运行在本地服务器(如
localhost:5174或localhost:3000)。 - 功能:添加任务、移动任务、标记完成。
- 测试一个待办事项(To-Do List)应用,运行在本地服务器(如
- 测试步骤:
- 启动浏览器,导航到应用页面。
- 验证页面标题是否符合预期。
- 找到输入框(创建任务),输入内容,点击提交按钮。
- 验证新任务标题是否在页面上可见。
- 工具支持:
- 使用
npx playwright test运行测试,显示测试报告(包括失败情况)。 - 使用 UI 模式(
npx playwright test --ui)查看测试时间线、点击事件、页面状态等。 - 支持选择器调试:可通过自动选择器或手动选择(如
getByPlaceholder)定位元素。
- 使用
- 额外功能:
- 可指向生产环境测试(需解决登录等问题)。
- 提供测试前后页面状态查看,方便调试。
- 问题与解决:
- 当前测试存在问题(如未在测试间清除状态、重复标题导致错误),后续会讨论解决方案。
5. Playwright 的优势与局限性
- 优势:
- 无需拆分代码:即使代码未分离为小函数,也可直接测试应用功能,适合快速获取测试覆盖。
- 降低重构焦虑:在重构代码以便单元测试会增加压力时,E2E 测试提供安全网,确保未破坏整体功能。
- 模拟真实用户行为:比手动测试更快,能覆盖更多交互场景。
- 局限性:
- 运行缓慢:相比单元测试,E2E 测试耗时长(“heavy and take forever to run”)。
- 不稳定(Flaky):测试结果可能受网络、环境等影响,稳定性不如单元测试。
- 测试覆盖报告有限:无法像单元测试那样在覆盖报告中体现具体代码行覆盖情况。
- 讲者观点:
- E2E 测试是“精神上”的覆盖,目标是验证应用整体未被破坏,而非追求代码行覆盖。
- 单元测试与 E2E 测试应互补使用,前者快速精准,后者验证整体行为。
6. 总结与核心理念
- Playwright 的价值:
- 提供快速测试覆盖,尤其适用于复杂、未拆分的代码库。
- 模拟真实用户行为,验证应用整体功能,降低重构或修改代码的风险。
- 适用场景:
- 代码难以拆分或单元测试覆盖不足时。
- 需要验证整个应用行为,尤其在重构前或紧急修复后。
- 局限性与互补性:
- 运行慢、不稳定,不适合替代单元测试。
- 与单元测试和组件测试结合使用,单元测试提供快速反馈,E2E 测试验证整体效果。
- 讲者建议:
- 面对复杂代码库,不应因无法拆分而放弃测试,E2E 测试是一种实用策略。
- Playwright 语法与 Testing Library 类似,易于上手,调试工具(如 UI 模式)增强了测试体验。
- 后续需解决测试间状态管理等问题,确保测试可靠性。
- 核心理念:
- 测试的目标是降低压力,确保功能正常,而非追求完美代码结构。
- E2E 测试(如 Playwright)是复杂代码库的实用工具,与单元测试互补,共同构建测试安全网。
总结与核心理念
- 问题背景:代码库中常存在复杂、未拆分的代码,难以通过单元测试覆盖,重构成本高。
- Playwright 的应用:
- 作为 E2E 测试工具,控制真实浏览器,模拟用户行为,验证应用整体功能。
- 语法与 Testing Library 类似,支持调试工具(如 UI 模式),易于上手。
- 优势:
- 无需拆分代码即可测试,适合快速获取覆盖,降低重构焦虑。
- 模拟真实用户行为,比手动测试更快。
- 局限性:
- 运行慢、不稳定,测试覆盖报告有限,不适合替代单元测试。
- 使用建议:
- 将 Playwright 与单元测试结合使用,前者验证整体行为,后者提供快速反馈。
- 适用于复杂代码库或重构前的安全验证。
- 注意解决测试间状态管理等问题,提高测试可靠性。
- 价值:Playwright 提供了一种实用的 E2E 测试策略,帮助开发者在现实与理想实践之间找到平衡,确保应用功能正常,降低开发压力。
26-testing-the-counter-with-playwright
主要内容
Steve Kinney 在视频中讲解了如何使用 Playwright 对一个计数器应用进行测试,强调了 Playwright 的使用场景、测试流程以及其优势和局限性。
1. 测试环境准备
- 运行应用:测试前需要确保应用正在运行,可以通过程序化方式在测试开始时启动应用。
- 网络模拟:提到了模拟网络的重要性,后续会详细讲解两种方法。
- 测试前置步骤:使用
test.beforeEach来设置测试前的操作,例如访问特定页面(如用户设置页面或首页)。
2. Playwright 的适用性
- Playwright 适用于各种类型的应用,包括静态营销网站,因为它本质上是一个浏览器自动化工具。
- 它使用了无障碍角色(Accessibility Roles),与之前在 Testing Library 中学习的知识点相呼应。
3. 测试基本流程
- 页面访问:使用
page.goto()导航到目标页面(例如端口 5173)。 - 元素查找:使用
getByTestId方法查找特定元素,例如计数器元素counter-count。 - 测试运行:运行测试后,可以查看页面加载的时间线和快照,尽管当前测试未设置具体断言,但只要元素存在,测试就算通过。
- 隐式验证:如果 Playwright 找不到目标元素,测试会失败,这本身就是一种有效的验证方式,确保页面上存在关键元素。
4. 测试示例:计数器功能
- 目标:测试计数器元素是否存在,并验证增量按钮的功能。
- 步骤:
- 访问页面。
- 查找计数器元素(
counter-count)。 - 查找增量按钮并点击(
increment-button.click())。 - 测试通过的标准:如果按钮无法点击,测试会失败。
- 代码问题解决:Steve 提到在测试中需要将函数设置为异步(
async),并处理自动补全导致的变量命名错误。
5. Playwright 的优势
- 无需重构:与单元测试不同,Playwright 测试不需要对代码进行重构或模拟(mock),直接在浏览器中运行。
- 接近真实用户体验:测试模拟了真实用户行为(如点击按钮),更接近端到端测试。
- 调试工具:提供了时间线和快照功能,方便查看测试过程中的页面状态。
6. Playwright 的局限性
- 测试速度慢:相比单元测试,Playwright 测试速度较慢,因为它需要在浏览器中运行。
- 组件隔离测试困难:在 Playwright 中难以对单个组件进行独立测试,测试通常涉及整个页面或应用。
- 复杂性:测试可能带来额外的复杂性(“ball of wax”),需要权衡使用场景。
7. 工具选择的思考
- Steve 强调没有哪种工具是绝对优于其他工具的,测试工具就像一组刀具(set of knives),需要根据具体问题选择合适的工具。
- Playwright 适合不需要频繁重构且希望模拟真实用户交互的场景。
总结
- 本节内容展示了如何使用 Playwright 对计数器应用进行基本测试,包括页面访问、元素查找和交互验证。
- Playwright 的核心优势在于无需重构代码和接近真实用户体验,但测试速度较慢且不适合组件级测试。
- 工具选择应基于项目需求和测试目标,灵活搭配不同测试框架和方法。
27-mock-service-worker
1. 背景与问题
- 在测试中,经常需要处理后端 API 的模拟(mocking、stubbing、spying)。
- 后端 API 未完成、测试速度慢或网络不稳定等问题会影响前端开发和测试进度。
- 解决方案:使用工具如 Mock Service Worker (MSW) 来模拟后端行为。
2. Mock Service Worker (MSW) 简介
- 定义:MSW 是一种利用 Service Worker 技术拦截网络请求的工具,可用于开发和测试环境。
- Service Worker 原理:
- Service Worker 是用于渐进式 Web 应用(PWA)的技术,介于客户端和网络之间。
- 它可以在离线时拦截网络请求并返回缓存数据,在线时同步数据。
- MSW 的作用:
- 拦截特定端点的网络请求,阻止其到达真实服务器。
- 返回预定义的响应对象,模拟后端行为。
3. MSW 的优势
- 测试速度快:不访问真实网络,测试运行更快。
- 测试稳定性:避免因网络问题导致测试失败。
- 提前开发 API:在后端 API 未完成时,前端可以基于模拟数据开发和测试。
- 灵活性:
- 支持多种 HTTP 方法(GET、POST、PUT、PATCH 等)。
- 可为不同测试场景设置不同的响应(如管理员账户数据、错误数据、404 响应等)。
- 无代码侵入性:MSW 在应用边界拦截请求,不需要修改应用代码,仅模拟服务器返回的数据。
- 开发支持:可在浏览器中运行,支持开发阶段的调试。
4. MSW 使用示例
- 场景:模拟任务列表 API(
/api/tasks)。 - 代码实现:
- 定义处理程序(handlers),拦截 GET 请求到
/api/tasks,返回预定义的 JSON 数据(例如两个假任务)。 - 对于 POST 请求,可以获取客户端请求数据(如任务标题),模拟创建任务并返回响应。
- 定义处理程序(handlers),拦截 GET 请求到
- 注意事项:
- 模拟服务器行为时,避免过度模拟(例如完全重写服务器逻辑),应保持简单。
- 可在单元测试和 Playwright 测试中使用 MSW。
5. Playwright 的网络请求处理
- HAR 文件支持:
- Playwright 允许开发者从 Chrome 开发者工具中复制网络请求和响应的 HAR 文件。
- 在测试中,Playwright 可以根据 HAR 文件模拟响应,无需访问真实网络。
- 网络请求录制与回放:
- Playwright 支持录制网络请求(例如对生产或 staging 环境的请求)。
- 录制后,可在测试中回放这些请求,保持请求顺序(如 GET 任务列表 -> POST 新任务 -> 再次 GET 任务列表)。
- 这对于测试复杂交互流程非常有用。
6. 实际应用与经验教训
- 自动化测试设置:
- Steve 提到在云端和开源项目中,使用 Playwright 启动浏览器,模拟不同视口(viewports)下的网络请求。
- 录制所有网络请求并截图,用于检测视觉回归(如在移动视图修改时意外影响平板视图)。
- 早期错误:
- 忽略深色模式(dark mode)支持,导致后期补救耗费大量时间。
- 响应式设计(responsive design)考虑不足,移动视图适配带来额外压力。
- 测试策略:
- 在进行大规模重构前,用测试包围代码,确保信心。
- 目标是即使出现问题,也能在客户发现前通过测试捕获。
7. 总结与思考
- MSW 的核心价值:通过拦截网络请求,MSW 提供了一种快速、稳定且灵活的模拟后端方式,特别适合前端开发者和测试人员。
- Playwright 的高级功能:网络请求录制与回放功能进一步增强了测试的真实性和可重复性。
- 工具选择与平衡:
- 模拟服务器行为时,应避免过度复杂化。
- 测试的目标是提高开发效率和产品质量,确保在重构或功能迭代中不引入回归问题。
- 心态:前端开发常因后端延迟而受阻,MSW 等工具帮助开发者在 API 未就绪时继续工作,缓解时间压力。
总结
- 本节内容详细介绍了 Mock Service Worker (MSW) 的原理、优势及使用场景,结合 Playwright 的网络请求处理功能,展示了如何在前端测试和开发中高效模拟后端行为。
- 强调了测试策略的重要性,尤其是在重构和跨团队协作中,通过工具和自动化测试减少回归风险,提高开发效率。
28-wrapping-up
主要内容
Steve Kinney 在课程的总结部分回顾了测试工具和策略的多样性,强调了测试与代码结构之间的关系,以及如何在测试和重构之间找到平衡。他鼓励开发者以开放的心态面对测试中的权衡,并将测试视为管理代码复杂性的一种工具。
1. 测试工具与代码结构的关系
- 工具多样性:课程中介绍了多种测试工具(如 mocking、stubbing),这些工具在代码难以测试时非常有用。
- 代码解耦的重要性:
- 将代码拆分成独立、可组合的模块(“snap together into Voltron”),而不是紧密耦合(“glued together”),可以显著降低测试难度。
- 例如,将数据获取逻辑从组件中分离出来,测试会变得像最开始的简单加减运算一样容易。
2. 重构的挑战与现实
- 重构的难度:
- 重构现有代码(尤其是已经在生产环境运行的代码)本身就是一种风险。
- 完全停止开发六个月进行重构是不现实的,Steve 对此表示“good luck”。
- 技术债务的普遍性:
- 每个团队都有代码库中难以触碰的部分(“everyone on the team sighs during sprint planning”),这些通常是技术债务的体现。
- 虽然理想的做法是将代码拆分成易于测试的模块,但往往无法立即实现。
3. 测试困难的根源与解决方案
- 测试困难的本质:
- 如果某段代码难以测试,并不意味着测试本身很难,而是代码结构有问题。
- 测试困难可能是技术债务的信号,表明需要重构。
- 替代策略:
- 如果重构不可行,可以通过更广义的测试(如 Playwright 端到端测试)来获得基本覆盖。
- 例如,在每次拉取请求(PR)时启动浏览器测试关键流程,虽然反馈循环较慢(7-11 分钟),但可以防止重大故障。
- 单元测试 vs 端到端测试:
- 单元测试速度快,但可能需要重构代码。
- 端到端测试(如 Playwright)不需要重构,但测试范围过大可能导致测试价值降低。
4. 测试策略的权衡
- 快照测试(Snapshot Tests):
- 生成快照测试很快,但如果代码难以测试,团队可能会忽略这些测试结果。
- Mocking 依赖:
- 完全模拟依赖可能在短期内解决问题,但长期可能会带来其他问题。
- 只要开发者清楚权衡(“go in eyes wide open”),这种做法是可以接受的。
- 短期与长期目标:
- 有时选择“简单的方法”(easy way out)可以为后续重构争取时间。
- 例如,临时使用 Playwright 测试来保证信心,之后再进行代码拆分和单元测试。
5. 测试与代码简洁性的相互促进
- 核心理念:
- 如果代码结构简单,测试也会变得简单。
- 代码简洁性不仅有利于测试,还带来可复用性等其他优势。
- 复杂性管理:
- 测试是管理代码复杂性的一种工具。
- 测试和代码设计是相互交织的问题,解决测试问题往往也意味着解决设计问题。
6. 对未来开发的建议
- 从小处着手:
- 在下一个项目或模块中,尝试将代码拆分成小的、易于测试的单元。
- 接受权衡:
- 不同的测试策略(如单元测试、端到端测试、mocking)都是有效的,关键是根据当前需求选择合适的工具。
- 持续学习与改进:
- 技术债务是不可避免的,即使在未来一年内,开发者仍会制造新的债务。
- 关键是意识到这些问题,并通过测试和重构逐步改进。
7. 总结与感谢
- Steve 强调课程的目标是让大家认识到测试的多样性和权衡,并从中获得最大价值。
- 他感谢观众的参与,并希望大家带着对测试和代码设计的深刻理解离开课程。
- 观众以掌声回应。
总结
- 本节是课程的收尾,Steve Kinney 总结了测试工具的使用场景和代码结构对测试的影响。
- 他指出测试困难往往源于代码设计问题,鼓励开发者通过重构提高代码可测试性,同时接受短期权衡策略(如端到端测试或 mocking)。
- 核心理念是:测试不仅是验证代码的工具,也是推动代码简洁性和可维护性的驱动力。开发者应以开放心态面对测试中的挑战,并持续改进代码和测试实践。