React全家桶与前端单元测试艺术

TL;DR——什么是好的单元测试?

其实我是个标题党,单元测试根本没有“艺术”可言。

好的测试来自于好的代码,如果说有艺术,那也是代码的艺术。

注:以下“测试”一词,如非特指均为单元测试。

单元测试的好坏在于“单元”而不在“测试”。如果一个系统毫无单元可言,那就没法进行单元测试,几乎只能用Selenium做大量的E2E测试,其成本和稳定性可想而知。科学的单元划分可以让你摆脱mock,减少依赖,提高并行度,不依赖实现/易重构,提高测试对业务的覆盖率,以及易学易用,大幅减少测试代码。

最好的单元是返回简单数据结构的函数:函数是最基本的抽象,可大可小,不需要mock,只依靠传参。简单数据结构可以判等。 最好的测试工具是Assert.Equal这种的:只是判等。判等容易,判断发生了什么很难。你可以看到后面对于DOM和异步操作这些和副作用相关的例子都靠判等测试。把作用幂等于数据,拿到数据就一定发生作用,然后再测数据,是一个基本思路。

以上是你以前学习测试第一天就会的内容,所以不存在门槛。

为什么不谈TDD?

首先,TDD肯定是有价值的(价值大小不论)。反对TDD的原因一般比较明显,对于TDD是否带来正收益不确定(动机不足)。 某些项目质量要求很高,预算宽绰,TDD势在必行。某些项目比较紧急,或者并非关键或无长期维护计划,TDD理由就不充分。

为什么谈测试?

因为测试难。

第一难学,第二难写。写测试是个挺困难的活,要在测试里正确重演业务要费好大劲,只能靠反复练习。虽然这些测试在某些项目中是值得的,但是可能并不适合其他某些项目的基本情况。

测试难,就代表训练成本高,生产成本也高,收益就下降。要提高采用TDD的动机,与其说服别人,不如从简化测试开始。

(图片来自:http://t.cn/Rpw9WKg

为什么谈前端测试?

一般项目都是后端测试覆盖率高,同时后端套路也比较固定。测RESTful API粒度足够大,可以很好地避开实现并且覆盖业务。同时RESTful API一般也正好对应Web框架的Action handler,在这里同时它粒度也足够小,刚好可以直接调用而不启动真的Web server,使得测试最大程度并行化。所以这样测试收益总是最高的,争议很小。

前端不说套路不固定,测不测都有待商榷。因为前端流派不统一,资源不规则,边界也不清晰,有渲染又有点业务,有导航有请求,很多团队不测试/测Model/测Component/测E2E,五花八门。 但得益于JavaScript本身,前端测试其实是可以非常高效的。

下面你可以看到各种极简极快的测试工具和测试方式,并且它们完全可以贯穿开发始终,而非仅给Hello World体量项目准备的,你可以在很大的全家桶项目中完全机械地套用这些方法。(机械也是极限的一部分,你不应该在使用工具过程中面临太多抉择,而应当专注于将业务翻译成测试)。

为什么谈React全家桶?

前端从每周刷新一个框架,稳定到了Angular, React, Vue3个主流框架并存的阶段。网络中争论这三个框架盖的楼已经可以绕太阳系了。根据盖的各种大楼看来,现在哪个更优秀还没个定论。不过具体到单元测试方面,得益于Virtual DOM本身和模块化设计(不然全家桶白叫了),React全家桶明显更优秀些。

测试工具

我们本篇中的测试有三个目标:学得快,写得快,跑得快

(图片来自:http://t.cn/RpwCke3

平台上Selenium, Phantom, Chrome, 包括Karma都比较重,最好的测试框架就是直接跑在node上的。本着极限编程的原则,我们将测试本身和测试环境尽可能简化,以达到加快测试速度,最终反馈到开发速度的目的。

我们使用AVA进行测试,它非常简洁,速度非常快,和mocha不同,它默认会启动多线程并发测试。因此我们的测试必须减少共享状态来提高并发能力,不然就会出现意想不到的错误。安装和运行:

yarn add ava
ava --watch

这样可以运行并watch测试。改变代码测试结果会立刻改变,你也可以看到友善的错误信息,以及expected和actual之间的diff。写下第一段测试:

import test from 'ava'

test(t => {
  t.is(1 + 1, 2)
})

除了is方法以外,我们还会用到deepEqual和true方法。好,你现在已经完全会用AVA了。其他的功能我们完全不关心。

Redux测试 (Model测试)

Redux就是用一堆Reducer函数来reduce所有事件用来做全局Store的状态机(FSM)。用源码本身介绍它甚至比用上一小段文字介绍还快:

const createStore = reducer => {
  let state, listeners = []

  const dispatch = action => {
    state = reducer(state, action)
    listeners.forEach(listeners => listeners())
  }

  return {
    getState() { return state },
    subscribe(listener) {
      listeners.push(listener)
      return () => { listeners = listeners.filter(l => l !== listener)}
    },
    dispatch,
  }
}

这是一个简化版的代码,去掉了抛错等等细节,但功能是完整的。把你自己写的reducer扔进去,然后可以发事件来使其更新,你还可以订阅它来拿状态。有点像Event Sourcing,以消息而非调用来处理逻辑,更新和订阅的逻辑不在一起(事件是写模型,各种view就是多个读模型)。

reducer几乎包括了我们所有前端业务的核心,测好它就测了大半。它们全都是(State, Action) => nextState形式的纯函数,无异步操作,用swtich case来模拟模式匹配来处理事件。比如用喜闻乐见的简陋版的栈停车场举例:

export const parkingLot = (state = [], action) => {
  switch (action.type) {
    case 'parkingLot/PARK':
      return [action.car, ...state]
    case 'parkingLot/PICK':
      const [_, ...rest] = state
      return rest
    default: return state
  }
}

Reducer是这么用的:

const store = createStore(parkingLot)
store.subscribe(() => renderMyView(store.getState()))
store.dispatch({ type: 'parkingLot/PARK' })

好,现在你又理解了Redux。那我们可以看看怎么测试上面的parkingLot reducer了:

test('parking lot', t => {
  const initial = parkingLot(undefined, {})
  t.deepEqual(initial, [], 'should be empty when init')

  const parked = parkingLot(initial, { type: 'parkingLot/PARK', car: 'Tesla Model S' })
  t.deepEqual(parked, ['Tesla Model S'], 'should park Model S in lot')

  const picked = parkingLot(parked, { type: 'parkingLot/PICK' })
  t.deepEqual(picked, [], 'should remove the car')
})

它就是你第一天学测试就会写的那种测试。这些测试不受任何上下文影响,是幂等的。试着把那几个const声明的state挪到任何地方,你都可以发现测试还是正确的,这和我们平常小心翼翼分离各个测试case,并用beforeEach和afterEach重置截然不同。

(图片来自:http://t.cn/RpwS3AK

测试Reducer是非常机械的,你不需要问自己“我到底应该测哪些东西”,只需要机械地测试初始state和每个switch case就好了。(小秘密:redux-devtools写完实现,在浏览器里打开,反过来还可以自动生成各种框架的测试代码,粘贴回来就行了。推荐不写测试的项目尝试下,反正白送的测试……而且跟你写的没两样)

随着业务变得复杂,当state树变大时,我们可以将reducer结构继续往下抽,并继续传递事件,函数没有this,重构起来比普通OO要简单得多,就不赘述了。这时候测试还是完全一样的,这种树形结构保证了我们能最大限度地覆盖一个bounded context—也就是root reducer。

另外更好的方式是用t.is(断言引用相同)而非t.deepEqual。但是JavaScript对象本身是可变的,引入immutable.js可以让你只用t.is测试,不过immutable的API有点别扭,不展开了。

组件测试 (View测试)

React是一个View library,它干的活就是DOM domain里的两个事:渲染和捕获事件。我们在这里依然从简,只用stateless component这个子集,虽然在用到生命周期方法的时候需要用一下class,但绝大多数时候应该只用stateless component。

它以Virtual DOM的形式封装了恶心的浏览器基础设施,让我们以函数和数据结构来描述组件,所以和大部分框架不同,我们的测试依然可以在node上并行运行。如果用Karma + Chrome真正地渲染测试,你会发现共享一个浏览器实例的测试非常慢,几乎无法watch测试,因此我们的TDD cycle就会变得不那么流畅了。

最基本的就是state => UI这种纯函数组件:

const Greeter = ({ name }) => <p>Greetings {name}!</p>

使用的时候就像HTML一样传递attribute就可以了。

render(<Greeter name="React"/>, document.body)

最简单的测试还是判等,我们用一个叫jsx-test-helpers的库来帮我们渲染:

import { renderJSX, JSX } from 'jsx-test-helpers'

const Paragraph = ({ children }) => <p>{children}</p>
const Greeter = ({ name }) => <Paragraph>Greetings {name}!</Paragraph>

test('Greeter', t => {
  t.is(renderJSX(<Greeter name="React"/>), 
       JSX(<Paragraph>Greetings React!</Paragraph>), 
       'should render greeting text with name')
})

这里我多加了一层叫做Paragraph的组件,它的作用仅仅是传递给p标签,children这个prop表示XML标签传进来的子元素。多加这层Paragraph是为了展示renderJSX只向下渲染了一层,而非最终需要渲染的p标签。这样我们在View上的测试粒度就会变得更小,成本更低,速度更快。

(图片来自:http://t.cn/RpwYskG

View不像业务本身那么稳定,细粒度低成本的快速测试更划算些,这也是为什么我们的View都只是接受参数渲染,这样你只用测很少的case就能保证View可以正确渲染。假如你的FSM Model有M种可能性,View显示的逻辑有N种,如果将两个集成在一起测试可能就需要M×N种Path,如果分开测就有M+N种。View和Model的边界清晰时,你的Model测试不容易被更困难的View测试干扰,View测试也减少了混沌程度,需要测试的情形就减少了。

我们的组件不应该只有渲染,还有事件,比如我们封装个TextField组件:

const TextField = ({ label, onChange }) => <label>
  {label}
  <input type="text" onChange={onChange} />
</label>

当然我们还可以判等,只要onChange函数引用相同就好了。

test('TextField', t => {
  const onChange = () => {}
  const actual = renderJSX(<TextField label="Email" onChange={onChange} />)
  const expected = JSX(<label>
    Email
    <input type="text" onChange={onChange}/>
  </label>)
  t.is(actual, expected)
})

当然有时候你的组件更复杂些,测试时并不关心组件是不是完全按你想要的样子渲染,可能你想像jQuery一样选择什么,触发什么。这样可以用更主流的enzyme来测试:

import {shallow} from 'enzyme'
import sinon from 'sinon'

test('TextField with enzyme', t => {
  const onChange = sinon.spy()
  const wrapper = shallow(<TextField label="Email" onChange={onChange} />)
  t.true(wrapper.contains(<label>Email</label>), 'should render label')

  const event = { target: { value: 'foo@bar.com' } }
  wrapper.find('input').simulate('change', event)
  t.true(onChange.calledWith(event))
})

这里用的shallow顾名思义,也是向下渲染一层。此外我们还用了spy,这样测试就变得有点复杂了,丢掉了我们之前声明式的优雅,所以组件还是小一点、一下测完比较好。

还不够快?Facebook就觉得不够快,他们觉得View测试成本比较浪费,干脆搞了个Snapshot测试——意思就是照个像,只断言它不变。下次谁改了别的地方不小心影响到这里,就会挂掉,如果无意的就修好,如果有意的话和git一样commit一下就修好了:

import render from 'react-test-renderer'

test('Greeter', t => {
  const tree = render.create(<Greeter name="React"/>).toJSON()
  t.snapshot(tree, 'should not change')
})

当你修改Greeter的时候,测试就会挂掉,这时候运行:

ava --update-snapshots

就好了。Facebook自家的Jest对snapshot的支持更好,当snapshot不匹配时按个y/n就完事了,够快了吧。要有更快的可能就是不测了……

小结

这节里我们展示了3种测试View的不同方式,它们都比传统框架更简单更快速。我们的思路还是以判等为主,但不同于Model,粒度越大越好。View测试粒度越小越好,足够小、足够幂等之后,其实不用测试你也可以发现组件总是按照预期工作。相比之下MVVM天然有一种让View和Model粒度拟合的倾向,很容易让测试变得既难测又缺乏价值。

异步Effect测试

这算个续集……异步操作不复杂的项目可以无视这段,可以选择性不测。

React先解决了恶心的DOM问题,把Model的问题留下了。然后Redux把同步逻辑解决了,其实前端还留下异步操作的大问题没有解决。这种类似“Unix只做一件事”的哲学是React全家桶的根基。我们用一个叫做Redux-saga的库来展现全家桶的异步测试怎么写,Redux模仿的目标是Elm architecture,但是简化掉了Elm的作用模型,只保留了同步模型,Redux-saga其实就是把Elm的作用模型又拿回来了。

Saga是一种worker模式,很早之前在Java社区就存在了。Redux-saga抽象出来多种通用的作用比如call / takeEvery等等,然后有了这些作用,我们又可以愉快地判等了。比如:

import { takeEvery, put, call, fork, cancel } from 'redux-saga/effects'

function *account() {
  yield call(takeEvery, 'login/REQUESTED', login)
}
function *login({ name, password }) {
  try {
    const { token } = yield call(fetch, '/login', { method: 'POST', body: { name, password } })
    yield put({ type: 'login/SUCCEEDED', token })
  }
  catch (error) {
    yield put ({ type: 'login/FAILED', error })
  }
}

这段代码乍看起来很丑,这是因为它把程序里所有异步操作全都集中在自己身上了。其他部分都可以开心地发同步事件了,此外有了Saga之后Redux终于有了“用事件触发事件”的机制了,只用redux,应用复杂到一定程度你一定会想这个问题的。

这是个最普通的API处理saga,一个account worker看到每个'login/REQUESTED'就会forward给login worker(takeEvery),让它继续管下面的事。然后login worker拿到消息就会去发请求(call),之后傻傻地等着回复,或者是出错。最后它会发出和结果相关的事件。用这个方式你可以轻松解决疯狂难度的异步问题。

test('account saga', t => {
  const gen = account()
  t.deepEqual(gen.next().value, call(takeEvery, 'login/REQUESTED', login))
})

test('login saga', t => {
  const gen = login({ name: 'John', password: 'super-secret-123'})

  const request = gen.next().value
  t.deepEqual(request, call(fetch, '/login', { method: 'POST', body: { name: 'John', password: 'super-secret-123'} }))
  const response = gen.next({ token: 'non-human-readable-token' }).value
  t.deepEqual(response, put({ type: 'login/SUCCEEDED', token: 'non-human-readable-token' }))
  const failure = gen.throw('You code just exploded!').value
  t.deepEqual(failure, put({ type: 'login/FAILED', error: 'You code just exploded!'}))
})

你看我们的测试连异步操作都还可以无耻地判等。call就是以某些参数调用某个函数,put就是发事件。

可以试着把fetch覆盖成空函数,你可以发现实际上副作用根本没发生,“fetch到底是个啥”对测试一点影响都没有。你可能发现了,其实saga就是用数据结构表示作用,而不着急执行,在这里又走回幂等的老路了。这和React Virtual DOM的思路异曲同工。

结语

首先是文章开头提到的TL;DR的内容。函数是个好东西,测函数不等同“测1+1=2”这种没营养的单元,函数是可以包含很大上下文的。这种输入输出的模型既简单又有效。

我们消灭了mock,减少了依赖,并发了测试,加快了速度,降低了门槛,减少了测试路径等等。如果你的React项目原来在TDD的边缘摇摆不定,现在是时候入一发这种唯快不破了。

全家桶让Model/View/Async这三者之间的边界变得清晰,任由业务变更,它们之间的职责是不会互相替代的,这样你测它们的时候才更容易。后端之所以测试稳定是因为有API。所以想让前端好测也是一样的思路。

文中好多次提到“幂等”这个概念,幂等可以让你减少测试的case,写代码更有底气。抛开测试不谈,代码幂等的地方越多,程序越可控可预期。其实仔细思考一下我们的实际项目,大部分业务都是非常确定的,并没有什么随机因素。为什么最后还是会出现很多随机现象呢?

声明优于命令,描述发生什么、想要什么比亲自指导具体步骤好。

消息机制优于调用机制。Smalltalk > Simula。其实RESTful API一定程度上也是消息。简单的对象直接互相作用是完全没问题的,人作为复杂对象主要通过语言媒介来交流,听到内容思考其中的含义,而不是靠肢体接触,或者像连体婴儿那样共享器官。所以才有一句俗语叫“你的对象都想成长为Actor”。

从View的几种测试里我们也可以看到,测试并不是只有测或者不测这两种选择,我们老提测试金字塔,意思是测试可多可少,不同层级的测试保持正金字塔形状比较健康,像今天我们说的就可以大幅加宽你测试金字塔的底座。所以你的项目有可能测试过少,也可能测试过度,所以时间可以动态调整。

没用全家桶的项目可以把“大Model小View”的思想拿走,这样更容易于专注价值。尽量抽出Model层,不要把逻辑写在VM里,看那样似省事,行数在测试里都还回来了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容