-
Notifications
You must be signed in to change notification settings - Fork 114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
【草稿】Vue 应用单元测试的策略与实践 #323
Milestone
Comments
JimmyLv
added a commit
that referenced
this issue
Jul 22, 2018
第一篇已首发于 Vue 应用单元测试的策略与实践 01 - 前言 | 吕立青的博客 欢迎关注知乎专栏 —— 前端的逆袭(凡可 JavaScript,终将 JavaScript。) |
JimmyLv
added a commit
that referenced
this issue
Oct 30, 2018
…d-practice-03-testing-vue-components.md
JimmyLv
added a commit
that referenced
this issue
Jul 16, 2019
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
本文主要尝试解决三个问题:
不谈论的包括:
------------分割线,先copy自React版再做重构------------
下面我就来结合具体场景,进一步实例化这些问题,举几个🌰:
于是乎,这就是本系列文章的大纲,先放出来给大家一个对于Vue应用单元测试的全局观:
-------------正文终于开始-----------
单元测试基础
引用好友鲜明的观点就是:写不好是能力问题,不写则是态度问题。单元测试客观上可以让开发者的工作更高效,Vue 应用的单元测试是一定要的。
单元测试的上下文
谈任何东西都一定要有个上下文。你的论述不能是「因为单元测试有这些好处,所以我们要做单元测试」,而应该是「不做单元测试我们会遇到什么问题」,这样才能回答「为什么要写单元测试」的问题。那么我们谈论单元测试的上下文是什么呢?不做单元测试我们会遇到什么问题呢?
上图为一个产品从 idea 分析、设计、开发、测试到交付并获取市场反馈的过程。
而单元测试的上下文就是存在于「敏捷」当中。敏捷为的是更快地交付有价值的可工作的软件。为此,它有一个指标来度量这个「更快」,那就是 lead time,它度量的是一个 idea 从提出被验证,到最终上生产环境面对用户的时间。显然,这个时间越短,软件获得反馈的时间就越短,对价值的验证就越快发生。
单元测试与自动化的意义
这个结论对我们写不写单元测试有什么影响呢?答案是,不写单元测试,你就快不起来。为啥呢?因为每次发布,你都要投入人力来进行手工测试;因为没有测试,你倾向于不敢随意重构,这又导致代码逐渐腐化,复杂度使得你的开发速度降低。
那么在这个上下文中来谈要不要单元测试,我们就可以很有根据了,而不是开发爽了就用,不爽就不用这样含糊的答案:
if-else
裸奔也不在话下,脑不好还做什么程序员,那你可以不用单元测试除此之外,你就需要写单元测试。如果你想随时整理重构代码,那么你需要写单元测试;如果你想有自动化的测试套件来帮你快速验证提交的完整性,那么你需要写单元测试。综上,我用以谈单元测试的「透镜」是什么呢?一言以蔽之,两点:反馈速度和自动化。
自动化回答的是要不要自动化的单元测试这个问题。测试是重构的唯一保障,也就是说,没有测试,基本上就没法重构代码(重构指的是 不改变软件可观测行为的前提下改善代码内部设计或实现 ),基本上就只能看着代码腐化。那么,基本上只要你的系统需要持续发展,你就需要单元测试。
反馈速度回答的是要不要 TDD、测试先行还是后补这个问题。我认为,要 TDD,最好先行,因为可以提高反馈速度。
至此回答了「为什么我们需要写单元测试」的问题。下面谈谈如何写好 JavaScript 和 Vue 框架的单元测试。
为什么选择 Jest
Jest 的几大好处可以涵盖为:
总结一下,Jest 最大的特点是它是一个非常有效的解决方案,不需要与其他测试库交互来执行它的工作。与此同时 Jest 非常注重开发者体验,这一点也是特别值得欣赏,现在市面上关注开发者(“人”)体验的开发框架和工具实在不多,而Jest Watch模式的核心就在于快速获得反馈,虽然我没在命令行使用而是WebStorm但亦可以与之结合。除此之外,还有很多开发者体验亦值得细细品味与发现,特别是Jest本身来自Facebook的工程化支持也是特别棒的,这个讲述如何开发Jest的官方视频值得一看:Building High-Quality JavaScript Tools
Jest 的基本用法
首先创建
jest-demo
项目并安装jest
作为项目devDependencies
依赖:然后让我们写第一个测试。创建一个
math.js
文件,输入一个我们稍后测试的sum
函数:现在在同一个文件夹中创建一个
math.test.js
文件,在这里我们将使用 Jest 来测试math.js
中定义的函数:然后运行
yarn test
你就可以看到相应的结果。麻雀虽小五脏俱全,在上面的例子当中,我们可以看到很多的测试元素,下面我们将会来一一介绍:
首先我们看到的是一个由
it
包裹的测试主体最小单元,采用了Given When Then的经典格式,我们常常称之为测试三部曲:beforeEach
修改断言的结果,就可以看到成功后的结果了:
Fake/Stub/Mock/Spy
该如何测试异步?
更加Jest相关的内容可以查看这篇文章 Testing JavaScript with Jest,与此同时具体的 API 可以参考官方文档。
Vue 单元测试
在组件化出现之前,我们不谈 UI 的单元测试,哪怕是对于 UI 页面的测试来说都是一件非常困难的事情。其实组件化并不全是为了复用,很多情况下也恰恰是为了分治,从而我们可以分组件对 UI 页面进行开发,然后分别对其进行单元测试。
前端组件化已经让 UI 测试变得容易很多,每个组件都可以被简化为这样一个表达式,即
UI = f(data)
,这个纯函数返回的只是一个描述 UI 组件应该是什么样子的虚拟 DOM,本质上就是一个树形的数据结构。给这个纯函数输入一些应用程序的状态,就会得到相应的 UI 描述的输出,这个过程不会去直接操作实际的 UI 元素,也不会产生所谓的副作用。Vue 组件树的测试
按理来说按照纯函数这样的思路,Vue 组件的测试应该很简单的说。但与此同时对于(渲染出 UI 的)组件树进行测试依然存在一个问题,从下图中可以看出,越处于上层的组件,其复杂度必然会随之提高。对于最底层的子组件来说,我们可以很容易得将其进行渲染并测试其逻辑的正确与否,但对于较上层的父组件来说,通常来说就需要对其所包含的所有子组件都进行预先渲染,甚至于最上面的组件需要渲染出整个 UI 页面的真实 DOM 节点才能对其进行测试,这显然是不可取的。
浅渲染(Shallow Rendering)解决了这个问题,也就是说在我们针对某个上层组件进行测试时,可以不用渲染它的子组件,所以就不用再担心子组件的表现和行为,这样就可以只对特定组件的逻辑及其渲染输出进行测试了。Vue 官方提供了
@vue/test-utils
可以让我们使用浅渲染这个特性,用于测试虚拟 DOM 对象,即Vue.component
的实例。Vue 组件的渲染方式
shallowMount(component[, options]) => Wrapper
浅渲染在将一个组件作为一个单元进行测试的时候非常有用,可以确保你的测试不会去间接断言子组件的行为。
shallowMount
方法就是 Shallow Rendering 的封装,shallowMount
跟mount
类似返回mounted
和rendered
Vue 组件的 Wrapper,但只会渲染出组件的第一层 DOM 结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试的速度也会更快。mount(component[, options]) => Wrapper
mount
方法则会将 Vue 组件渲染为真实的 DOM 节点,特别是在你依赖真实的 DOM 结构必须存在的情况下,比如说按钮的点击事件。完全的 DOM 渲染需要在全局范围内提供完整的 DOM API, 这也就意味着 Vue Test Utils 依赖于浏览器环境。从技术上讲,你可以在真实的浏览器中运行,但由于在不同平台上启动真实浏览器的复杂性,更建议使用 JSDOM 在虚拟浏览器环境中运行 Node 中的测试。推荐使用
mount
的方法是依赖于一个名为jsdom
的库,它本质上是一个完全在 JavaScript 中实现的 headless 浏览器。render(component[, options]) => CheerioWrapper
render
方法则会将 Vue 组件渲染成静态的 HTML 字符串,返回的是一个 Cheerio 实例对象,采用的是一个第三方的 HTML 解析库 Cheerio,这是一个类 jQuery 的库,可以在 Node.js 中遍历 DOM。渲染后所返回的 CheerioWrapper 可以用于分析最终结果的 HTML 代码结构,它的 API 跟shallowMount
和mount
方法的 API 都保持基本一致。renderToString(component[, options]) => string
renderToString
很简单,顾名思义就是把一个组件渲染成对应的 HTML 字符串,在此不再赘述。Wrapper
find()
方法与选择器从前面的示例代码中可以看到,无论哪种渲染方式所返回的 wrapper 都有一个
.find()
方法,它接受一个 selector 参数,然后返回一个对应的 wrapper 对象。而.findAll()
则会返回一个类型相同的 wrapper 对象数组,里面包含了所有符合条件的子组件。在这个对象数组的基础上,at
方法则可以返回指定位置的子组件,trigger
方法用于在组件之上模拟触发某种行为。@vue/test-utils
中的 Selectors 即选择器,既可以是 CSS 选择器(也支持比较复杂的关系选择器组合),也可以是 Vue 组件 或是一个 option 对象,以便于在 wrapper 对象中可以轻松地指定想要查找的节点。在下面的示例中,我们可以通过 Vue 组件构造函数的引用找到该组件,与此同时也可以基于 Vue 组件属性的子集来查找组件和节点。
UI 组件交互行为的测试
我们不但可以通过
find
方法查找 DOM 元素,还可以通过trigger
方法在组件上模拟触发某个 DOM 事件,比如 Click,Change 等等。对于浅渲染来说,事件模拟并不会像真实环境中所预期的那样进行传播,因此我们必须在一个已经设置好了事件处理方法的实际节点上才能够调用,实际上.trigger()
方法将会根据模拟的事件触发这个组件的 prop。例如,.trigger('click')
实际上会获取 对应的clickHandler
propsData 并调用它。Vuex 单元测试
古人说「读史让人明智」,学习历史是为了更好得前行,为了能够认识现在,看清未来。让我们来看看 Vuex 的历史,Vuex 借鉴于 Redux,而 Redux 的实现构想最初则出身于 Flux ,这是一个由 Facebook 为其应用所设计的应用程序架构。Flux 模式在 JavaScript 应用里像是找到了新家一样,但其实只是借鉴了领域驱动设计 (DDD) 和命令-查询职责分离 (CQRS)。
CQRS 与 Flux 架构
描述 Flux 最普遍的一种的方式就是将其与 Model-View-Controller (MVC) 架构进行对比。
在 MVC 当中,一个 Model 可以被多个 Views 读取,并且可以被多个 Controllers 进行更新。在大型应用当中,单个 Model 会导致多个 Views 去通知 Controllers,并可能触发更多的 Model 更新,这样结果就会变得非常复杂。
而 Flux 以及我们要学习的 Vuex 则是试图通过强制单向数据流来解决这个复杂度。在这种架构当中,Views 查询 Stores(而不是 Models),并且用户交互将会触发 Actions,Actions 则会被提交到一个集中的 Dispatcher 当中。当 Actions 被派发之后,Stores 将会随之更新自己并且通知 Views 进行修改。这些 Store 当中的修改会进一步促使 Views 查询新的数据。
MVC 和 Flux 最大的不同就是查询和更新的分离。在 MVC 中,Model 同时可以被 Controller 更新并且被 View 所查询。在 Flux 里,View 从 Store 获取的数据是只读的。而 Stores 只能通过 Actions 被更新,这就会影响 Store 本身而不是那些只读的数据。
以上所描述的模式非常接近于由 Greg Young 第一次所提出的 CQRS:
如何对 Vuex 进行单元测试
mutations 测试
Mutation 很容易被测试,因为它们仅仅是一些完全依赖参数的函数。最为简单的 mutation 测试,仅一一对应保存数据切片。此种 mutation 可以不需要测试覆盖,因为基本由架构简单和逻辑简单保证,不需要靠读测试用例来理解。而一个较为复杂、具备测试价值的 mutation 在保存数据的同时,还可能进行了合并、去重等操作。
actions 测试
Action 应对起来略微棘手,因为它们可能需要调用外部的 API。当测试 action 的时候,我们需要增加一个 mocking 服务层——例如,我们可以把 API 调用抽象成服务,然后在测试文件中用 mock 服务回应 API 调用。
getters 测试
getter 的测试与 mutation 一样直截了当。getters 是重逻辑的地方,并且它也是一个纯函数,与 mutations 测试享受同样待遇:纯净的输入输出,简易的测试准备。下面给一个先看一个稍微简单点的 getters 测试用例:
Vue组件和Vuex store的交互
TODO:
Vue应用测试策略
其实,上面的讨论中,从质量问题到单元测试的这个映射过程中,为了简化问题我有意地省略了一些中间步骤,那就是测试策略。简单来说,问这么两个问题:
关于可测试性这个概念。也许有人会说「Vue 和 vuex 的单元测试往往非常直观,几乎可以认为是多余的。的确,重要的不是代码容易测试,而是程序的结构非常简单,简单到单元测试都显得没有必要的地步。」这里我理解核心观点是,一个好的架构和分层可以让应用结构趋于简单,这可以让应用的某些层级非常简单直观,以至于有毛病时,依赖架构一眼就可以定位出,而不需要多余的测试。我完全同意这个观点,架构对于应用的可维护性至关重要。虽然,「结构简单到单元测试都显得没有必要」,肯定是乐观了。不管如何,结论是,还有其他的手段可以保证质量,比如架构就是重要的一个方面。它们与测试的关系不是互斥的,而是互相受益的。
第二个问题,当然不是。我们的系统中有各种各样的假设,可能需要使用不同的测试来覆盖它们。有的测试编写维护成本高,运行时间长、速度慢(如端到端测试、集成测试、UI 测试等);有的测试编写成本低,运行时间短、速度快(如单元测试、契约测试等)。视你的项目痛点不同,资源不同(人多或时间充裕),你可以决定使用不同比例的测试组合,它本质是个按收益-成本进行的决策,因此我们也叫测试策略。经典的测试策略是测试金字塔(看下图),说白了,结论是:你应该拥有更多接近底层次的测试,因为它们成本低速度快。而这个底层次的测试就是单元测试。这是我们需要单元测试的原因,因为它们编写成本最低,反馈速度最快,因而保护价值相对最大。
综上,我完成了 质量保证 -> 单元测试 的这个逻辑解释,主要的依据是测试金字塔。
单元测试的特点及其位置
组件测试其实是实践最多,争论也最多的地方。Vue 组件是一个高度自治的单元,从分类上来看,它大概有这么几类:
先把这个分类放在这里,待会回过头来谈。对于 Vue 组件测什么不测什么,我有一些思考,它是以「好的单元测试标准」作为原则出发考虑的:输入输出确定、不依赖于内部实现。先说结论:两测两不测。组件分支渲染逻辑必须测,事件调用和参数传递一般要测;纯 UI 不测,连接 vuex 的高阶组件不测;其他的一般不测(比如 CSS,官方文档有反例)。具体怎么得出这个结论的呢,分析如下:
connect
过的组件从测试的角度看无非四个点:mapState
中取得了正确的参数mapActions
中取得正确的参数这四个点,
vuex
已经都帮你测过了,已经证明 work 了,为啥要重复测试自寻烦恼呢?当然,不测这个东西的话,是有可能你 export 的纯组件测试都是过的,但是代码实际运行出错。穷尽下来主要可能是这几种问题:mapState
中打错了字或打错了变量名mapState
但没有 connect 上去mapState
中取的路径是错的,在 vuex store 中已经被改过第一、二种可能,无视。测试不是万能药,不能预防人主动犯错,这种场景如果是小步提交发现起来是很快的,如果不小步提交那什么测试都帮不了你的;如果发生得很频繁,解决方案大概有二:如果因为某段数据获取的逻辑多处重复,则可能应该被抽取到 selector 进行单独测试;如果某段数据获取就是经常敲错,那么可以专门 export 这段代码进行测试固定。
第三种可能,确实是问题,但发生频率目前看来较低。为啥呢,因为没有类型系统我们不会也不敢随意改 vuex store 的数据结构啊…(这侵入性重的框架哟)
综上,
@connect
组件不测,因为框架本身已做了大部分测试,剩下的场景出 bug 频率不高,而施加测试的话提高成本(准备依赖和数据),降低开发体验,模糊测试场景,性价比不大,所以强烈建议省了这份心。不测@connect
过的组件,其实也是 官方文档 推荐的做法。然后,基于上面第1、2个结论,映射回四类组件的结构当中去,我们可以得到下面的表格,然后发现…每种组件都要测渲染分支和事件调用,跟组件类型根本没必然的关联…不过,功能型组件有可能会涉及一些其他的模式,因此又大致分出一小节来谈。
@connect
单元测试的关注点
好的单元测试应该遵循这三点大的原则。如果在落地测试策略的过程中,发现测试很难维护、价值不大,请回过头来看这三条原则,并思考你的单元测试是否有改进空间、你的测试工具是否有改进空间。
快、稳定(比如,单元测试中不应该掉真实的 API、IndexDB 等)
仅对输入输出敏感(比如,不应该对执行次序敏感)
不关注(或极少关注)内部实现(只要输入输出没变,测试就不应该挂,从而保证重构进行)
遵循好这三条基本原则,可以很大程度提高测试的稳定性、降低维护成本,让其能为项目后续的重构和持续改进保驾护航,能作为活文档让所有团队成员阅读理解。
应用测试的测试策略
一个典型 Vue 项目上的应用架构一般包含以下几个层级,分别有不同的特点,因此也有不同的测试策略:
这里做一些补充。一是不测 connect 过的组件这个策略。理由是成本远高于收益:要牺牲开发体验(搞起来没那么快了),要配置依赖(配置 store、
<Provider />
,在大型或遗留系统中补测试还很可能遇到@connect
组件里套@connect
组件的场景);然后收益也只是可能覆盖到了几个极少数出现的场景。得不偿失,果断不测。另外,UI 测试这块,团队之前尝试过 snapshot 测试,然而在「保证 UI 不受非预期改变」这点上收益不大,成本也偏高。所以我个人持非常保留的态度,不推荐。
总结
好,总结下来,本文主要的内容如下:
单元测试对于 Vue 项目(及其他任何项目)来说都是必须的
在必须有测试的上下文中,推荐练习并使用 TDD 这种测试先行的方法获得快速反馈
之所以优先选择单元测试,是依据测试金字塔的成本收益比原则确定得到的
好的单元测试具备三大特征:仅对输入输出敏感、不依赖于内部实现、快且稳定
测试也有测试策略:在 Vue & Vuex 的典型架构下,一个测试体系大概分为五层:组件、action、mutation、getter、副作用层。它们分别的测试策略为:
@connect
过的高阶组件不测其他高级技巧:定制测试工具(
jest.extend
)、参数化测试(test.each()
)等The text was updated successfully, but these errors were encountered: