ethanlin-twer.github.io icon indicating copy to clipboard operation
ethanlin-twer.github.io copied to clipboard

React 单元测试策略及落地

Open EthanLin-TWer opened this issue 6 years ago • 55 comments

React 单元测试策略及落地

续 https://github.com/linesh-simplicity/linesh-simplicity.github.io/issues/122#issuecomment-340631650

写好的单元测试,对开发速度、项目维护有莫大的帮助。前端的测试工具一直推陈出新,而测试的核心、原则却少有变化。与产品代码一并交付可靠的测试代码,是每个专业开发者应该不断靠近的一个理想之地。本文就围绕测试讲讲,为什么我们要做测试,什么是好的测试和原则,以及如何在一个 React 项目中落地这些测试策略。

本文使用的测试框架、断言工具是 jest。文章不打算对测试框架、语法本身做过多介绍,因为已有很多文章。本文假定读者已有一定基础,至少熟悉语法,但并不假设读者写过单元测试。在介绍什么是好的单元测试时,我会简单介绍一个好的单元测试的结构。

目录

  1. 为什么要做单元测试
    1. 单元测试的上下文
    2. 测试策略:测试金字塔
    3. 如何写好单元测试:好测试的特征
      • 有且仅有一个失败的理由
      • 表达力极强
      • 快、稳定
  2. React 单元测试策略及落地
    1. React 应用的单元测试策略
    2. actions 测试
    3. reducer 测试
    4. selector 测试
    5. saga 测试
      • 来自官方的错误姿势
      • 正确姿势
    6. component 测试
      • 业务型组件 - 分支渲染
      • 业务型组件 - 事件调用
      • 功能型组件 - children 型高阶组件
    7. utils 测试
  3. 总结
  4. 未尽话题 & 欢迎讨论

为什么要做单元测试

虽然关于测试的文章有很多,关于 React 的文章也有很多,但关于 React 应用之详细单元测试的文章还比较少。而且更多的文章都更偏向于对工具本身进行讲解,只讲「我们可以这么测」,却没有回答「我们为什么要这么测」、「这么测究竟好不好」的问题。这几个问题上的空白,难免使人得出测试无用、测试成本高、测试使开发变慢的错误观点,导致在「质量内建」已渐入人心的今日,很多人仍然认为测试是二等公民,是成本,是锦上添花。这一点上,我的态度一贯鲜明:不仅要写测试,还要把单元测试写好;不仅要有测试前移质量内建的意识,还要有基于测试进行快速反馈快速开发的能力。没自动化测试的代码不叫完成,不能验收。

「为什么我们需要做单元测试」,这是一个关键的问题。每个人都有自己关于该不该做测试、该怎么做、做到什么程度的看法,试图面面俱到、左右逢源地评价这些看法是不可能的。我们需要一个视角,一个谈论单元测试的上下文。做单元测试当然有好处,但本文不会从有什么好处出发来谈,而是谈,在我们在意的这个上下文中,不做单元测试会有什么问题。

那么我们谈论单元测试的上下文是什么呢?不做单元测试我们会遇到什么问题呢?

单元测试的上下文

先说说问题。最大的一个问题是,不写单元测试,你就不敢重构,就只能看着代码腐化。代码质量谈不上,持续改进谈不上,个人成长更谈不上。始终是原始的劳作方式。

image

image

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

再考虑到以下两个大事实:人员会流动,应用会变大。人员一定会流动,需求一定会增加,再也没有任何人能够了解任何一个应用场景。因此,意图依赖人、依赖手工的方式来应对响应力的挑战首先是低效的,从时间维度上来讲也是不现实的。那么,为了服务于「高响应力」这个目标,我们就需要一套自动化的测试套件,它能帮我们提供快速反馈、做质量的守卫者。唯解决了人工、质量的这一环,效率才能稳步提升,团队和企业的高响应力才可能达到。

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

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

除此之外,你就需要写单元测试。如果你想随时整理重构代码,那么你需要写单元测试;如果你想有自动化的测试套件来帮你快速验证提交的完整性,那么你需要写单元测试;如果你是个长期项目有人员流动,那么你需要写单元测试;如果你不想花大量的时间在记住业务场景和手动测试应用上,那么你就需要单元测试。

至此,我们从「响应力」这个上下文中,回答了「为什么我们需要写单元测试」的问题。接下来可以谈下一个问题了:「为什么是单元测试」。

测试策略:测试金字塔

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

这是个好的问题。为了达到保障质量这个目标,测试当然只是其中一个方式,稳定的自动化部署、集成流水线、良好的代码架构、组织架构的必要调整等,都是必须跟上的设施。我从未认为单元测试是解决质量问题的银弹,多方共同提升才可能起到效果。但相反,也很难想象单元测试都没有都写不好的项目,能有多高的响应力。

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

image

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

以上是对「为什么我们需要的是单元测试」这个问题的回答。接下来一小节,就可以正式进入如何做的环节了:「如何写好单元测试」。

关于测试金字塔的补充阅读:测试金字塔实战

如何写好单元测试:好测试的特征

写单元测试仅仅是第一步,下面还有个更关键的问题,就是怎样写出好的、容易维护的单元测试。好的测试有其特征,虽然它并不是什么新的东西,但总需要时时拿出来温故知新。很多时候,同学感觉测试难写、难维护、不稳定、价值不大等,可能都是因为单元测试写不好所导致的。那么我们就来看看,一个好的单元测试,应该遵循哪几点原则。

首先,我们先来看个简单的例子,一个最简单的 JavaScript 的单元测试长什么样:

// production code
const computeSumFromObject = (a, b) => {
  return a.value + b.value
}

// testing code
it('should return 5 when adding object a with value 2 and b with value 3', () => {
  // given - 准备数据
  const a = { value: 2 }
  const b = { value: 3 }

  // when - 调用被测函数
  const result = computeSumFromObject(a, b)

  // then - 断言结果
  expect(result).toBe(5)
})

以上就是一个最简答的单元测试部分。但麻雀虽小,五脏基本全,它揭示了单元测试的一个基本结构:准备输入数据、调用被测函数、断言输出结果。任何单元测试都可以遵循这样一个骨架,它是我们常说的 given-when-then 三段式。

为什么说单元测试说来简单,做到却不简单呢?除了遵循三段式,显然我们还需要遵循一些其他的原则。前面说到,我们对单元测试寄予了几点厚望,下面就来看看,它如何能达到我们期望的效果,以此来反推单元测试的特征:

  • 安全重构已有代码 -> 应该有且仅有一个失败的理由不关注内部实现
  • 保存业务上下文 -> 表达力极强
  • 快速回归 -> 稳定

下面来看看这三个原则都是咋回事:

有且仅有一个失败的理由

有且仅有一个失败的理由,这个理由是什么呢?是 「当输入不变时,当且仅当被测业务代码功能被改动了」时,测试才应该挂掉。为什么这会支持我们重构呢,因为重构的意思是,在不改动软件外部可观测行为的基础上,调整软件内部实现的一种手段。也就是说,当我被测的代码输入输出没变时,任我怎么倒腾重构代码的内部实现,测试都不应该挂掉。这样才能说是支持了重构。有的单元测试写得,内部实现(比如数据结构)一调整,测试就挂掉,尽管它的业务本身并没修改,这样怎么支持重构呢?不怪得要反过来骂测试成本高,没有用。一般会出现这种情况,可能是因为是先写完代码再补的测试,或者对代码的接口和抽象不明确所导致。

另外,还有一些测试(比如下文要看到的 saga 官方推荐的测试),它需要测试实现代码的执行次序。这也是一种「关注内部实现」的测试,这就使得除了业务目标外,还有「执行次序」这个因素可能使测试挂掉。这样的测试也是很脆弱的。

表达力极强

表达力极强,讲的是两方面:

  • 看到测试时,你就知道它测的业务点是啥
  • 测试挂掉时,能清楚地知道业务、期望数据与实际输出的差异

这些表达力体现在许多方面,比如测试描述、数据准备的命名、与测试无关数据的清除、断言工具能提供的比对等。空口无凭,请大家在阅读后面测试落地时时常对照。

快、稳定

不快的单元测试还能叫单元测试吗?一般来讲,一个没有依赖、没有 API 调用的单元测试,都能在毫秒级内完成。那么为了达到快、稳定这个目标,我们需要:

  • 隔离尽量多的依赖。依赖少,速度就快,自然也更稳定
  • 将依赖、集成等耗时、依赖三方返回的地方放到更高层级的测试中,有策略性地去做
  • 测试代码中不要包含逻辑。不然你咋知道是实现挂了还是你的测试挂了呢?

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

React 单元测试策略及落地

image

React 应用的单元测试策略

上个项目上的 React(-Native) 应用架构如上所述。它涉及一个常见 React 应用的几个层面:组件、数据管理、redux、副作用管理等,是一个常见的 React、Redux 应用架构,也是 dva 所推荐的 66%的最佳实践(redux+saga),对于不同的项目应该有一定的适应性。架构中的不同元素有不同的特点,因此即便是单元测试,我们也有针对性的测试策略:

架构层级 测试内容 测试策略 解释
action(creator) 层 是否正确创建 action 对象 一般不需要测试,视信心而定 这个层级非常简单,基础设施搭好以后一般不可能出错,享受了架构带来的简单性
reducer 层 是否正确完成计算 对于有逻辑的 reducer 需要 100%覆盖率 这个层级输入输出明确,又有业务逻辑的计算在内,天然属于单元测试宠爱的对象
selector 层 是否正确完成计算 对于有较复杂逻辑的 selector 需要 100%覆盖率 这个层级输入输出明确,又有业务逻辑的计算在内,天然属于单元测试宠爱的对象
saga(副作用) 层 是否获取了正确的参数去调用 API,并使用正确的数据存取回 redux 中 对于是否获取了正确参数、是否调用正确的 API、是否使用了正确的返回值保存数据、业务分支逻辑、异常分支 这五个业务点建议 100% 覆盖 这个层级也有业务逻辑,对前面所述的 5 大方面进行测试很有重构价值
component(组件接入) 层 是否渲染了正确的组件 组件的分支渲染逻辑要求 100% 覆盖、交互事件的调用参数一般要求 100% 覆盖、被 redux connect 过的组件不测、纯 UI 不测、CSS 一般不测 这个层级最为复杂,测试策略还是以「代价最低,收益最高」为指导原则进行
UI 层 样式是否正确 目前不测 这个层级以我目前理解来说,测试较难稳定,成本又较高
utils 层 各种帮助函数 没有副作用的必须 100% 覆盖,有副作用的视项目情况自定

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

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

关于 UI 测试这块的策略。团队之前尝试过 snapshot 测试,对它寄予厚望,理由是成本低,看起来又像万能药。不过由于其难以提供精确快照比对,整个工作的基础又依赖于开发者尽心做好「确认比对」这个事情,很依赖人工耐心又打断日常的开发节奏,导致成本和收益不成正比。我个人目前是持保留态度的。

关于 DOM 测试这块的策略。也就是通过 enzyme 这类工具,通过 css selector 来进行 DOM 渲染方面的测试。这类测试由于天生需要通过 css selector 去关联 DOM 元素,除了被测业务外 css selector 本身就是挂测试的一个因素。一个 DOM 测试至少有两个原因可使它挂掉,并不符合我们上面提到的最佳实践。但这种测试有时又确实有用,后文讲组件测试时会专门提到,如何针对它制定适合的策略。

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,
    },
  }

  expect(actions.saveUserComments(comments)).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 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 中做重逻辑处理,此处做单元测试保护的价值也很大。请留意,上面所说的单元测试,是不是符合我们描述的单元测试基本原则:

  • 有且仅有一个失败的理由:当输入不变时,仅当我们被测「合并去重」的业务操作不符预期时,才可能挂掉测试
  • 表达力极强:测试描述已经写得清楚「当使用新获取到的留言数据分发 action saveUserComments 时,应该与已有留言合并并去除重复的部分」;此外,测试数据只准备了足够体现「合并」这个操作的两条 id 的数据,而没有放很多的数据,形成杂音;
  • 快、稳定:没有任何依赖,测试代码不包含准备数据、调用、断言外的任何逻辑

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 expected = {
    canvas: false,
    casual: false,
    oxford: false,
    bullock: true,
    ankle: false,
  }

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

  expect(productLabels).toEqual(expected)
})

saga 测试

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

  • 是否使用正确的参数(通常是从 action payload 或 redux 中来),调用了正确的 API
  • 对于 mock 的 API 返回,是否保存了正确的数据(通常是通过 action 保存到 redux 中去)
  • 主要的业务逻辑(比如仅当用户满足某些权限时才调用 API 等)
  • 异常逻辑
  • 其他副作用是否发生(比如有时有需要 Emit 的事件、需要保存到 IndexDB 中去的数据等)

来自官方的错误姿势

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())
  )
})

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

  1. 测试分明就是把实现抄了一遍。这违反上述所说「有且仅有一个挂测试的理由」的原则,改变实现次序也将会使测试挂掉
  2. 当在实现中某个部分加入新的语句时,该语句后续所有的测试都会挂掉,并且出错信息非常难以描述原因,导致常常要陷入「调试测试」的境地,这也是依赖于实现次序带来的恶果,根本无法支持「重构」这种改变内部实现但不改变业务行为的代码清理行为
  3. 为了测试两个重要的业务「只保存获取回来的前三个推荐产品」、「对非 VIP 用户推送广告」,不得不在前面先按次序先断言许多个不重要的实现
  4. 测试没有重点,随便改点什么都会挂测试

正确姿势

于是,针对以上痛点,我们理想中的 saga 测试应该:1) 不依赖实现次序;2) 允许仅对真正关心的、有价值的业务进行测试;3) 支持不改动业务行为的重构。如此一来,测试的保障效率和开发者体验都将大幅提升。

于是,我们发现官方提供了这么一个跑测试的工具,刚好可以用来完美满足我们的需求: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 用户推送广告」两个关心的业务点,其中自有简洁的规律:

  • 非常容易准备输入数据:action、store、mock API 返回
  • 当输入不变时,无论你怎么修改优化内部实现,这个测试关心的业务场景都不会挂,真正做到了测试保护重构、支持重构的作用
  • 可以仅断言你关心的点,忽略不重要或不关心的中间过程(比如上例中,我们就没有断言其他 notImportant 的 action 是否被 dispatch 出去)
  • 与次序无关。调整产品代码内部 dispatch action 的次序也不会使测试失败
  • 自定义的 expect(action).toHaveBeenDispatchedWith(payload) matcher 很有表达力,且出错信息友好

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

expect.extend({
  toHaveBeenDispatched(action) { ... },
  toHaveBeenDispatchedWith(action, payload) { ... },
})

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

component 测试

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

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

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

  • 组件分支渲染逻辑必须测
  • 事件调用和参数传递一般要测
  • 纯 UI 不在单元测试层级测
  • 连接 redux 的高阶组件不测
  • 其他的一般不测(比如 CSS,官方文档有反例)

组件的分支逻辑,往往也是有业务含义和业务价值的分支,添加单元测试既能保障重构,还可顺便做文档用;事件调用同样也有业务价值和文档作用,而事件调用的参数调用有时可起到保护重构的作用。

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

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

  • mapStateToProps 中是否从 store 中取得了正确的参数
  • mapDispatchToProps 中是否地从 actions 中取得了正确的参数
  • map 过的 props 是否正确地被传递给了组件
  • redux 对应的数据切片更新时,是否会使用新的 props 触发组件进行一次更新

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

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

第一、二种可能,无视。测试不是万能药,不能预防人主动犯错,这种场景如果是小步提交发现起来是很快的,如果不小步提交那什么测试都帮不了你的;如果某段数据获取的逻辑多处重复,则可以考虑将该逻辑抽取到 selector 中并进行单独测试。

第三种可能,确实是问题,但发生频率目前看来较低。为啥呢,因为没有类型系统我们不会也不敢随意改 redux 的数据结构啊…(这侵入性重的框架哟)所以针对这些少量出现的场景,不必要采取错杀一千的方式进行完全覆盖。默认不测,出了问题或者经常可能出问题的部分,再策略性地补上测试进行固定即可。

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

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

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

业务型组件 - 分支渲染

export const CommentsSection = ({ comments }) => (
  <div>
    {comments.length > 0 && <h2>Comments</h2>}

    {comments.map((comment) => <Comment content={comment} key={comment.id} />)}
  </div>
)

对应的测试如下,测试的是不同的分支渲染逻辑:没有评论时,则不渲染 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(<CommentsSection comments={[]} />)

  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(<CommentsSection comments={contents} />)

  const header = component.find('h2')
  const comments = component.find(Comment)

  expect(header.html()).toBe('Content')
  expect(comments).toHaveLength(2)
})

业务型组件 - 事件调用

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

export const ProductItem = ({
  id,
  productName,
  introduction,
  trackPressEvent,
}) => (
  <TouchableWithoutFeedback onPress={() => trackPressEvent(id, productName)}>
    <View>
      <Title name={productName} />
      <Introduction introduction={introduction} />
    </View>
  </TouchableWithoutFeedback>
)
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(
    <ProductItem
      id={100832}
      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 的关联本身就是使测试挂掉的「另一个理由」。但对组件的分支、事件进行测试又有一定的价值,无法避免。所以,我认为这个部分还是要用,只不过同时需要一些限制,以控制这些假设为维护测试带来的额外成本:

  • 不要断言组件内部结构。像那些 expect(component.find('div > div > p').html().toBe('Content') 的真的就算了吧
  • 正确拆分组件树。一个组件尽量只负责一个功能,不允许堆叠太多的函数和功能。要符合单一职责原则

如果你的每个组件都十分清晰直观、逻辑分明,那么像上面这样的组件测起来也就很轻松,一般就遵循 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 = () => <View />

test('should not render children component when remote toggle is empty', () => {
  const component = shallow(
    <FeatureToggle features={{}} featureName="promotion618">
      <DummyComponent />
    </FeatureToggle>
  )

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

test('should render children component when remote toggle is present and stated on', () => {
  const features = {
    promotion618: FeatureToggles.on,
  }

  const component = shallow(
    <FeatureToggle features={features} featureName="promotion618">
      <DummyComponent />
    </FeatureToggle>
  )

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

test('should not render children component when remote toggle object is present but stated off', () => {
  const features = {
    promotion618: FeatureToggles.off,
  }

  const component = shallow(
    <FeatureToggle features={features} featureName="promotion618">
      <DummyComponent />
    </FeatureToggle>
  )

  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, description) => {
    expect(truncateAndPadTrailingZeros(input)).toEqual(expected)
  }
)

image

总结

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

  • 单元测试对于任何 React 项目(及其他任何项目)来说都是必须的
  • 我们需要自动化的测试套件,根本目标是为了提升企业和团队的 IT「响应力」
  • 之所以优先选择单元测试,是依据测试金字塔的成本收益比原则确定得到的
  • 好的单元测试具备三大特征:有且仅有一个失败的理由表达力极强快、稳定
  • 单元测试也有测试策略:在 React 的典型架构下,一个测试体系大概分为六层:组件、action、reducer、selector、副作用层、utils。它们分别的测试策略为:
    • reducer、selector 的重逻辑代码要求 100% 覆盖
    • utils 层的纯函数要求 100% 覆盖
    • 副作用层主要测试:是否拿到了正确的参数是否调用了正确的 API是否保存了正确的数据业务逻辑异常逻辑 五个层面
    • 组件层两测两不测:分支渲染逻辑必测事件、交互调用必测;纯 UI(包括 CSS)不测、@connect 过的高阶组件不测
    • action 层选择性覆盖:可不测
  • 其他高级技巧:定制测试工具(jest.extend)、参数化测试等

未尽话题 & 欢迎讨论

讲完 React 下的单元测试尚且已经这么花费篇幅,文章中难免还有些我十分想提又意犹未尽的地方。比如完整的测试策略、比如 TDD、比如重构、比如整洁代码设计模式等。如果读者有由此文章而生发、而疑虑、而不吐不快的种种兴趣和分享,都十分欢迎留下你的想法和指点。写文交流,乐趣如此。感谢。


附录:快照测试成本与实践落地剖析

snapshot testing 测试实践

之前我对快照测试是很有看法的,觉得外界对它「多快好省」的希冀其实真实的价值并没有那么大,反而有很多副作用。就像 TDD 的狂热者一样,大家对快照测试狂热不已。为了描述这种坏味道,我有三点主要的质疑:

  • 快照测试是反 TDD 的
  • 快照测试会阻碍团队的重构
  • 快照测试会损伤开发者体验

这些质疑都有其道理,却也不是不能解决。先来看看其道理所在:反 TDD 很明显,它是写完再打快照,那在你开始写到写完这个过程,你没有办法通过测试获得反馈;阻碍重构,是指我重构组件的过程中就算逻辑没有动,但因为字符串的改变组件仍然会挂(这点非真);损伤开发者体验,主要是说如果我频繁运行测试,并且正在修改组件,那我不得不重复「比对差异 - 更新快照」这个事情以使快照测试通过,否则我就得忍受经常红掉的测试。

先说反 TDD 这个事情,它确实和 TDD 不是一路的玩法。和祁兮沟通了一下,他觉得快照测试更接近 ATDD 这样高层级的测试,只不过它是针对于 UI 的测试。那可能就是你先写着,但在你的故事卡完成之前它一直会是挂的。那么你是不是可以选择在做卡的时候不去运行快照测试,只有结卡的时候再去把它固化下来?这点看起来很吸引,但仍然有一个问题,且看后面。但关键点是说,它确实不是与 TDD 兼容的测试方式,但同时它也是更高层级的测试,你不需要经常运行,需要 TDD 的时候依然可以采用其他的测试工具。

阻碍团队重构,是我了解不足所导致。我原本以为,只有改了任意字符串,快照测试就会挂,哪怕你没有改动功能,那这样就不满足我们对「不改变软件可观测行为的前提下进行重构」的标准。但事实证明,快照测试并不只是简单比较字符串,而且比较对于给定的 props 输入,输出是不是一致。因此,里面的可执行代码(逻辑)变化了,只要最终可观测的输出没有变化,那么快照测试也不会挂球。所以这点感受非真。

损伤开发者体验,这个问题比较关键。因为快照测试是检测「变化」,而非「结果」,所以当你改动了 UI 时,快照测试必然会挂。这个测试的挂就是给你传达这样一个信息:「现在我挂了,说明你改了代码。至于你改对了没有,那我咋个晓得呢,你确定你真的要修改吗?」因此,你要做的无非两个事情:比对差异、做出回应(更新快照或回滚修改)。这个对开发者体验可能的影响点是在,它是凭空地在你的红绿循环中间加了这么一道,当你频繁运行测试时,你可能必须频繁重复这两个事情:比对差异、做出回应。那么,你要么别频繁运行测试,要么能快速地完成这两个事情。

别频繁运行测试肯定是不能接受的,那么问题就转化成了:如何快速地完成这两个事情,以及这个过程带来的价值真的比开发者体验过程牺牲的反馈时间性价比更高吗?

价值。我们把组件的观测点分为两类:纯 UI 和逻辑。逻辑方面,用 Enzyme 来覆盖更加能保留业务场景;UI 方面,用 Enzyme 来覆盖必然要面临脆弱测试的问题,此时用 snapshot 测试则更有价值。因此我们讨论的结果是 snapshot 测试主要的价值就是在固化 UI 上,这其中也包括样式。祁兮说,如果把 UI 当成与业务代码一样对待,那么保证每个提交都没有破坏业务功能和 UI,显示是有其价值的。

如何快速地完成 比对差异 和 做出回应 这两个事情:

比对差异

对于 UI 组件的更新,一般可以分为四类操作:

  • 改字、改样式。这个差异比对体验完美 - 不就是自己开个 Git 比对一样的效果么?
  • 增加或删除 UI 组件
  • 改变组件结构,比如包一层,去一层之类的
  • 可观测 UI 行为不变,重构逻辑。这个 snapshot 或单元测试都能覆盖,推荐后者,更清晰、轻量

后两个在纯 React/HTML/JSX 中体验过,也跟纯文本对比一样,所以基本来说可以达到没什么成本。尽管说,这样而言就跟 Git 比对没什么两样,为啥还要测试这个流程?优点还是有一个,可以自动化,以及防止人为对 UI 的疏忽导致改出来 bug。问题是,在 RN 中,增删组件、包一层减一层组件(一般是包 View),jest 会默认把 View 的内部实现展开出来,变成冗长的字符串,这会导致差异没法比对,大大增加比对成本。解决方案是把它 mock 掉。

因此,差异比对这一块,如果没有特别的坑,可以做到比较低的成本;比对方式,仍然遵照 Git 进行文本比对的方式即可。

更新快照

  • 可以通过命令行的方式 jest --updateSnapshot,但执行太耗时,并且要它执行完以后你才可以 commit,需要等待,打断节奏;
  • 可以通过 jest --watch 的方式,在快照更新并确认要更新后按个 w 去更新,但我用的是 WebStorm 的测试面板,它通常被我放在副屏,不在主屏,这样我要么需要通过 cmd + 4 或鼠标移动的方式切换到测试面板去执行这个「按 w 更新快照」的操作,然后再通过两下 escape 或鼠标移动点击的方式回主面板。当我核心注意力在红绿循环中的时候,这无疑需要额外的注意力来切换屏幕、进行鼠标操作,乃是大忌,频繁执行测试下,对工作流和节奏的打乱更要命
  • 可以通过 precommit 脚本自动执行 jest --updateSnapshot。这是我目前想到相对最好的解决方案,在你比对了差异并确认需要更新后,还是通过 WS 的 Git 插件 cmd + k 进行 commit,然后 hook 脚本就会在后台执行,既不需要切换,也不需要打断当前思路和流程

至此,如果没有其他的坑,差异比对和更新快照的问题都得到了妥善解决。去除了这层成本对开发者体验的影响,我依然可以 TDD,于是能尝试去使用快照测试,看看具体的关节实践起来是否真如我们期望。

还有另外一个问题,就是快照测试什么时候运行并提交的问题。有三种粒度:

  • commit 粒度。即每个 commit 都需要保证快照测试通过,它是把 UI 当成与逻辑同样的一等公民来对待。这个粒度能保证每次对快照的更新都比较少,使得差异比较能 focus 在一个点上;它还能保证每个提交都是「原子提交」,即随便切一个 commit 都是可发布的状态,它们都通过 lint、通过 UI 测试、通过单元测试;这个是我们所期望的状态,挑战在于它需要 UI 部分跟 UT 一样要频繁进行「比对差异、更新快照」这两个事情,只有按照前面描述的实践,把这两个事情的时间成本降到最低,才能保证工作流畅,不然光研究差异就得花去半天时间,哪里还有心情写代码
  • push 粒度。就是说我 push 的时候必须保证快照测试通过。缺点是,中间的提交不能保证原子性,即是我没法保证每个 commit 都可发布;同时 review 的时候变化可能比较多,如果因为其他原因可读性不够好,基本就 review 不下去了,那也只能强行 jest --updateSnapshot 了,可能会增加比对差异的成本;
  • pr 粒度。通过配合本地 skip 掉快照测试的方式,这种粒度对开发者体验的侵入性是最小的。但缺点是 commit 原子性无法保证,比对差异可能更难进行,过高的比对成本可能导致人在这个环节偷懒犯错,从而失去快照测试的本意和意义

综上,快照测试的价值是对 UI 进行覆盖,把 UI 当成与逻辑同等地位的代码来看待。同时,快照测试不能完全取代单元测试,有逻辑的部分建议使用 Enzyme 进行 TDD 和覆盖。快照测试的粒度,建议是 commit 的粒度,以便每次提交都能保持「原子性」(即 lint、UT、快照测试都通过)。为此,依然需要频繁运行测试,并需要解决这个过程频繁的「差异比对」和「更新快照」可能带来的过高的成本,防止开发者体验因此降低。在价值、定位、粒度、具体操作都明确的基础上,不妨进行尝试,看它是否能带来预期的收益。

https://benmccormick.org/2016/09/19/testing-with-jest-snapshots-first-impressions 。这里将 ST 的优缺点和利弊权衡都讲得好。

施工中 🚧 TODOLIST
  • [ ] 谷歌或百度一下,前端 React 单元测试相关的文章;看下别人写过的东西,取长补短
    • https://testingjavascript.com/
    • https://kentcdodds.com/blog/write-tests
    • https://kentcdodds.com/blog/making-your-ui-tests-resilient-to-change
    • http://frontendgirl.com/what-i-have-learned-from-the-kent-c-dodds-testing-javascript-course/
  • [ ] 走查一下项目,主要关注几个方面:
    • 以函数为子组件的模式:Readable 例子补充一下。这种模式,是不是都可以写个专门的 helper 来屏蔽掉这些细节?是不是可以把里面的 children 直接抽取出一个组件来测试逻辑?
    • 有哪些可以测的东西没有写测试的?有哪些因为设计不好写的太复杂而没有写测试的?
    • 积累测试素材:
      • TagsContainer/index.test.js 有个也还不错的测试
      • ImproveUserInfo 上有许多不错的测试用例,可惜设计和耦合比较深,难以剥离出来
      • Login 里面也有些「验证登录按钮可不可点」的逻辑,非常适合做单元测试,但写的还是有点依赖于实现
      • ProductDetail 里面好像也有很多逻辑,然而没写好
      • Register 里关于测试六个字段的部分,也有点意思,它可能违反了最佳实践,但它是从 tasking 列表来的;
      • Register 关于 GetValidationCode 的部分,也有点问题,是个通用问题:大多数情况下,你不应该直接去操作内部字段,而是应该通过「界面行为」去表达场景,让内部自己处理,然后你最后得到断言结果
      • Reservations 里也有有趣的实践
      • DevFeatures 里提供了一个实例,如何在一个测试中需要多次用到一个 mock,并且返回不同的值
  • [ ] 测不测组件类上的实例方法呢?大部分时间不测,它们就像 private 方法,一般是你重构出来的,你有这个疑惑,很可能说明没有好好 tasking,没有 TDD;有小部分场景,是因为 simulate 事件不好测,那么这种场景下你可以做假设、直接调实例方法。
  • [ ] 那种根据代码条件逻辑而有不同样式的代码,怎么测?Visible 这样的组件,可读性提升了,consumer 端怎么测试?如果只测传给 Visible 的参数,显然就变成测试实现了
  • [ ] 更细致的组件测试的例子:如 mock、更接近「实现」更违背「不应关注实现」但具有业务价值的例子、如何 mock 系统级别的依赖,如 moment() Date.now
  • [ ] 最近听见了前端工作者经常质疑的一个问题:「是不是所有的代码都要 TDD 啊?感觉 TDD 不适用于前端啊?」我的结论是:TDD 的两大核心思想 快速反馈设计工具 依然是适用的,但产生于后端 Java 为主的这种完全 OO 风格编程语境下 TDD 其某些具体实践,可能确实不能完全适用于前端重 UI、交互的语境,UT 在 UI 及其相关领域,并不是合理的快速反馈方式。而 测试策略这个事情,刚好从政策的层面解答了「是不是所有代码都要 TDD」这个问题:测试策略的制定者凭借经验决定,什么代码使用 UT 来获得反馈是好的、需要的,然后凡是不在策略里的就不需要 TDD,凡是在策略里的就需要 TDD
  • [ ] 然后对于另一个说法:「进度紧所以没法 TDD」。我理解这个话多数情况下是个借口。对于不在测试策略(不适合 TDD)中的代码,不需要 TDD 甚至可以允许没有测试;对于在测试策略中的代码,你说进度紧所以没法 TDD,意思是用 TDD 就更慢,那我理解大概几种可能性,只有最后一种比较有合理性:
    • 技能不熟所以没法在适合使用 UT 以获得快速反馈的代码上获得快速反馈;或是说:
    • 你认为 TDD 方法论中使用 UT 来获得快速反馈的这个方式是无效的;或是说:
    • 你想说的不是 TDD 慢,而是写测试慢;或是说:
    • 原代码库设计极其不佳乱成一团导致很难 TDD,又因为进度压力没法偿还以前技术债
  • [ ] TDD 的核心思想:快速反馈设计工具在实践中随时可用。但是要谈「前端的 TDD」这个话题,我还必须了解前端在解决什么问题,对比其中可用 UT 来解决的问题比例,才有厚重的东西来谈前端的 TDD。现在还没太有能量
  • [ ] 前端某些元素其实是没法 TDD 的,也就因此没有办法收到重构的保护。那么,这些元素有没有可能有安全的重构方法?
整理中:Readable 例子

功能型组件 - 以函数为子组件

既然是侧重逻辑的功能型组件,它的设计模式就比较多样一些,其中经常会出现「以函数为子组件」的这种设计模式。至于为什么会用到这种模式,它的利弊如何呢,程墨有本书《深入浅出 React 和 Redux》,讲的很到位,这里不再细补充。还是以代码为例子:

import React, { PureComponent } from 'react'
import PropTypes from 'prop-types'
import { TouchableOpacity, View } from 'react-native'

import { Indicator } from '../Indicator'
import styles from './styles'

export default class Readable extends PureComponent {
  static propTypes = {
    children: PropTypes.func,
    initialStatus: PropTypes.bool,
  }

  state = {
    pressed: false,
  }

  get readStatus() {
    return this.state.pressed || this.props.initialStatus
  }

  onPress = () => this.setState({ pressed: true })

  render() {
    return (
      <View style={styles.container}>
        <Indicator status={this.readStatus} />

        <TouchableOpacity activeOpacity={0.8} onPress={this.onPress}>
          {this.props.children(this.readStatus)}
        </TouchableOpacity>
      </View>
    )
  }
}

这个组件,顾名思义,是负责管理「已读未读」的组件:它接受一个 children,负责记录它是否已被点击(阅读)过的状态,并将此状态作为参数,调用 children 时传递给它。再来看使用它的地方:

const ProductItem = ({ isRead, productName }) => (
  <Readable initialStatus={isRead}>
    {(isRead) => (
      <ItemContent
        title={productName}
        style={[styles.normal, isRead && styles.readStyle]}
      />
    )}
  </Readable>
)

好,现在比如你要测 ProductItem 里面的这段「是否渲染评论组件」的逻辑,你要怎么测呢?一般的 shallow(<ProductItem />),里面这段以函数作为子组件的函数可不会被调用哟?

目前我们项目解决方案是,手动拿到 children 这个函数,再手动 shallow 渲染一下,然后再测。再次地,这非常有侵入性,对实现了解比上面的例子更多。这是我所能接受的差不多一个平衡点了,就是如果准备再复杂一些,我就会开始觉得麻烦了。

test('should render a Comment component when comment is not empty', () => {
  const componant = shallow(
    <ProductItem
      isRead={false}
      productName="iMac Pro"
      comments={[
        'awesome products',
        'I will buy 10 for my friends',
        'I would love to have such a friend!',
      ]}
    />
  )
  const productItem = shallow(
    component
      .find(Readable)
      .props()
      .children()
  )

  const commentsSection = productItem.find(Comments)

  expect(commentsSection).toHaveLength(1)
})

EthanLin-TWer avatar Jun 09 '18 18:06 EthanLin-TWer

牛逼到我没有语言,只能66666666

JimmyLv avatar Jun 10 '18 01:06 JimmyLv

两会的测试 session 有一个点我觉得很棒,说「策略本身的 effort 会决定策略被落地的可能性」,大概是这个意思。我感觉也是这样,但这个事情背后的假设是悲观的…就是复杂的东西没人会用,哪怕是好的?

EthanLin-TWer avatar Jun 12 '18 05:06 EthanLin-TWer

啊呜,之后项目都要用Vue了,🤣

JimmyLv avatar Jun 12 '18 05:06 JimmyLv

意思是 React 太难没人学吗🤣

EthanLin-TWer avatar Jun 12 '18 05:06 EthanLin-TWer

只是为了统一技术栈而已,我觉得也行吧,只是Vue的单元测试又需要研究一下了…

JimmyLv avatar Jun 12 '18 05:06 JimmyLv

好在这篇文章直到「好测试的特征」之前的部分都是能复用的🤣

EthanLin-TWer avatar Jun 12 '18 06:06 EthanLin-TWer

把后面的落地一下,明天出篇 Vue 测试策略的文章没问题吧…

EthanLin-TWer avatar Jun 12 '18 06:06 EthanLin-TWer

哈哈哈哈,🤣 组合型写作,https://github.com/JimmyLv/jimmylv.github.io/issues/250

JimmyLv avatar Jun 12 '18 06:06 JimmyLv

哈哈哈哈,组件型写作。general ideas 多个地方都可以用,落地的具体实践部分,以几个具体原则做指导(相当于接口),再填充具体的东西就行…大家求变,要抓住不变!

export Blog = () => (
  <GeneralIdeas />
  <Practice />
)

interface Practice() {
  whatMakesAGoodTestCase() {}
  whatMakesAGoodTest() {}
  testingStrategy() {}
}
export ReactInPractice extends Practice { ... }
export VueInPractice extends Practice { ... }

EthanLin-TWer avatar Jun 12 '18 06:06 EthanLin-TWer

给一些我的反馈哈。总体来说,我觉得文章写的很好,很落地,对 react 和 redux 前端开发做 UT 给出了非常详细的指导和建议,让我学到很多。我最近做 RN 的开发,写的前端代码还不多,需要根据本文中的建议去实践一下。 我下面的反馈会按照文中的章节来给(每个反馈一个 commit 吧),不对的地方,欢迎交流。:)

JosephYao avatar Jun 12 '18 06:06 JosephYao

单元测试的上下文,测试策略和好测试的特征这几节绝大部分的内容,我很认同,我也是 TDD 的粉丝哈。 测试策略里面提到“有的测试编写维护成本高,运行时间长、速度慢(如端到端测试、集成测试、UI 测试等)”,这是个常见的认知。我会认为端到端测试(或者叫验收测试)维护成本可以和 UT 基本保持一致的,关键是看对测试的重构和设计是否做的好。我写了一篇相关的文章说这个,这个不展开了哈。https://www.odd-e.cn/rest-in-peace-integration-test/ 好测试的特征里面,我感觉少了一条重要的,我后面反馈再提哈,其实你好像也提到了,只是这里漏了。:)

JosephYao avatar Jun 12 '18 06:06 JosephYao

reducer 的测试部分,我很认同。reducer应该被测试,但是没必要通过创建store来测试,直接纯函数测试就好了。不过,我对单元测试的“表达力”要求比较高,所以我会建议重构一下那个merge的UT。大致如下:

import reducers from './reducers'
import actions from './actions'

test('should merge user comments within the same day and remove duplications when action saveUserComments is dispatched with new fetched comments', () => {
  const state = {
    comments: {
      '2017-08-21': [{ id: 3, title: 'comments-3' }],
    },
  }
  const comments = {
    '2017-08-21': [
      { id: 3, title: 'comments-3' },
      { id: 4, title: 'comments-4' },
    ],
  }
  const expected = {
    comments: {
      '2017-08-21': [
        { id: 3, title: 'comments-3' },
        { id: 4, title: 'comments-4' },
      ],
    },
  }

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

  expect(result).toEqual(expected)
})

主要就是把测试只体现关键点(这里应该是 merge相关的数据),其他不相关的数据应该不出现。之前我提到“好测试的特征”那一节少了一点,就是“测试的表现力”,这个对测试的理解和维护至关重要。

JosephYao avatar Jun 12 '18 09:06 JosephYao

本来想等你回完再 一起回,忍不住,由于提交比较简短我就合并一下好了。

「端到端测试维护成本高,运行时间长、速度慢」这个「常见的认知」,确实是我没有实践清楚就套用定势认知的部分,感谢澄清,十分有益。嗯,如果说验收测试可以做到和 UT 一样级别的维护成本,那是个很有意义的方向,十分期待了解更多的实践和总结。你这篇文章好像是讲持续集成和 git 的?我也看过,哈哈。

这个表达力的反馈赞,这就修改下。确实就精简测试数据本身,都能减少噪音,提高表达力。这让我想起,有时测试数据太多,我们还会编写一些 fixture 类的东西来帮忙组织、提高表达力,应该也是类似的作用。然后这些 fixture、testing helper,上面讲的专为测试写的 runSaga 和断言扩展等,其实也都是一个项目的测试积累了。

EthanLin-TWer avatar Jun 12 '18 13:06 EthanLin-TWer

先赞一下你的行动力哈。之前那个关于验收测试的链接给错了。。。我已经在那个commit改好了,欢迎交流。

继续我对文章的反馈哈。

  • 对于 action,我认同你的说法,就是 UT 基本是没必要的,因为 action 的代码太直接了
  • 对于 selector,我也认同要和 reducer 一样做 UT 的观点。更重要的是,我通过本文知道了有 selector 这样的好东东,对于 mapToProps 的代码实现和UT的确是非常好的solution,学习了,之后一定要实践一下
  • 对于 saga,我非常认同通过 runSaga 的方式来 UT 的做法,这样不会陷入测 Saga 代码实现细节的麻烦。saga 之前有过了解,但还没实践过,看了你的代码和测试之后,感觉也是必须尝试的好东东啊

有个问题想交流一下,你们项目中有写过 middleware 吗?有的话,UT 又是如何实现的呢?我自己写过 middleware 及其对应的 UT,感觉 UT 还是必须的

JosephYao avatar Jun 13 '18 13:06 JosephYao

对于 react component 的 UT,我个人感觉是本文中写的最深入的部分了。我之前对于 component 的 UT 有不少困惑的地方,看完基本都有思路了,后面一定会找机会来实践的。

就是有个小问题想讨论一下。文中有个叫 ProductDetailPage 的 component,代码如下:

const ProductDetailPage = ({ name, introduction, details, comments }) => (
  <>
    <Title name={name} />
    <Introduction introduction={introduction} />
    <ProductDetail content={details} />
    {comments.length > 0 && <Comments comments={comments} />}
  </>
)

我的问题是,这行代码{comments.length > 0 && <Comments comments={comments} />} 是否可以重构成下面这样呢?

<Comments comments={comments} />

也就是把检查 length 的逻辑移到 Comments 这个 component 里面去。原来的代码有点“特性嫉妒”的臭味,就是 ProductDetailPage 替 Comments 做了检查,然而数据则是属于 Comments 的。

这样重构之后,就会发现 ProductDetailPage 成了下面这样,我觉得对他做 UT 就没有必要了。原因是他实际上只是做了数据传递的事情,没有其他逻辑了。

const ProductDetailPage = ({ name, introduction, details, comments }) => (
  <>
    <Title name={name} />
    <Introduction introduction={introduction} />
    <ProductDetail content={details} />
    <Comments comments={comments} />
  </>
)

我的反馈到这里也就结束了。最后,再次感谢你写了如此精彩的一篇前端 UT 的文章。

JosephYao avatar Jun 13 '18 14:06 JosephYao

selector 这个东西,我们项目实践了下觉得是个不错的东西,如果用上可以多交流。

middleware 的话,我们的 middleware 比较简单,是个 API interceptor,大概长这样:

import axios from 'axios' 

export default function ({ getStore, dispatch }) {
  axios.interceptor.request.use(
    config => addHeadersForSpecifiedRequest(config),
    error => error
  )

  return (next) => (action) => next(action)
}

这个场景下,getStoredispatch 也没被用到,场景也相对简单,所以我们现在做法是直接把 addHeadersForSpecifiedRequest export 出来做单元测试。

如果有更复杂的 middleware 测试需求,不妨贴出来分享下。另外是官方文档也有一部分提及 middleware 及其测试的,也可参考参考:https://redux.js.org/recipes/writing-tests#middleware 。我看文档的做法基本就是 mock 掉 storenext

EthanLin-TWer avatar Jun 13 '18 16:06 EthanLin-TWer

component 那个很有道理,我重构基础又没打扎实😂平凡之处见功夫哈。这个例子我可以找个其他更恰当的,顺便重构手法 get,这个地方确实是 feature envy 了,判空这个行为应该是属于 Comments 这个组件的,跟面向对象的道理一样。

另外 components 部分我还打算补充更多的例子,比如有时可能会需要用到 mock 的,还有一些其他的例子,不过大的方向跟我们做 UT 遵循的原则是一致的。

十分感谢这份详尽的反馈,阅读+反馈也是需要很多精力的,刚好遇到对主题有相似思考又有实践需求/经验的读者,还愿意花时间给予详尽的反馈,感觉特别好运,哈哈。

互相吹捧完毕。。

EthanLin-TWer avatar Jun 13 '18 16:06 EthanLin-TWer

有个小 tips 不晓得你知不知道:

写 markdown 的时候,代码片段可以指定语言,这样渲染出来的 markdown 会有对应的语法高亮效果,比如:

```js
const React = vue({ id: 1 })
```

会被渲染为:

const React = vue({ id: 1 })

EthanLin-TWer avatar Jun 13 '18 16:06 EthanLin-TWer

很感谢你的回复哈。上面的那个小 tips,学习了。

难得有文章可以让我这么投入的回复,以后多交流哈。🙂

JosephYao avatar Jun 14 '18 00:06 JosephYao

很久没有看到这样的好文章了,把React单元测试分析的比较全面,思路非常清楚,对单元测试的理解非常到位。有几点反馈:除了 @JosephYao 提到的测试表现力外,测试数据的准备有时不需要写的冗长包含各种细节,只突出关键。比如:

const comments = [
    { id: 283992, author: '男***8', comment: '价廉物美,相信奥康旗舰店' },
    { id: 283993, author: '雨***成', comment: '因为工作穿皮鞋时间较多,所以一双合脚的鞋子...' },
    { id: 283994, author: '叶***亮', comment: '不错,很软,我买了大一码,因为脚宽些,是真皮的,划算萌东上...' },
    { id: 283995, author: '替***崽', comment: '磕到了,脚踝疼得不好穿,要不你们试试' },
  ]

我的理解是这里comments的内容对测试是完全无意义的,可以直接提取成数据方法comments(),后面也有测试也用到comments数据,也是一样数据内容本身无意义,这些都可以复用的。这样写你会发现慢慢你有了自己的测试数据构建的一套方法,可以形成自己的DSL,只在测试中暴露必要的相关信息。 这些细节很容易影响阅读测试的效率,读者容易被不重要的细节带走,会错过测试的本意。这也就是所谓的Incidental Details

zbcjackson avatar Jun 15 '18 05:06 zbcjackson

另外,对我来说测试要明确测试的意图,也就是测试的目标逻辑要清晰。其实你讲的很多内容都是在达成这一点,比如仅对输入输出敏感、不依赖于内部实现,以及什么测什么不测,你举的很多例子都很好的说明了这点。但是在Saga测试这一段,我觉得没有说清楚(当然也有可能是我Saga用的少)。

我觉得这里前后的两个例子并没有很好的证明你的观点“仅对输入输出敏感、不依赖于内部实现”。如果按照“正确的姿势”来写前一个例子,其实真正让你觉得舒服,符合你描述的有点,关键在于toHaveBeenDispatched这个matcher。

为什么这么说呢?“不完美的姿势”让你觉得“违反上述所说「仅对输入输出敏感」的原则”,或者改动代码容易让测试失败的原因是使用了Generator。Generator返回的迭代器是关切顺序的。这里实际代码的要实现的逻辑,据我理解是不关心顺序的,我只要取了这些数据就行。

而你使用testSaga和toHaveBeenDispatched恰恰就避免了顺序的影响。因此,如果“不完美的姿势”里,写一个工具方法把Generator返回的迭代器里的内容放到一个数组或hash对象之类的容器里,只需判断容器内是否有你想要的action就行。

而stub select函数或者api调用,我觉得差别其实没多大。

总体而言,我觉得用testSaga来测试也没什么问题,但就saga的输入输出而言,确实就是参数和迭代器,因为saga就是个Generator函数而已,虽然它的唯一使用者是redux-saga。不用testSaga也可以实现你要的效果,所以在这里这两个例子没有充分表述你的观点。

我只是读你这段的时候稍微花了点时间研究了Saga和Generator,所以如果我的理解有问题,你也可以帮忙指出。谢谢😄

zbcjackson avatar Jun 15 '18 06:06 zbcjackson

哦,对了,为了明确测试的意图,我通常不用参数化测试,因为很容易迷失掉测试的意图(对于写和读测试的人都一样),纯粹覆盖可能的数据。

zbcjackson avatar Jun 15 '18 06:06 zbcjackson

测试意图和表达力

在你的回复中,我感受到了对测试意图和表现力的极致追求,看来我的「偏执」还有很多提升空间嘛。

「只在测试中暴露必须的信息」这点很有道理,补充了我之前不太留意的一个视角(原来知道但没去做得更好)。确实,有时看到长长的测试,很容易产生「这很复杂」的感觉,进而降低阅读效率和体验,无法一眼了解测试的意图。

saga 测试相关

关于 saga 的那段是个很好的反馈,我觉得你的理解没问题,我这个地方例子举得确实没表达到我要表达的东西。原来「不完美的姿势」一节,我想表达最大的不完美的点是在于顺序对测试的影响。所以:

因此,如果“不完美的姿势”里,写一个工具方法把Generator返回的迭代器里的内容放到一个数组或hash对象之类的容器里,只需判断容器内是否有你想要的action就行。

是,这其实就是我最想表达的点,也是 testSaga 所解决的问题。它就是这个工具方法。其他的,如 toHaveBeenDispatched matcher 其实是个附加的东西,它确实提升了表达力,但不是要害;stub select 函数或者 API 调用,则是不完美的姿势一节的方法也可以解决的问题,更不是重点。

感觉我前后用了两个不同的例子,使得比较效果模糊了。如果我把例子改成这样,你觉得对于表达「原来测试与次序耦合,是痛点;改良后的测试避免了顺序的影响,让你真正专注于测试场景,并很好支持了重构」这个观点是否会有帮助:

export function* someSaga() {
  yield put(actions.notImportantAction1())
  yield put(actions.notImportantAction2())
  yield put(actions.notImportantAction3())
  yield put(actions.notImportantAction4())

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

  const products = recommendations.length > 5 ? getFirstFiveProducts(recommendations) : recommendations

  yield put(actions.importantActionToSaveRecommendedProducts(products))
}

依赖于次序的写法是这样:

test('should only save first five recommended products when there are more than 5 recommendations', () => {
  const sixRecommendedProducts = [product(1), product(2), product(3), product(4), product(5), product(6)]
  const firstFiveProducts = [product(1), product(2), product(3), product(4), product(5)]

  const generator = cloneableGenerator(someSaga)()

  expect(generator.next().value()).toEqual(put(actions.notImportantAction1()))
  expect(generator.next().value()).toEqual(put(actions.notImportantAction2()))
  expect(generator.next().value()).toEqual(put(actions.notImportantAction3()))
  expect(generator.next().value()).toEqual(put(actions.notImportantAction4()))
  expect(generator.next().value()).toEqual(call(Api,get, 'products/recommended'))
  expect(generator.next(sixRecommendedProducts).value()).toEqual(put(actions.importantActionToSaveRecommendedProducts(firstFiveProducts)))
})

而有了 testSaga() 这样的工具,我们就可以不依赖于次序了:

test('should only save first five recommended products when there are more than 5 recommendations', async () => {
  const sixRecommendedProducts = [product(1), product(2), product(3), product(4), product(5), product(6)]
  const firstFiveProducts = [product(1), product(2), product(3), product(4), product(5)]
  Api.get = jest.fn().mockImplementations(() => sixRecommendedProducts)

  await testSaga(someSaga)

  expect(Api.get).toHaveBeenCalledWith('products/recommended')
  expect(actions.importantActionToSaveRecommendedProducts).toHaveBeenDispatchedWith(firstFiveProducts)
})

参数化测试

这是个好点,就是说测试本身它有个等价类划分的过程,可能表现出来是 describe('category here')it('test description') 的形式,然后参数化测试会丢掉这个上下文。我们今天想出来一个办法,可以把测试描述也写在参数化测试里面,这样就兼顾代码简洁和测试意图了:

  test.each([
    [['0', '99'], 0.99, '(整数部分为0时也应返回)'],
    [['5', '00'], 5, '(小数部分不足时应该补0)'],
    [['5', '10'], 5.1],
    [['4', '38'], 4.38],
    [['4', '99'], 4.994, '(超过默认2位的小数的直接截断,不四舍五入)'],
    [['4', '99'], 4.995],
    [['4', '99'], 4.996],
    [['-0', '50'], -0.5, '(整数部分为负数时应该保留负号)'],
  ])('should return %s when number is %s%s', (expected, input, description) => {
    expect(truncateAndPadTrailingZeros(input)).toEqual(expected)
  })

当然,这个 API 也不够简洁了,[] 的最后一个参数作为 description 看着是有点怪怪的。不知道测试工具本身是否能提供更好的 API 了。

EthanLin-TWer avatar Jun 15 '18 11:06 EthanLin-TWer

把测试描述也写在参数化测试里面

这真是好非常不错的一点,清晰有用!

关于参数化测试,Jest 23.0 最新版有一种表达力更为优雅的方式,即不需要[]数组作为测试数据容器,而是更自然的表格形式:

each`
  a    | b    | expected | description
  ${1} | ${1} | ${2}     |  xxxxx
  ${1} | ${2} | ${3}     |  xxxxxxx
  ${2} | ${1} | ${3}     |  xxxxxx
`.test('returns $expected when adding $a to $b', ({a, b, expected}) => {
  expect(a + b).toBe(expected);
});

ref: https://facebook.github.io/jest/blog/2018/05/29/jest-23-blazing-fast-delightful-testing.html#jest-each

JimmyLv avatar Jun 15 '18 14:06 JimmyLv

而且 Prettier 也很快就支持了表格的自动格式化,不过想来要手打这个 | 也是很蛋疼…我经常打错…

这个 jest-each 的作者还撸过一个 given-when-then 的 babel plugin,想来是受到 spock 的启发不浅。

EthanLin-TWer avatar Jun 15 '18 14:06 EthanLin-TWer

写一个 WebStorm 插件好了,直接跟 Typora 一样可视化编辑表格,🤣

| a    | b    | expected | description |
| ---- | ---- | -------- | ----------- |
| 1    | 1    | 2        | 1+1=2       |
| 2    | 1    | 3        | 2+1=3       |
| 3    | 7    | 10       | 3+7=10      |

image

JimmyLv avatar Jun 15 '18 15:06 JimmyLv

少年简直可以哒!这样连 ¥{} 都省了呢

EthanLin-TWer avatar Jun 15 '18 15:06 EthanLin-TWer

文章整体很棒,尤其是测试针对不同层级的测试覆盖方法以及策略。不过对于 snapshot 本身不太明白加入到 precommit 之后 snapshot 是否还有价值。

可以通过 precommit 脚本自动执行 jest --updateSnapshot。这是我目前想到相对最好的解决方案,在你比对了差异并确认需要更新后,还是通过 WS 的 Git 插件 cmd + k 进行 commit,然后 hook 脚本就会在后台执行,既不需要切换,也不需要打断当前思路和流程。

本来 snapshot 的价值之一就是代替手工维护 expected 对象,当 snapshot 出问题的时候最重要的就是去看 snapshot 新的结果和旧的结果之间的区别是什么。如果不看这个区别就去更新那一定是有问题的。

nothingrealhappen avatar Jul 17 '18 05:07 nothingrealhappen

这是一段也许还不太成熟的观点,故意隐藏收起,没想到同学还是看了,哈哈。感谢阅读和反馈!

嗯,你说的对,snapshot 建立起 baseline 以后,整套工作机制最最重要的就是要人去看。我其实有提到「在你比对了差异并确认需要更新后」这个过程的,不过说是 precommit 阶段做可能有误,至少也应该是在填写了 commit message 发起一次 commit 以后,也许应该在 postcommit,但这样不知道又会不会循环触发 commit 流程。总之要点在于,对,它是需要人去看去仔细检查的。

我对快照测试最主要的观望点在于需要人为去看这个事情,以及运行频率的问题:

  1. 如果我们在做 TDD,人为去看 这个事情是否凭空就在红绿循环中间加了一道?是否会延长反馈周期?或者是否我们需要调整红绿循环的工作流来适应快照测试的加入?
  2. 人为去看,你真的得很仔细去看。人为、仔细是快照测试整套机制工作的基础:
    • 可能大部分时候,改动都是你期望的(因为本来就在改 UI),仔细对比之后发现并无改动,提交;
    • 可能少部分时候,改动中即有期望的部分也有误修改的部分,仔细对比之后,纠出错误,回滚误改部分,get 测试好处,提交;
    • 可能少部分时候,改动全是你非预期的,对比之后,全是错误,全部回滚,大大 get 测试好处,提交
  3. 提交粒度。是每次 commit 的时候保证快照测试更新,还是每次 push 的时候保证快照测试更新?

前面说的各种 precommit 脚本、IDE Git 插件,都是为了尝试解决第一个问题,这只是个技术问题,不做也行,就是慢一点点;而对于第二个问题,如果说,实践起来,真的是「大部分时候,改动都是你期望的」,那么我们要求每次快照变化都要「人为、细心」去检查这个事情,是不是其实收益并没有付出那么大?长久下去,是否可能导致团队厌烦情绪而放松了细心检查这个过程,从而使得快照测试的价值打折扣?而第三个问题,如果是 push 粒度更新快照测试,那么很显然失去了原子提交的优势;如果是 commit 粒度的更新,那就回到了人为、细心这个要求上,那还是要看第二点问题,是否真实存在,能不能解决。

以上是实践起来可能的成本部分。然后说快照测试的收益部分。快照测试的收益,在于提醒你留意 UI 的非期望改动;提醒的方式是给你提供快照差异。这个地方,快照差异与真实的 UI 差异程度是测试收益大小的核心点。

总之,抛开对这项实践本身的倾向性不讲,我想表达的点是,在明确期望成本和收益的前提下,团队去做何种尝试都是没有问题的。这一点问题也没有。是要鼓励的一个事情。我真正想提醒的是那些 snapshot testing everything(just because I can) 的论调。在欣喜捡到了一个便宜的宝贝之余,可多想想,便宜是真,宝贝是否未必呢?

也许这段结论更加成熟一点了呢。

EthanLin-TWer avatar Jul 17 '18 16:07 EthanLin-TWer

@JimmyLv Vue 的测试虽然 点都有,不过就是写起来没有 React 痛快,最近在将 React 的快照测试迁移到 Vue 的快照里面,vue 快照容易生成 这种东西,让快照测试的结果没有比对意义。。。

你们现在有啥经验么

azzgo avatar Jul 19 '18 14:07 azzgo