APP下载

React单元测试策略及落地_自动化

消息来源:baojiabao.com 作者: 发布时间:2024-05-20

报价宝综合消息React单元测试策略及落地_自动化

单元测试是现代软件开发最基本,也普遍落地不力的实践。市面关于React单元测试的文章,普遍停留在“可以如何写”和介绍工具的层面,既未回答“为何必须做单元测试”,也未回答“单元测试的最佳实践”两个关键问题。本文正是要对这两个问题作出回答。

目录

第一部分:为什么必须做单元测试

对于单元测试有保障质量之利益一事,业界已多有共识,但谈及其实施,则有种种“浪费时间”“很难写”“作用不大”顾及成本之声,他们板起一副编写单元测试将付出巨大成本的严肃脸。在“单元测试能保障质量”已成政治正确的今日,又有一种中庸的声音,说“单元测试固然有用,但也要根据专案情况进行裁剪,要写对开发者真正有用的测试”,一副放之四海皆准的样子,于是终于能够安心地根据专案情况将单元测试裁剪掉。

这种态度我一贯旗帜鲜明地反对:上来就谈裁剪,不是正确的导向。与产品程式码一并交付高质量的测试程式码,是每个开发者日常交付软件的基本职责。

单元测试的上下文

“为什么我们需要做单元测试”,这是一个关键的问题。往小了说,不做单元测试的程式码无法保证后续不被破坏,无法重构,只能看着程式码腐化;往大了说,不做单元测试的团队响应力不可能提高

实际上,自动化测试是实现“敏捷”的基本保障。现代企业数字化竞争日益激烈,业务端快速上线、快速验证、快速失败的思路对技术端的响应力提出了更高的要求:更快上线持续上线。怎么样衡量这个“更快”呢?第一图给出了一个指标:lead time。它度量的是一个想法从提出并被验证,到最终上生产环境面对使用者获取反馈的时间。显然,这个时间越短,软件就能越快获得反馈,对价值的验证就越快发生,软件对反馈的响应能力就越强。这个结论对我们写不写单元测试有什么影响呢?答案是,不写单元测试、不写好的单元测试,你就快不起来。为啥呢?因为每次释出,你都要投入人力来进行手工测试;因为没有自动化测试,你倾向于不敢随意重构,这又导致程式码逐渐腐化,复杂度使得你的开发速度降低。

再考虑到以下两大事实:人员会流动,应用会变大。人员一定会流动,需求一定会增加,直至再也没有一个人能够了解应用的所有功能,那时对应用做出修改的成本将变得很高。因此,意图依赖人、依赖手工的方式来应对响应力的挑战首先是低效的,从时间维度上来讲也是不可能的。因此,为了服务于“高响应力”这个目标,随时重构整理程式码是必须的,这就需要我们有一套自动化的测试套件,它能帮我们提供快速反馈,做质量的守卫者。唯解决了人工、质量的这一环,开发效率才能稳步提升,团队和企业的高响应力才可能达到。

在“响应力”和“随时重构”这个上下文中来谈要不要单元测试,我们就可以很有根据了,而不是含糊不清地回答“看专案的具体情况”了。显然,写出易于理解、易于修改、可以重构的程式码,是每个开发者的本来职责,而单元测试正是达成此一目的的唯一途径。

测试策略——测试金字塔

上面我直接从高响应力谈到单元测试,可能有的同学会问,高响应力这个事情我认可,也认可快速开发的同时,质量也很重要。但是,为了达到“保障质量”的目的,不一定得通过测试呀,就算需要测试,也不一定得通过单元测试鸭。

这是个好的问题。为了达到保障质量这个目标,测试当然只是其中一个方式,稳定的自动化部署、整合流水线、良好的程式码架构、甚至于团队架构的必要调整等,都是必须跟上的设施。自动化测试不是解决质量问题的银弹,多方共同提升才可能起到效果。

即便我们谈自动化测试,也未必全部都是单元测试。我们对自动化测试套件寄予的厚望是,它能帮我们安全重构已有程式码快速回归已有功能储存业务上下文。测试种类多种多样,为什么我要重点谈单元测试呢?因为它写起来相对最容易、执行速度最快、反馈效果又最直接。下面这个图,想必大家都有所耳闻:

这就是有名的测试金字塔。对于一个自动化测试套件,应该包含种类不同、关注点不同的测试,比如关注单元的单元测试、关注整合和契约的整合测试和契约测试、关注业务验收点的端到端测试等。正常来说,我们会受到资源的限制,无法应用所有层级的测试,效果也未必最佳。因此,我们需要有策略性地根据收益-成本的原则,考虑专案的实际情况和痛点来定制测试策略:比如三方依赖多的专案可以多写些契约测试,业务场景多、复杂或经常回归的场景可以多写些端到端测试,等。但不论如何,整个测试金字塔体系中,你还是应该拥有更多低层次的单元测试,因为它们成本相对最低,执行速度最快(通常是毫秒级别),而对单元的保护价值相对更大。

TDD——单元测试的核心灵魂

以上回答了“为何要有单元测试”的问题,却没有回答“如何得到这些单元测试”。有同学可能问,你说要写单元测试,那么什么时候写这些单元测试呢?让谁来写呢(开发人员还是测试人员)?程式码实现那么烂,我根本写不出强壮的测试,怎么办呢?

回答是,这些单元测试应该由开发者,在开发软件的同时编写对应的单元测试。它应该是内建的,而不是后补的:也即在编写实现的同时完成单元测试,而不是写完程式码再一次性补足。测试先行,这正是TDD的做法。使用TDD开发方法是得到可靠单元测试的唯一途径。

长久以来,大家都认为单测是单测,TDD是TDD,说单测必须要有,但是否使用TDD(测试先行)应该尊重开发者的习惯爱好。但事实是,且不说测试很难补,补出来的测试也几乎不可能完整覆盖我们对重构和质量的要求。TDD和单元测试是全有或全无:不做TDD,难以得到好的单元测试;TDD是获得可靠的单元测试的的唯一途径。除此之外别无捷径,想抛开TDD而获得一个好的单元测试套件是迷思,难以成功。

那么如何掌握TDD呢?事实上非常简单,多练即可。你可关注微信公众号“程序员练功房”,也可扫码直接开始你的十四天程式设计训练营,刻意练习,打好TDD基础。

第二部分:什么是好的单元测试

好,相信看到这里,你已经愿意为一套好的单元测试集而奋斗了。下一个摆在我们眼前的问题就是,“什么才是好的单元测试”,以及“如何写出这样的单元测试”了。开始之前,我们先来看个例子,即一个最简单的Java单元测试长什么样:

// production code

const computeTotalAmount = (products) => {

return products.reduce((total, product) => total + product.price, 0);

}

// testing code

it(\'should return summed up total amount 1000 when there are three products priced 200, 300, 500\', () => {

// given - 准备资料

const products = [

{ name: \'nike\', price: 200 },

{ name: \'adidas\', price: 300 },

{ name: \'lining\', price: 500 },

]

// when - 呼叫被测函式

const result = computeTotalAmount(products)

// then - 断言结果

expect(result).toBe(1000)

})

这个例子虽小,五脏却基本齐全。遵循这个given-when-then的结构,可以让你写出比较清晰的测试结构,既易于阅读,也易于编写。此外,编写容易维护的单元测试还有一些原则,这些原则对于任何语言、任何层级的测试都适用。这些原则不是新东西,但总是需要时时温故知新,笔者总结于此,可以此为镜,时时检验你的单元测试套件是否高效:

只关注输入输出,不关注内部实现

比如上面那个例子,你是如何完成“求总价格”的,测试本身不关注,因此你可以用reduce实现,也可以自己写for循环实现。只要测试输入没有变,输出就不应该变。这个特性,是测试支撑重构的基础。因为重构指的是,在不改变软件外部可观测行为的基础上,调整软件内部的实现。由此也可以看出,如果你是后补的测试,加之实现本身就写得细节横陈,就很难补出这种能够支撑重构、结构又清晰的测试程式码。测试先行本身就会驱动你写出易于测试的程式码。

另外,还有一些测试(比如下文要看到的 saga 官方推荐的测试),它需要测试实现程式码的执行次序。这也是一种“关注内部实现”的测试,这就使得除了输入输出外,还有“执行次序”这个因素可能使测试挂掉。显然,这样的测试也不利于重构的开展。

此外,对外部依赖采取mock策略,同样是某种程度上的“关注内部实现”,因为mock的失败同样将导致测试的失败,而非真正业务场景的失败。对待mock的态度,我认为是谨慎使用,但本文未做展开。肖鹏有篇文章Mock的七宗罪对此展开了详细描述,我还没细看,这里只能先分享给读者。

只测一条分支

通常来说,一条分支就是一个业务场景,是你做任务分解过程的一个细粒度的task。为什么测试只测一条分支呢?很显然,如此你才能给它一个好的描述,这个测试才能保护这个特定的业务场景,挂了的时候能给你细致到输入输出级别的业务反馈。

常见的反模式是,实现本身就做了太多的事情,不符合SRP原则。

表达力极强

表达力强的测试,能在失败的时候给你非常迅速的反馈。它讲的是两方面:

总结起来,这些表达力主要体现在以下的方面:

上述第三点有些反例,比如说chai和sinon提供的断言API就不如jest友好,体现在:

这些细节,在阅读本文后面的任意测试,以及您自己编写单元测试的时候应该时常对照和雕琢。

不包含逻辑

跟写宣告式的程式码一样的道理,测试需要都是简单的宣告:准备资料、呼叫函式、断言,让人一眼就明白这个测试在测什么。如果含有逻辑,你读的时候就要多花时间理解;一旦测试挂掉,你咋知道是实现挂了还是测试本身就挂了呢?

执行速度快

单元测试只有在毫秒级别内完成,开发者才会愿意频繁地执行它,将其作为快速反馈的手段也才能成立。那么为了使单元测试更快,我们需要:

在如何避免依赖的问题上,截止我下笔此文章时仍在采用第一种方案,如何才能“适当”隔离掉三方依赖也难以在此详细表述,好在并不影响本文行文;近期可能会考察下第二种方法。

在后面的介绍中,我会将这些原则落实到我们写的每个单元测试中去。大家可以时时翻到这个章节来对照,是不是遵循了我们说的这几点原则,不遵循是不是确实会带来问题。时时勤拂拭,莫使惹尘埃啊。

第三部分:React 单元测试策略

上个专案上的 React(-Native) 应用架构如上所述。它涉及一个常见 React 应用的几个层面:元件、资料管理、redux、副作用管理等,是一个常见的 React、Redux 应用架构,对于不同的专案应该有一定的适应性。架构中的不同元素有不同的特点,因此即便是单元测试,我们也有针对性的测试策略:

对于这个策略,这里做一些其他补充:

关于不测 redux connect 过的元件策略:理由是成本高于收益,得不偿失:

关于 UI 测试策略:团队之前尝试过 snapshot 测试,对它寄予希望,主要理由是成本低,看起来又像万能药。实质上其整个机制的工作基础依赖于开发者在每次执行测试时耐心做好“确认比对”这个事情,这会打断日常的开发节奏(特别是依赖于TDD的红绿循环进行快速反馈的专案);其次还有些小的问题,比如其难以提供精确的快照比对,而只是程式码层面的近似快照。我个人目前对此种测试型别持保留态度。

第四部分:React 单元测试落地

actions 测试

这一层获益于架构的简单性,甚至都可以不用测试。当然,如果有些经常出错的action,可以针对性地对这些action creator补充测试。其测试方法如下:

export const saveUserComments = (comments) => ({

type: \'saveUserComments\',

payload: {

comments,

},

})

import * as actions from \'./actions\'

test(\'should dispatch saveUserComments action with fetched user comments\', () => {

const comments = []

const expected = {

type: \'saveUserComments\',

payload: {

comments: [],

},

}

const result = actions.saveUserComments(comments)

expect(result).toEqual(expected)

})

reducer 测试

reducer 大概有两种:一种比较简单,仅一一储存对应的资料切片;一种复杂一些,里面具有一些计算逻辑。对于第一种 reducer,写起来非常简单,简单到甚至可以不需要用测试去覆盖,其正确性基本由简单的架构和逻辑去保证。下面是对一个简单 reducer 做测试的例子:

import Immutable from \'seamless-immutable\'

const initialState = Immutable.from({

isLoadingProducts: false,

})

export default createReducer((on) => {

on(actions.isLoadingProducts, (state, action) => {

return state.merge({

isLoadingProducts: action.payload.isLoadingProducts,

})

})

}, initialState)

import reducers from \'./reducers\'

import actions from \'./actions\'

test(\'should save loading start indicator when action isLoadingProducts is dispatched given isLoadingProducts is true\', () => {

const state = { isLoadingProducts: false }

const expected = { isLoadingProducts: true }

const result = reducers(state, actions.isLoadingProducts(true))

expect(result).toEqual(expected)

})

下面是一个较为复杂、更具备测试价值的 reducer 例子,它在储存资料的同时,还进行了合并、去重的操作:

import uniqBy from \'lodash/uniqBy\'

export default createReducers((on) => {

on(actions.saveUserComments, (state, action) => {

return state.merge({

comments: uniqBy(

state.comments.concat(action.payload.comments),

\'id\',

),

})

})

})

import reducers from \'./reducers\'

import actions from \'./actions\'

test(`

should merge user comments and remove duplicated comments by comment id

when action saveUserComments is dispatched with new fetched comments

`, () => {

const state = {

comments: [{ id: 1, content: \'comments-1\' }],

}

const comments = [

{ id: 1, content: \'comments-1\' },

{ id: 2, content: \'comments-2\' },

]

const expected = {

comments: [

{ id: 1, content: \'comments-1\' },

{ id: 2, content: \'comments-2\' },

],

}

const result = reducers(state, actions.saveUserComments(comments))

expect(result).toEqual(expected)

})

reducer 作为纯函式,非常适合做单元测试,加之一般在 reducer 中做重逻辑处理,此处做单元测试保护的价值很大。请留意,上面所说的单元测试,是不是符合我们描述的单元测试基本原则:

selector 测试

selector 同样是重逻辑的地方,可以认为是 reducer 到元件的延伸。它也是一个纯函式,测起来与 reducer 一样方便、价值不菲,也是应该重点照顾的部分。况且,稍微大型一点的专案,应该说必然会用到 selector。原因我讲在这里。下面看一个 selector 的测试用例:

import { createSelector } from \'reselect\'

// for performant access/filtering in React component

export const labelArrayToObjectSelector = createSelector(

[(store, ownProps) => store.products[ownProps.id].labels],

(labels) => {

return labels.reduce(

(result, { code, active }) => ({

...result,

[code]: active,

}),

{}

)

}

)

import { labelArrayToObjectSelector } from \'./selector\'

test(\'should transform label array to object\', () => {

const store = {

products: {

10085: {

labels: [

{ code: \'canvas\', name: \'帆布鞋\', active: false },

{ code: \'casual\', name: \'休闲鞋\', active: false },

{ code: \'oxford\', name: \'牛津鞋\', active: false },

{ code: \'bullock\', name: \'布洛克\', active: true },

{ code: \'ankle\', name: \'高帮鞋\', active: true },

],

},

},

}

const expectedActiveness = {

canvas: false,

casual: false,

oxford: false,

bullock: true,

ankle: false,

}

const productLabels = labelArrayToObjectSelector(store, { id: 10085 })

expect(productLabels).toEqual(expectedActiveness)

})

saga 测试

saga 是负责呼叫 API、处理副作用的一层。在实际的专案上副作用还有其他的中间层进行处理,比如 redux-thunk、redux-promise 等,本质是一样的,只不过 saga 在测试性上要好一些。这一层副作用怎么测试呢?首先为了保证单元测试的速度和稳定性,像 API 呼叫这种不确定性的依赖我们一定是要 mock 掉的。经过仔细总结,我认为这一层主要的测试内容有五点:

来自官方的错误姿势

redux-saga 官方提供了一个 util: CloneableGenerator 用以帮我们写 saga 的测试。这是我们专案使用的第一种测法,大概会写出来的测试如下:

import chunk from \'lodash/chunk\'

export function* onEnterProductDetailPage(action) {

yield put(actions.notImportantAction1(\'loading-stuff\'))

yield put(actions.notImportantAction2(\'analytics-stuff\'))

yield put(actions.notImportantAction3(\'http-stuff\'))

yield put(actions.notImportantAction4(\'other-stuff\'))

const recommendations = yield call(Api.get, \'products/recommended\')

const MAX_RECOMMENDATIONS = 3

const [products = []] = chunk(recommendations, MAX_RECOMMENDATIONS)

yield put(actions.importantActionToSaveRecommendedProducts(products))

const { payload: { userId } } = action

const { vipList } = yield select((store) => store.credentails)

if (!vipList.includes(userId)) {

yield put(actions.importantActionToFetchAds())

}

}

import { put, call } from \'saga-effects\'

import { cloneableGenerator } from \'redux-saga/utils\'

import { Api } from \'src/utils/axios\'

import { onEnterProductDetailPage } from \'./saga\'

const product = (productId) => ({ productId })

test(`

should only save the three recommended products and show ads

when user enters the product detail page

given the user is not a VIP

`, () => {

const action = { payload: { userId: 233 } }

const credentials = { vipList: [2333] }

const recommendedProducts = [product(1), product(2), product(3), product(4)]

const firstThreeRecommendations = [product(1), product(2), product(3)]

const generator = cloneableGenerator(onEnterProductDetailPage)(action)

expect(generator.next().value).toEqual(

actions.notImportantAction1(\'loading-stuff\')

)

expect(generator.next().value).toEqual(

actions.notImportantAction2(\'analytics-stuff\')

)

expect(generator.next().value).toEqual(

actions.notImportantAction3(\'http-stuff\')

)

expect(generator.next().value).toEqual(

actions.notImportantAction4(\'other-stuff\')

)

expect(generator.next().value).toEqual(call(Api.get, \'products/recommended\'))

expect(generator.next(recommendedProducts).value).toEqual(

firstThreeRecommendations

)

generator.next()

expect(generator.next(credentials).value).toEqual(

put(actions.importantActionToFetchAds())

)

})

这个方案写多了,大家开始感受到了痛点,明显违背我们前面提到的一些原则:

正确姿势

针对以上痛点,我们认为真正能够保障质量、重构和开发者体验的 saga 测试应该是这样:

于是,我们发现官方提供了这么一个跑测试的工具,刚好可以用来完美满足我们的需求:runSaga。我们可以用它将 saga 全部执行一遍,搜集所有释出出去的 action,由开发者自由断言其感兴趣的 action!基于这个发现,我们推出了我们的第二版 saga 测试方案:runSaga + 自定义拓展 jest 的 expect 断言。最终,使用这个工具写出来的 saga 测试,几近完美:

import { put, call } from \'saga-effects\'

import { Api } from \'src/utils/axios\'

import { testSaga } from \'../../../testing-utils\'

import { onEnterProductDetailPage } from \'./saga\'

const product = (productId) => ({ productId })

test(`

should only save the three recommended products and show ads

when user enters the product detail page

given the user is not a VIP

`, async () => {

const action = { payload: { userId: 233 } }

const store = { credentials: { vipList: [2333] } }

const recommendedProducts = [product(1), product(2), product(3), product(4)]

const firstThreeRecommendations = [product(1), product(2), product(3)]

Api.get = jest.fn().mockImplementations(() => recommendedProducts)

await testSaga(onEnterProductDetailPage, action, store)

expect(Api.get).toHaveBeenCalledWith(\'products/recommended\')

expect(

actions.importantActionToSaveRecommendedProducts

).toHaveBeenDispatchedWith(firstThreeRecommendations)

expect(actions.importantActionToFetchAds).toHaveBeenDispatched()

})

这个测试已经简短了许多,没有了无关断言的杂音,依然遵循 given-when-then 的结构,并且同样是测试“只储存获取回来的前三个推荐产品”、“对非 VIP 使用者推送广告”两个关心的业务点:

这个自定义的 matcher 是通过 jest 的 expect.extend 扩充套件实现的:

expect.extend({

toHaveBeenDispatched(action) { ... },

toHaveBeenDispatchedWith(action, payload) { ... },

})

上面是我们认为比较好的副作用测试工具、测试策略和测试方案。使用时,需要牢记你真正关心的业务价值点(也即本节开始提到的 5 点),以及做到在较为复杂的单元测试中始终坚守几条基本原则。唯如此,单元测试才能真正提升开发速度、支援重构、充当业务上下文的档案。

component 测试

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

先把这个分类放在这里,待会回过头来谈。对于 React 元件测什么不测什么,我有一些思考,也有一些判断标准:除去功能型元件,其他型别的元件一般是以渲染出一个语法树为终点的,它描述了页面的 UI 内容、结构、样式和一些逻辑 component(props) => UI。内容、结构和样式,比起测试,直接在页面上除错反馈效果更好。测也不是不行,但都难免有不稳定的成本在;逻辑这块,有一测的价值,但需要控制好依赖。综合上面提到的测试原则进行考虑,我的建议是:两测两不测。

元件的分支逻辑,往往也是有业务含义和业务价值的分支,新增单元测试既能保障重构,还可顺便做档案用;事件呼叫同样也有业务价值和档案作用,而事件呼叫的引数呼叫有时可起到保护重构的作用。

纯 UI 不在单元测试级别测试的原因,纯粹就是因为不好断言。所谓快照测试有意义的前提在于两个:必须是视觉级别的比对、必须开发者每次都认真检查。jest 有个 snapshot 测试的概念,但那个 UI 测试是程式码级的比对,不是视觉级的比对,最终还是绕了一圈,去除了杂音还不如看 Git 的 commit diff。每次要求开发者自觉检查,既打乱工作流,也难以坚持。考虑到这些成本,我不推荐在单元测试的级别来做 UI 型别的测试。对于我们之前中等规模的专案,诉诸手工还是有一定的可控性。

连线 redux 的高阶元件不测。原因是,connect 过的元件从测试的角度看无非几个测试点:

这四个点,react-redux 已经都帮你测过了,已经证明 work 了,开发者没有必要进行测试。当然,不测这个东西的话,还是有这么一种可能,就是你 export 的纯元件测试都是过的,但是程式码实际执行出错。穷尽下来主要可能是这几种问题:

第一、二种可能,如果是小步前进其实发现起来很快。如果某段资料获取的逻辑多处重复,则可以考虑将该逻辑抽取到 selector 中并进行单独测试;第三种可能,确实是问题,但由于在我所在专案发生频率较低(部分因为上个专案没有型别系统我们不会随意改 redux 的资料结构…),所以针对这些少量出现的场景,不必要采取错杀一千的方式进行完全覆盖。预设不测,出了问题或者经常可能出问题的部分,再策略性地补上测试进行固定即可。

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

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

元件型别 / 测试内容分支渲染逻辑事件呼叫@connect纯 UI展示型元件–容器型元件通用 UI 元件–功能型元件

业务型元件 – 分支渲染

export const CommentsSection = ({ comments }) => (

{comments.length > 0 && (

Comments

)}

{comments.map((comment) => (

)}

)

对应的测试如下,测试的是不同的分支渲染逻辑:没有评论时,则不渲染 Comments header。

import { CommentsSection } from \'./index\'

import { Comment } from \'./Comment\'

test(\'should not render a header and any comment sections when there is no comments\', () => {

const component = shallow()

const header = component.find(\'h2\')

const comments = component.find(Comment)

expect(header).toHaveLength(0)

expect(comments).toHaveLength(0)

})

test(\'should render a comments section and a header when there are comments\', () => {

const contents = [

{ id: 1, author: \'男***8\', comment: \'价廉物美,相信奥康旗舰店\' },

{ id: 2, author: \'雨***成\', comment: \'所以一双合脚的鞋子...\' },

]

const component = shallow()

const header = component.find(\'h2\')

const comments = component.find(Comment)

expect(header.html()).toBe(\'Comments\')

expect(comments).toHaveLength(2)

})

业务型元件 – 事件呼叫

测试事件的一个场景如下:当某条产品被点选时,应该将产品相关的资讯传送给埋点系统进行埋点。

export const ProductItem = ({

id,

productName,

introduction,

trackPressEvent,

}) => (

trackPressEvent(id, productName)}>

)

import { ProductItem } from \'./index\'

test(`

should send product id and name to analytics system

when user press the product item

`, () => {

const trackPressEvent = jest.fn()

const component = shallow(

introduction="iMac Pro - Power to the pro."

trackPressEvent={trackPressEvent}>

)

component.find(TouchableWithoutFeedback).simulate(\'press\')

expect(trackPressEvent).toHaveBeenCalledWith(

100832,

\'iMac Pro - Power to the pro.\'

)

})

简单得很吧。这里的几个测试,在你改动了样式相关的东西时,不会挂掉;但是如果你改动了分支逻辑或函式呼叫的内容时,它就会挂掉了。而分支逻辑或函式呼叫,恰好是我觉得接近业务的地方,所以它们对保护程式码逻辑、保护重构是有价值的。当然,它们多少还是依赖了元件内部的实现细节,比如说 find(TouchableWithoutFeedback),还是做了“元件内部使用了 TouchableWithoutFeedback 元件”这样的假设,而这个假设很可能是会变的。也就是说,如果我换了一个元件来接受点选事件,尽管点选时的行为依然发生,但这个测试仍然会挂掉。这就违反了我们所说了“不关注内部实现”原则,这对于元件测试来说,确实是不够完美的地方。

但这个问题无法避免。因为元件本质是渲染元件树,那么测试中要与元件树关联,必然要通过元件名、id这样的 selector,这些 selector 的关联本身就是一些“内部实现”的细节。但对元件的分支、事件进行测试又有一定的价值,无法避免。所以,我认为这个部分还是要用,只不过同时需要一些限制,以控制这些假设为维护测试带来的额外成本:

也就是说,如果你发现你很难快速地准备对元件的测试,那么有可能是你的元件太复杂了,这也是一个坏味道。多数情况下是元件承担了太多的职责,你应该将它们拆成更小的元件,使其符合单一职责原则。

如果你的每个元件都十分清晰直观、逻辑分明,那么像上面这样的元件测起来也就很轻松,一般就遵循 shallow -> find(Component) -> 断言的三段式,哪怕是了解了一些元件的内部细节,通常也在可控的范围内,维护起来成本并不高。这是目前我觉得平衡了表达力、重构意义和测试成本的实践。

功能型元件 – children 型高阶元件

功能型元件,指的是跟业务无关的另一类元件:它是功能型的,更像是底层支撑着业务元件运作的基础元件,比如路由元件、分页元件等。这些元件一般偏重逻辑多一点,关心 UI 少一些。其本质测法跟业务元件是一致的:不关心 UI 具体渲染,只测分支渲染和事件呼叫。但由于它偏功能型的特性,使得它在设计上常会出现一些业务型元件不常出现的设计模式,如高阶元件、以函式为子元件等。下面分别针对这几种进行分述。

export const FeatureToggle = ({ features, featureName, children }) => {

if (!features[featureName]) {

return null

}

return children

}

export default connect(

(store) => ({ features: store.global.features })

)(FeatureToggle)

import React from \'react\'

import { shallow } from \'enzyme\'

import { View } from \'react-native\'

import FeatureToggles from \'./featureToggleStatus\'

import { FeatureToggle } from \'./index\'

const DummyComponent = () =>

test(\'should not render children component when remote toggle does not exist\', () => {

const component = shallow(

)

expect(component.find(DummyComponent)).toHaveLength(0)

})

test(\'should render children component when remote toggle is present and is on\', () => {

const features = {

promotion618: FeatureToggles.on,

}

const component = shallow(

)

expect(component.find(DummyComponent)).toHaveLength(1)

})

test(\'should not render children component when remote toggle is present but is off\', () => {

const features = {

promotion618: FeatureToggles.off,

}

const component = shallow(

)

expect(component.find(DummyComponent)).toHaveLength(0)

})

utils 测试

每个专案都会有 utils。一般来说,我们期望 util 都是纯函式,即是不依赖外部状态、不改变引数值、不维护内部状态的函式。这样的函式测试效率也非常高。测试原则跟前面所说的也并没什么不同,不再赘述。不过值得一提的是,因为 util 函式多是资料驱动,一个输入对应一个输出,并且不需要准备任何依赖,这使得它多了一种测试的选择,也即是引数化测试的方式。引数化测试可以提升资料准备效率,同时依然能保持详细的用例资讯、错误提示等优点。jest 从 23 后就内建了对引数化测试的支援,如下:

test.each([

[[\'0\', \'99\'], 0.99, \'(整数部分为0时也应返回)\'],

[[\'5\', \'00\'], 5, \'(小数部分不足时应该补0)\'],

[[\'5\', \'10\'], 5.1, \'(小数部分不足时应该补0)\'],

[[\'4\', \'38\'], 4.38, \'(小数部分不足时应该补0)\'],

[[\'4\', \'99\'], 4.994, \'(超过预设2位的小数的直接截断,不四舍五入)\'],

[[\'4\', \'99\'], 4.995, \'(超过预设2位的小数的直接截断,不四舍五入)\'],

[[\'4\', \'99\'], 4.996, \'(超过预设2位的小数的直接截断,不四舍五入)\'],

[[\'-0\', \'50\'], -0.5, \'(整数部分为负数时应该保留负号)\'],

])(

\'should return %s when number is %s (%s)\',

(expected, input, deion) => {

expect(truncateAndPadTrailingZeros(input)).toEqual(expected)

}

)

当然,对纯资料驱动的测试,也有一些不同的看法,认为这样可能丢失一些描述业务场景的测试描述。所以这种方式还主要看专案组的接受度。

总结

好,到此为止,本文的主要内容也就讲完了。总结下来,本文主要覆盖到的内容如下:

未尽话题

诚然,关于构建一个完整的前端测试体系,有一些点是本文没有涉及到的,或因为没有涉猎,或因为尚未尝试,或因为未有结论,一并罗列于下。有兴趣的读者可来电交流。

参考

文/ThoughtWorks林从羽

更多精彩洞见,请关注微信公众号:ThoughtWorks洞见

2020-01-30 20:06:00

相关文章