Skip to content
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

Closed
JimmyLv opened this issue Jul 21, 2018 · 3 comments
Closed

【草稿】Vue 应用单元测试的策略与实践 #323

JimmyLv opened this issue Jul 21, 2018 · 3 comments

Comments

@JimmyLv
Copy link
Owner

JimmyLv commented Jul 21, 2018

本文是【草稿】React 应用单元测试策略的姊妹篇。除此之外,还有两个原因:1. 上次在FCC社区讲TDD的时候说过单元测试的部分太干不适合讲,而是更适合写成博客文章作为技术参考;2. 公司内部已全线使用Vue技术栈作为产品开发的前端框架,而单元测试却因周期较紧而不得已暂且搁置。

本文主要尝试解决三个问题:

  1. TDD 做完Tasking列完实例化数据之后,完全没有UT基础不知道该怎么写单元测试?
  2. 在Vue应用的单元测试中,对UI组件和vuex store等测试的区别有何不同?颗粒度该细到什么程度?
  3. 测试收益如何最大化,如何配置高性价比的测试策略,即什么地方到底该花力气测试,什么地方又可以暂且放一放?

不谈论的包括:

  • ATT 验收测试 或 E2E 端到端测试,这个是我想进一步探索的话题,特别是在TDD的语境下 致Cypress:前端 TDD 的正确姿势到底是怎么样的? #322
  • 为什么要 TDD?但是我会讲为什么要 UT 单元测试。测试和TDD是两码事,而光是自动化测试的好处就已经足够多,但是如何做到更好的自动化和持续集成,那就需要TDD来指引方向。
  • Snapshot Testing 快照测试,其实我是很认可快照这种形式,但需要改进其工作流,至少结合Image Snapshot和Storybook等工具,甚至更应该放到CI上去,什么是驱动式的前端完美开发工作流? #311

------------分割线,先copy自React版再做重构------------

插播1:此处就有一种TDD的感觉,先在分割线之上定义问题(要解决的和不谈论的)就是Tasking;然后使用最快的方式回答问题,即copy其他地方的文字,取决于平常的阅读积累;然后再来进行重构,即审阅和相应的调整和修改。
插播2:与此同时,发扬敏捷精益的思想精髓,直接发在Issue里面作为草稿以供阅读,最快速度获取读者反馈,且有history作为文字变更的追踪;且通过🍅番茄工作法的方式,周期性地,迭代式得进行写作,停下来审视目标和回顾过去25分钟的成果是非常重要的,这个过程不仅可以让人脑获得休息,还可以激发新的灵感和创新,本质上就是人体活动,放在公司和团队当中则是一场社会活动。

下面我就来结合具体场景,进一步实例化这些问题,举几个🌰:

  1. TDD 做完Tasking列完实例化数据之后,完全没有UT基础不知道该怎么写单元测试?
// Given
一个完全没有UT基础的新人🚶
// When
当他🚶阅读和练习本文的Jest的部分
// Then
他能够把Given/When/Then的套路学会
他能够学会Jest的基本用法,包括测试suite和断言等语法
他能够学会Jest中测试异步的几种方式
  1. 在Vue应用的单元测试中,对UI组件和vuex store等测试的区别有何不同?颗粒度该细到什么程度?
// Given
一个有基本的UT知识但没写过Vue测试的新人🚶
// When
当他🚶阅读和练习本文的Vue单元测试的部分
// Then
当然,他能够学会Vue组件在测试当中的几种渲染方式
他能够学会UI组件的分类,特别是交互行为的测试方式
他能够对Vuex概念的理解更加深入,且知道 `Redux-like` 架构的好处
他能够合理测试vuex store的mutation和getter中的业务逻辑
他能够测试组件如何正确dispatch action以及action中如何做异步操作
  1. Vue项目中测试收益如何最大化,如何配置高性价比的测试策略,即什么地方到底该花力气测试,什么地方又可以暂且放一放?
// Given
一个具备UT基础但找不到着力点的求索之徒🐒
// When
当他🚶阅读本文的Vue应用测试策略部分
// Then
他能够找到测试的重点,重新燃起对UT的热情🔥
他能够在项目背景下合理配置单元测试的测试策略

插播3:至此,第一个🍅结束,看到上面👆定义好的问题感觉信心满满,瞬间可以产生本文的大纲,然后填补相应的内容即可。

于是乎,这就是本系列文章的大纲,先放出来给大家一个对于Vue应用单元测试的全局观:

## 单元测试基础

### 为什么选择 Jest
### Jest 的基本用法
### 该如何测试异步代码?
### 单元测试与自动化的意义

## Vue 单元测试

### Vue 组件的渲染方式
### Wrapper `find()` 方法与选择器
### UI 组件交互行为的测试

## Vuex 单元测试

### CQRS 与 `Redux-like` 架构
### 如何对 Vuex 进行单元测试
### Vue组件和Vuex store的交互

## Vue应用测试策略

### 单元测试的特点及其位置
### 单元测试的关注点
### 应用测试的测试策略

-------------正文终于开始-----------

单元测试基础

引用好友鲜明的观点就是:写不好是能力问题,不写则是态度问题。单元测试客观上可以让开发者的工作更高效,Vue 应用的单元测试是一定要的。

单元测试的上下文

谈任何东西都一定要有个上下文。你的论述不能是「因为单元测试有这些好处,所以我们要做单元测试」,而应该是「不做单元测试我们会遇到什么问题」,这样才能回答「为什么要写单元测试」的问题。那么我们谈论单元测试的上下文是什么呢?不做单元测试我们会遇到什么问题呢?

lead-time

agile

上图为一个产品从 idea 分析、设计、开发、测试到交付并获取市场反馈的过程。

单元测试的上下文就是存在于「敏捷」当中。敏捷为的是更快地交付有价值的可工作的软件。为此,它有一个指标来度量这个「更快」,那就是 lead time,它度量的是一个 idea 从提出被验证,到最终上生产环境面对用户的时间。显然,这个时间越短,软件获得反馈的时间就越短,对价值的验证就越快发生。

单元测试与自动化的意义

这个结论对我们写不写单元测试有什么影响呢?答案是,不写单元测试,你就快不起来。为啥呢?因为每次发布,你都要投入人力来进行手工测试;因为没有测试,你倾向于不敢随意重构,这又导致代码逐渐腐化,复杂度使得你的开发速度降低。

那么在这个上下文中来谈要不要单元测试,我们就可以很有根据了,而不是开发爽了就用,不爽就不用这样含糊的答案:

  • 如果你说我的业务部门不需要频繁上线,并且我有足够的人力来覆盖手工测试,那你可以不用单元测试
  • 如果你说我不在意代码腐化,并且我也不做重构,那你可以不用单元测试
  • 如果你说我不在意代码质量,好几个没有测试保护的 if-else 裸奔也不在话下,脑不好还做什么程序员,那你可以不用单元测试
  • 如果你说我确有快速部署的需求,但我们不 care 质量问题,出回归问题就修,那你可以不用单元测试

除此之外,你就需要写单元测试。如果你想随时整理重构代码,那么你需要写单元测试;如果你想有自动化的测试套件来帮你快速验证提交的完整性,那么你需要写单元测试。综上,我用以谈单元测试的「透镜」是什么呢?一言以蔽之,两点:反馈速度自动化

自动化回答的是要不要自动化的单元测试这个问题。测试是重构的唯一保障,也就是说,没有测试,基本上就没法重构代码(重构指的是 不改变软件可观测行为的前提下改善代码内部设计或实现 ),基本上就只能看着代码腐化。那么,基本上只要你的系统需要持续发展,你就需要单元测试。

反馈速度回答的是要不要 TDD、测试先行还是后补这个问题。我认为,要 TDD,最好先行,因为可以提高反馈速度

至此回答了「为什么我们需要写单元测试」的问题。下面谈谈如何写好 JavaScript 和 Vue 框架的单元测试。

为什么选择 Jest

引自技术雷达:Jest是一个“零配置”的前端测试工具,具有诸如模拟和代码覆盖之类的开箱即用特性,主要用于React和其他JavaScript框架。
我们团队对采用JEST做前端测试的结果非常满意。它提供了一种“零配置”的开发体验,并具备诸多开箱即用的功能,比如 Mock 和代码覆盖率等。你不仅可以将此测试框架应用于React.js应用程序,也可以应用于其他 JavaScript 框架。Jest 经常被炒作的功能之一是用户界面的快照测试。快照测试可以作为测试金字塔上层一个很好的补充,但请记住,单元测试仍然是坚实的基础。

Jest 的几大好处可以涵盖为:

  • Fast 天下武功,唯快不破。确实很快,虽然实测下来跟Mocha 4还是慢了些,找个机会再测一次。
  • Opinionated 不需要你做出选择和配置,就能提供所有的东西,比如Mock(干掉Sinon)、Test Runner(干掉Karma)、Matcher(干掉Chai)、Test Coverage(内置istanbul)
  • Watch Mode 注重开发者体验,快速获得测试结果的反馈。
  • Snapshot Testing 这一点是值得争议的一点,前文也提到过会专门开个issue来讨论,在此不再赘述。

总结一下,Jest 最大的特点是它是一个非常有效的解决方案,不需要与其他测试库交互来执行它的工作。与此同时 Jest 非常注重开发者体验,这一点也是特别值得欣赏,现在市面上关注开发者(“人”)体验的开发框架和工具实在不多,而Jest Watch模式的核心就在于快速获得反馈,虽然我没在命令行使用而是WebStorm但亦可以与之结合。除此之外,还有很多开发者体验亦值得细细品味与发现,特别是Jest本身来自Facebook的工程化支持也是特别棒的,这个讲述如何开发Jest的官方视频值得一看:Building High-Quality JavaScript Tools

插播4: 刚刚过去的一个🍅迭代感觉特别快,网速和CPU转动都对专注力有所影响,所以在查找资料的时候耗费了一些不必要的时间,同时自己的打字速度也貌似到了瓶颈,属于“又燃起想要学习双拼的冲动”的那种。但从成果上来说还是完成了大概1/6,特别感谢从羽的文章和自己之前沉淀的一些东西,产出Action则是可以考虑把读过的文章和备查的文章都缓存下来,这样就不用受限于电脑网速了。⚡️

Jest 的基本用法

首先创建 jest-demo 项目并安装 jest 作为项目 devDependencies 依赖:

mkdir jest-demo && cd $_
yarn init -y #--yes
yarn add jest -D #--dev

然后让我们写第一个测试。创建一个 math.js 文件,输入一个我们稍后测试的 sum 函数:

const sum = (a, b) => a + b

module.exports = { sum }

现在在同一个文件夹中创建一个 math.test.js 文件,在这里我们将使用 Jest 来测试 math.js 中定义的函数:

const { sum } = require('./math')

describe('Match module', () => {
  it("should return sum result when one number plus another number", () => {
    // Given
    const number = 1
    const anotherNumber = 2
    // When
    const result = sum(number, anotherNumber)
    // Then
    expect(result).toBe(2)
  })
})

然后运行 yarn test 你就可以看到相应的结果。

麻雀虽小五脏俱全,在上面的例子当中,我们可以看到很多的测试元素,下面我们将会来一一介绍:

首先我们看到的是一个由 it 包裹的测试主体最小单元,采用了Given When Then的经典格式,我们常常称之为测试三部曲:

GWT 3A 说明
Given Arrange 准备测试测试数据,有时可以抽取到 beforeEach
When Act 采取行动,一般来说就是调用相应的模块执行对应的函数或方法
Then Assert 断言,这时需要借助的就是Matchers的能力,Jest还可以扩展自己的Matcher
  • Matchers
expect(1+1).toBe(2)
expect(1+1).not.toBe(3)

修改断言的结果,就可以看到成功后的结果了:

Fake/Stub/Mock/Spy

  • Database
  • Network requests
  • access to Files
  • any External system
  • Mock
import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return {playSoundFile: mockPlaySoundFile};
  });
});
  • Stub
const mockFn = jest.fn();
mockFn();
expect(mockFn).toHaveBeenCalled();

// With a mock implementation:
const returnsTrue = jest.fn(() => true);
console.log(returnsTrue()); // true;
  • Spy

Spy packages without affecting the functions code

const video = require('./video');

it('plays video', () => {
  const spy = jest.spyOn(video, 'play');
  const isPlaying = video.play();

  expect(spy).toHaveBeenCalled();
  expect(isPlaying).toBe(true);
})
  • Mock global stuff
window.matchMedia = jest.fn().mockImplementation(query => {
  return {
    matches: false,
    media: query,
    onchange: null,
    addListener: jest.fn(),
    removeListener: jest.fn(),
  };
});

该如何测试异步?

  • 异步请求
navigator.geolocation.getCurrentPostion() # chrome API 异步获取当前位置
  • Callback
it('the data is peanut butter', done => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done();
  }

  fetchData(callback);
});
  • Promise
it('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});
expect(Promise.resolve('lemon')).resolves.toBe('lemon')

expect(Promise.reject(new Error('octopus'))).rejects.toThrow('octopus')
  • Async/Await
test('the data is peanut butter', async () => {
  expect.assertions(1);
  const data = await fetchData();
  expect(data).toBe('peanut butter');
});

更加Jest相关的内容可以查看这篇文章 Testing JavaScript with Jest,与此同时具体的 API 可以参考官方文档

Vue 单元测试

在组件化出现之前,我们不谈 UI 的单元测试,哪怕是对于 UI 页面的测试来说都是一件非常困难的事情。其实组件化并不全是为了复用,很多情况下也恰恰是为了分治,从而我们可以分组件对 UI 页面进行开发,然后分别对其进行单元测试。

前端组件化已经让 UI 测试变得容易很多,每个组件都可以被简化为这样一个表达式,即 UI = f(data),这个纯函数返回的只是一个描述 UI 组件应该是什么样子的虚拟 DOM,本质上就是一个树形的数据结构。给这个纯函数输入一些应用程序的状态,就会得到相应的 UI 描述的输出,这个过程不会去直接操作实际的 UI 元素,也不会产生所谓的副作用。

Vue 组件树的测试

按理来说按照纯函数这样的思路,Vue 组件的测试应该很简单的说。但与此同时对于(渲染出 UI 的)组件树进行测试依然存在一个问题,从下图中可以看出,越处于上层的组件,其复杂度必然会随之提高。对于最底层的子组件来说,我们可以很容易得将其进行渲染并测试其逻辑的正确与否,但对于较上层的父组件来说,通常来说就需要对其所包含的所有子组件都进行预先渲染,甚至于最上面的组件需要渲染出整个 UI 页面的真实 DOM 节点才能对其进行测试,这显然是不可取的。

Components-Tree

In unit tests, we typically want to focus on the component being tested as an isolated unit and avoid indirectly asserting the behavior of its child components.
In addition, for components that contain many child components, the entire rendered tree can get really big. Repeatedly rendering all child components could slow down our tests.

浅渲染(Shallow Rendering)解决了这个问题,也就是说在我们针对某个上层组件进行测试时,可以不用渲染它的子组件,所以就不用再担心子组件的表现和行为,这样就可以只对特定组件的逻辑及其渲染输出进行测试了。Vue 官方提供了 @vue/test-utils 可以让我们使用浅渲染这个特性,用于测试虚拟 DOM 对象,即 Vue.component 的实例。

import { shallowMount } from '@vue/test-utils'

const wrapper = shallowMount(Component)
wrapper.vm // the mounted Vue instance

Vue 组件的渲染方式

shallowMount(component[, options]) => Wrapper

浅渲染在将一个组件作为一个单元进行测试的时候非常有用,可以确保你的测试不会去间接断言子组件的行为。shallowMount 方法就是 Shallow Rendering 的封装,shallowMountmount 类似返回 mountedrendered Vue 组件的 Wrapper,但只会渲染出组件的第一层 DOM 结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试的速度也会更快。

import { shallowMount } from '@vue/test-utils'

describe('Vue Component shallowMount', () => {
  it('should have three <todo /> components', () => {
    const wrapper = shallowMount(App)
    expect(wrapper.find({ name: 'Todo' })).toHaveLength(3)
  })
}

mount(component[, options]) => Wrapper

mount 方法则会将 Vue 组件渲染为真实的 DOM 节点,特别是在你依赖真实的 DOM 结构必须存在的情况下,比如说按钮的点击事件。完全的 DOM 渲染需要在全局范围内提供完整的 DOM API, 这也就意味着 Vue Test Utils 依赖于浏览器环境。

从技术上讲,你可以在真实的浏览器中运行,但由于在不同平台上启动真实浏览器的复杂性,更建议使用 JSDOM 在虚拟浏览器环境中运行 Node 中的测试。推荐使用 mount 的方法是依赖于一个名为 jsdom的库,它本质上是一个完全在 JavaScript 中实现的 headless 浏览器。

import { mount } from '@vue/test-utils'

describe('Vue Component Mount', () => {
  it('should delete Todo when click button', () => {
    const wrapper = mount(App)
    const todoLength = wrapper.find('li').length
    wrapper.find('button.delete').at(0).trigger('click')
    expect(wrapper.find('li').length).toEqual(todoLength - 1)
  })
})

render(component[, options]) => CheerioWrapper

render 方法则会将 Vue 组件渲染成静态的 HTML 字符串,返回的是一个 Cheerio 实例对象,采用的是一个第三方的 HTML 解析库 Cheerio,这是一个类 jQuery 的库,可以在 Node.js 中遍历 DOM。渲染后所返回的 CheerioWrapper 可以用于分析最终结果的 HTML 代码结构,它的 API 跟 shallowMountmount 方法的 API 都保持基本一致。

import { render } from '@vue/test-utils'

describe('Vue Component Render', () => {
  it('should not have .todo-done class', () => {
    const wrapper = render(App)
    expect(wrapper.find('.todo-done').length).toEqual(0)
    expect(wrapper.text()).toContain('<div class="todo"></div>')
  })
})

renderToString(component[, options]) => string

renderToString 很简单,顾名思义就是把一个组件渲染成对应的 HTML 字符串,在此不再赘述。

import { renderedString } from '@vue/test-utils'

describe('Vue Component renderedString', () => {
  it('should have .todo class', () => {
    const renderedString = renderToString(App)
    expect(renderedString).toContain('<div class="todo"></div>')
  })
})

Wrapper find() 方法与选择器

从前面的示例代码中可以看到,无论哪种渲染方式所返回的 wrapper 都有一个 .find() 方法,它接受一个 selector 参数,然后返回一个对应的 wrapper 对象。而 .findAll() 则会返回一个类型相同的 wrapper 对象数组,里面包含了所有符合条件的子组件。在这个对象数组的基础上,at 方法则可以返回指定位置的子组件,trigger 方法用于在组件之上模拟触发某种行为。

@vue/test-utils 中的 Selectors 即选择器,既可以是 CSS 选择器(也支持比较复杂的关系选择器组合),也可以是 Vue 组件 或是一个 option 对象,以便于在 wrapper 对象中可以轻松地指定想要查找的节点。

/* CSS Selector */
wrapper.find('.foo') //class syntax
wrapper.find('input') //tag syntax
wrapper.find('#foo') //id syntax 
wrapper.find('[foo="bar"]') //attribute syntax
wrapper.find('div:first-of-type') //pseudo selectors

在下面的示例中,我们可以通过 Vue 组件构造函数的引用找到该组件,与此同时也可以基于 Vue 组件属性的子集来查找组件和节点。

/* Component Constructor */
import foo from './foo.vue'

const wrapper = shallowMount(app)
expect(wrapper.find(foo).is(foo)).toBe(true)

/* Find Option Object */
const wrapper = wrapper.find({ name: 'my-button' })
wrapper.trigger('click')

UI 组件交互行为的测试

我们不但可以通过 find 方法查找 DOM 元素,还可以通过 trigger 方法在组件上模拟触发某个 DOM 事件,比如 Click,Change 等等。对于浅渲染来说,事件模拟并不会像真实环境中所预期的那样进行传播,因此我们必须在一个已经设置好了事件处理方法的实际节点上才能够调用,实际上 .trigger() 方法将会根据模拟的事件触发这个组件的 prop。例如,.trigger('click') 实际上会获取 对应的 clickHandler propsData 并调用它。

it('should trigger event when click button', () => {  
  const clickHandler = jest.fn()
  const wrapper = shallowMount(Foo, {
    propsData: { clickHandler }
  })
  wrapper.trigger('click')
  expect(clickHandler).toHaveBeenCalled()
})

Vuex 单元测试

Vuex is a state management pattern + library for Vue.js applications. It serves as a centralized store for all the components in an application, with rules ensuring that the state can only be mutated in a predictable fashion.

古人说「读史让人明智」,学习历史是为了更好得前行,为了能够认识现在,看清未来。让我们来看看 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:

  1. 如果一个方法修改了这个对象的状态,那就是一个 command(命令),并且一定不能返回值。
  2. 如果一个方法返回了一些值,那就是一个 query(查询),并且一定不能修改状态。

如何对 Vuex 进行单元测试

mutations 测试

Mutation 很容易被测试,因为它们仅仅是一些完全依赖参数的函数。最为简单的 mutation 测试,仅一一对应保存数据切片。此种 mutation 可以不需要测试覆盖,因为基本由架构简单和逻辑简单保证,不需要靠读测试用例来理解。而一个较为复杂、具备测试价值的 mutation 在保存数据的同时,还可能进行了合并、去重等操作。

// count.js
const state = { ... }
const actions = { ... }
export const mutations = {
  increment: state => state.count++
}
// count.test.js
import { mutations } from './store'

// 解构 `mutations`
const { increment } = mutations

describe('mutations', () => {
  it('INCREMENT', () => {
    // 模拟状态
    const state = { count: 0 }
    // 应用 mutation
    increment(state)
    // 断言结果
    expect(state.count).toEqual(1)
  })
})

actions 测试

Action 应对起来略微棘手,因为它们可能需要调用外部的 API。当测试 action 的时候,我们需要增加一个 mocking 服务层——例如,我们可以把 API 调用抽象成服务,然后在测试文件中用 mock 服务回应 API 调用。

// product.js
import shop from '../api/shop'

export const actions = {
  getAllProducts({ commit }) {
    commit('REQUEST_PRODUCTS')
    shop.getProducts(products => {
      commit('RECEIVE_PRODUCTS', products)
    })
  }
}
// product.test.js
jest.mock('../api/shop', () => ({
  getProducts: jest.fn(() => /* mocked response */),
}))

describe('actions', () => {
  it('getAllProducts', () => {
    const commit = jest.spy()
    const state = {}
    
    actions.getAllProducts({ commit, state })
    
    expect(commit.args).toEqual([
      ['REQUEST_PRODUCTS'],
      ['RECEIVE_PRODUCTS', { /* mocked response */ }]
    ])
  })
})

getters 测试

getter 的测试与 mutation 一样直截了当。getters 是重逻辑的地方,并且它也是一个纯函数,与 mutations 测试享受同样待遇:纯净的输入输出,简易的测试准备。下面给一个先看一个稍微简单点的 getters 测试用例:

// product.js
export const getters = {
  filteredProducts (state, { filterCategory }) {
    return state.products.filter(product => {
      return product.category === filterCategory
    })
  }
}
// product.test.js
import { expect } from 'chai'
import { getters } from './getters'

describe('getters', () => {
  it('filteredProducts', () => {
    // 模拟状态
    const state = {
      products: [
        { id: 1, title: 'Apple', category: 'fruit' },
        { id: 2, title: 'Orange', category: 'fruit' },
        { id: 3, title: 'Carrot', category: 'vegetable' }
      ]
    }
    // 模拟 getter
    const filterCategory = 'fruit'

    // 获取 getter 的结果
    const result = getters.filteredProducts(state, { filterCategory })

    // 断言结果
    expect(result).to.deep.equal([
      { id: 1, title: 'Apple', category: 'fruit' },
      { id: 2, title: 'Orange', category: 'fruit' }
    ])
  })
})

Vue组件和Vuex store的交互


TODO:

Vue应用测试策略

其实,上面的讨论中,从质量问题到单元测试的这个映射过程中,为了简化问题我有意地省略了一些中间步骤,那就是测试策略。简单来说,问这么两个问题:

  • 保证质量,一定要通过测试吗?
  • 如果需要测试,一定要是单元测试吗?

关于可测试性这个概念。也许有人会说「Vue 和 vuex 的单元测试往往非常直观,几乎可以认为是多余的。的确,重要的不是代码容易测试,而是程序的结构非常简单,简单到单元测试都显得没有必要的地步。」这里我理解核心观点是,一个好的架构和分层可以让应用结构趋于简单,这可以让应用的某些层级非常简单直观,以至于有毛病时,依赖架构一眼就可以定位出,而不需要多余的测试。我完全同意这个观点,架构对于应用的可维护性至关重要。虽然,「结构简单到单元测试都显得没有必要」,肯定是乐观了。不管如何,结论是,还有其他的手段可以保证质量,比如架构就是重要的一个方面。它们与测试的关系不是互斥的,而是互相受益的。

第二个问题,当然不是。我们的系统中有各种各样的假设,可能需要使用不同的测试来覆盖它们。有的测试编写维护成本高,运行时间长、速度慢(如端到端测试、集成测试、UI 测试等);有的测试编写成本低,运行时间短、速度快(如单元测试、契约测试等)。视你的项目痛点不同,资源不同(人多或时间充裕),你可以决定使用不同比例的测试组合,它本质是个按收益-成本进行的决策,因此我们也叫测试策略。经典的测试策略是测试金字塔(看下图),说白了,结论是:你应该拥有更多接近底层次的测试,因为它们成本低速度快。而这个底层次的测试就是单元测试。这是我们需要单元测试的原因,因为它们编写成本最低,反馈速度最快,因而保护价值相对最大。

image

综上,我完成了 质量保证 -> 单元测试 的这个逻辑解释,主要的依据是测试金字塔。

单元测试的特点及其位置

组件测试其实是实践最多,争论也最多的地方。Vue 组件是一个高度自治的单元,从分类上来看,它大概有这么几类:

  • 展示型业务组件
  • 容器型业务组件
  • 通用 UI 组件
  • 功能型组件

先把这个分类放在这里,待会回过头来谈。对于 Vue 组件测什么不测什么,我有一些思考,它是以「好的单元测试标准」作为原则出发考虑的:输入输出确定、不依赖于内部实现。先说结论:两测两不测。组件分支渲染逻辑必须测,事件调用和参数传递一般要测;纯 UI 不测,连接 vuex 的高阶组件不测;其他的一般不测(比如 CSS,官方文档有反例)。具体怎么得出这个结论的呢,分析如下:

  • 纯 UI 不测:因为 UI 既不好 TDD,断言起期望结果来也不如直接看来得直观。所谓快照测试有意义的前提在于,对比方式必须是视觉级别的,像 snapshot 这种 UI 测试跟提交的时候看 Git 我觉得没差别,价值不大,还打乱工作流。因此,我认为 UI 测试诉诸手工,在中等规模的项目上看起来还是可行的
  • 连接 vuex 的高阶组件不测。理由如下详述
  • 组件分支渲染逻辑必须测:这种东西相对还是好断言的,并且分支逻辑往往具有业务意义,测试顺便能做为文档使用
  • 事件调用和参数传递一般要测:函数调用作为组件的一个「行为」,往往也有业务意义,这部分测试在重构的时候也能提供相当的保护价值

connect 过的组件从测试的角度看无非四个点:

  • 是否从 mapState 中取得了正确的参数
  • 是否从 mapActions 中取得正确的参数
  • 是否正确地被传递给了组件
  • vuex 对应的数据切片更新时是否会触发组件进行一次 props 更新

这四个点,vuex 已经都帮你测过了已经证明 work 了,为啥要重复测试自寻烦恼呢?当然,不测这个东西的话,是有可能你 export 的纯组件测试都是过的,但是代码实际运行出错。穷尽下来主要可能是这几种问题:

  • 你在 mapState 中打错了字或打错了变量名
  • 你写了 mapState 但没有 connect 上去
  • 你在 mapState 中取的路径是错的,在 vuex store 中已经被改过

第一、二种可能,无视。测试不是万能药,不能预防人主动犯错,这种场景如果是小步提交发现起来是很快的,如果不小步提交那什么测试都帮不了你的;如果发生得很频繁,解决方案大概有二:如果因为某段数据获取的逻辑多处重复,则可能应该被抽取到 selector 进行单独测试;如果某段数据获取就是经常敲错,那么可以专门 export 这段代码进行测试固定。

第三种可能,确实是问题,但发生频率目前看来较低。为啥呢,因为没有类型系统我们不会也不敢随意改 vuex store 的数据结构啊…(这侵入性重的框架哟)

综上,@connect 组件不测,因为框架本身已做了大部分测试,剩下的场景出 bug 频率不高,而施加测试的话提高成本(准备依赖和数据),降低开发体验,模糊测试场景,性价比不大,所以强烈建议省了这份心。不测 @connect 过的组件,其实也是 官方文档 推荐的做法。

然后,基于上面第1、2个结论,映射回四类组件的结构当中去,我们可以得到下面的表格,然后发现…每种组件都要测渲染分支事件调用,跟组件类型根本没必然的关联…不过,功能型组件有可能会涉及一些其他的模式,因此又大致分出一小节来谈。

组件类型 / 测试内容 分支渲染逻辑 事件调用 @connect 纯 UI
展示型组件 - ✖️
容器型组件 ✖️ ✖️
通用 UI 组件 - ✖️
功能型组件 ✖️ ✖️

单元测试的关注点

好的单元测试应该遵循这三点大的原则。如果在落地测试策略的过程中,发现测试很难维护、价值不大,请回过头来看这三条原则,并思考你的单元测试是否有改进空间、你的测试工具是否有改进空间。

快、稳定(比如,单元测试中不应该掉真实的 API、IndexDB 等)
仅对输入输出敏感(比如,不应该对执行次序敏感)
不关注(或极少关注)内部实现(只要输入输出没变,测试就不应该挂,从而保证重构进行)
遵循好这三条基本原则,可以很大程度提高测试的稳定性、降低维护成本,让其能为项目后续的重构和持续改进保驾护航,能作为活文档让所有团队成员阅读理解。

应用测试的测试策略

一个典型 Vue 项目上的应用架构一般包含以下几个层级,分别有不同的特点,因此也有不同的测试策略:

架构层级 测试内容 测试策略 解释
action(creator) 层 是否正确创建 action 对象 一般不需要测试,视信心而定 这个层级非常简单,基础设施搭好以后一般不可能出错,属于架构带来的简单性
mutation 层 是否正确完成计算 对于有逻辑的 mutation 需要100%覆盖率 这个层级 input -> output 明确,又有业务逻辑的计算在内,天然属于单元测试宠爱的对象
getter 层 是否正确完成计算 对于有复杂逻辑的 getter 需要100%覆盖率 这个层级 input -> output 明确,又有复杂的业务逻辑计算,天然属于单元测试宠爱的对象
saga(副作用) 层 是否获取了正确的参数去调用 API,并使用正确的数据存取回 redux 中 对于是否获取了正确参数、是否调用正确的 API、是否使用了正确的返回值保存数据、业务分支逻辑、异常分支需要100%覆盖 这个层级也有业务逻辑,对前面所述的5大方面进行测试很有重构价值
component(组件接入) 层 是否渲染了正确的组件
    • 组件的分支渲染逻辑要求100%覆盖
    • 交互事件的调用参数一般要求100%覆盖
    • 被 connect 过的组件不测
    • 纯 UI 不测
    • CSS 不测
    这个层级最为复杂,测试策略还是以「代价最低,收益最高」为原则进行
    UI 层 样式是否正确 目前不测 这个事情关键目前我理解在于,能不能提供可视化的快照对比,哪怕是成本高一些

    这里做一些补充。一是不测 connect 过的组件这个策略。理由是成本远高于收益:要牺牲开发体验(搞起来没那么快了),要配置依赖(配置 store、 <Provider />,在大型或遗留系统中补测试还很可能遇到 @connect 组件里套 @connect 组件的场景);然后收益也只是可能覆盖到了几个极少数出现的场景。得不偿失,果断不测。

    另外,UI 测试这块,团队之前尝试过 snapshot 测试,然而在「保证 UI 不受非预期改变」这点上收益不大,成本也偏高。所以我个人持非常保留的态度,不推荐。

    总结

    好,总结下来,本文主要的内容如下:

    • 单元测试对于 Vue 项目(及其他任何项目)来说都是必须的

    • 在必须有测试的上下文中,推荐练习并使用 TDD 这种测试先行的方法获得快速反馈

    • 之所以优先选择单元测试,是依据测试金字塔的成本收益比原则确定得到的

    • 好的单元测试具备三大特征:仅对输入输出敏感不依赖于内部实现快且稳定

    • 测试也有测试策略:在 Vue & Vuex 的典型架构下,一个测试体系大概分为五层:组件、action、mutation、getter、副作用层。它们分别的测试策略为:

      • mutation、getter 的重逻辑代码要求100%覆盖
      • 副作用层主要测试:是否拿到了正确的参数是否调用了正确的 API是否保存了正确的数据业务逻辑异常逻辑 五个层面
      • 组件层两测两不测:分支渲染逻辑事件、交互调用 必测;纯 UI、@connect 过的高阶组件不测
      • action 层选择性覆盖:可不测
    • 其他高级技巧:定制测试工具(jest.extend)、参数化测试(test.each())等

    @JimmyLv
    Copy link
    Owner Author

    JimmyLv commented Jul 21, 2018

    JimmyLv added a commit that referenced this issue Jul 22, 2018
    JimmyLv added a commit that referenced this issue Jul 22, 2018
    @JimmyLv JimmyLv changed the title 如何做到 Vue 应用单元测试策略的最佳实践? Vue 应用单元测试的策略与实践 Aug 28, 2018
    @JimmyLv JimmyLv changed the title Vue 应用单元测试的策略与实践 【草稿】Vue 应用单元测试的策略与实践 Aug 28, 2018
    JimmyLv added a commit that referenced this issue Sep 19, 2018
    JimmyLv added a commit that referenced this issue Sep 19, 2018
    @JimmyLv
    Copy link
    Owner Author

    JimmyLv commented Sep 19, 2018

    第一篇已首发于 Vue 应用单元测试的策略与实践 01 - 前言 | 吕立青的博客


    欢迎关注知乎专栏 —— 前端的逆袭(凡可 JavaScript,终将 JavaScript。)

    欢迎关注我的博客知乎GitHub掘金

    JimmyLv added a commit that referenced this issue Oct 29, 2018
    JimmyLv added a commit that referenced this issue Oct 29, 2018
    JimmyLv added a commit that referenced this issue Oct 29, 2018
    JimmyLv added a commit that referenced this issue Oct 29, 2018
    JimmyLv added a commit that referenced this issue Oct 29, 2018
    JimmyLv added a commit that referenced this issue Oct 30, 2018
    JimmyLv added a commit that referenced this issue Oct 30, 2018
    JimmyLv added a commit that referenced this issue Oct 30, 2018
    JimmyLv added a commit that referenced this issue Oct 30, 2018
    JimmyLv added a commit that referenced this issue Nov 2, 2018
    JimmyLv added a commit that referenced this issue Nov 2, 2018
    JimmyLv added a commit that referenced this issue Nov 2, 2018
    @JimmyLv JimmyLv pinned this issue Mar 7, 2019
    @JimmyLv JimmyLv self-assigned this Mar 21, 2019
    @JimmyLv JimmyLv added this to the 🏂2019 Q2 OKRs(4~6月) milestone May 23, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    JimmyLv added a commit that referenced this issue Jul 16, 2019
    @JimmyLv JimmyLv closed this as completed Jul 16, 2019
    @JimmyLv JimmyLv unpinned this issue Jul 23, 2019
    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
    Labels
    None yet
    Projects
    None yet
    Development

    No branches or pull requests

    1 participant