组件测试 (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上的测试粒度就会变得更小,成本更低,速度更快。
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里,看那样似省事,行数在测试里都还回来了。