React要写单元测试?不妨这样试试

612 阅读9分钟

长期以来,单元测试 (Unit testing/UT) 都是前端项目工程化绕不开的一个重点。而近两年随着越来越多更为便利的测试框架、工具的出现,端到端测试(End-to-end testing/E2E)在项目实践中的存在感也越来越强,面对E2E的“蚕食”,我们不得不思考,编写UT最合理的“度”在哪里?另一方面,过去一年多,React Hooks强势崛起,大家的编码习惯在潜移默化中发生了不小的改变,与之相对,UT的实践策略也应不断进行调整,如何在React项目中落地单元测试,是一个很值得深入的话题。

对于Java、C#等后端语言,UT的实践策略已经比较成熟,而在前端领域,UT始终处于一个复杂且细分的阶段。翻阅社区的众多资料,UT在不同项目中的实践方式可谓是五花八门,大家尝试套用后端语言的成熟经验,然而在落地时,又会遇到很多水土不服的问题,让执行变得异常艰难。这篇文章,正是想要提供一种思路,以解决问题为导向,尝试找到一个初步的实践策略。

import

论述UT重要性或是讲解如何实践测试驱动开发(Test-Driven Development/TDD)的文章已经很多[1],此处不再赘述,本文主要针对以下几点问题进行讨论:

  1. React组件化后,组件哪部分最具测试价值?
  2. 如何让我们的测试用例更易编写、维护?
  3. UT与E2E的边界在哪里?

带着这些问题,我们从React组件本身出发,一探究竟。

一、React组件哪部分最具测试价值?

1. Component

Component 应着重关注render以及副作用,同时业务逻辑的处理过程,都应该尽量提取到Hooks和Utils文件中。因此,对于Component的测试,我们完全可以将重心主要放在以下这两方面问题上:

  • 组件是否正常渲染了?
  • 组件副作用是否正常处理了?

在尝试使用UT对这两个关注点进行覆盖时,首先就要面临一个比较棘手的问题:开发人员需要mock整个组件渲染所需的所有数据,包括且不限于Redux store中的state和所有的Props,如此才可保证组件能够被正确渲染且覆盖符合预期。而这就意味着开发人员在编写测试用例时不得不耗费很大一部分精力mock数据,且在开发后期,极有可能为了页面新增的一个字段,开发人员却需要花费不小的工时对mock数据进行维护。

反观E2E,由于其并不关注程序的内部实现,因而在覆盖并解决上述两方面问题的同时,相对轻松地避开了UT所遭遇的痛点,故此我们更倾向于将基础组件渲染这部分内容的测试工作移交给E2E来负责,UT只需要关注带有复杂显示逻辑的组件。

同理,如果你的组件内部包含复杂的渲染逻辑,你依然可以使用UT对其进行覆盖,我们推荐使用react-testing-library来加载组件,mock接口之后,再写一个类似E2E的集成测试。当然使用Enzyme直接进行render的测试也是个不错的方案。

2. Hooks

如何测试React Hooks,社区目前已有相对成熟的解决方案,即@testing-library/react-hooks + react-test-renderer[2]。通过这两个依赖,开发人员可以很轻松的mock出Hooks执行所依赖的环境,把store的数据当作hooks的输入,关注在hooks内的业务逻辑,即可把Hooks当作纯方法(Pure Function)来进行测试。

3. Redux/Slice

对于Redux,如果项目在使用 Redux Toolkit 的话,事情会简单很多,开发人员只需要关注Dispatch的Actions即可。但如果Actions和Reducer是分开编写,则需要针对性处理:

  • Action

对于Action creator,虽然官网展示了对应的测试用例形式,但是大多数情况,这一部分都是类似的模版代码:

const orderLoading = () => ({ type: 'ORDER_LOADING' });

针对这类代码铺测试用例,唯一的效果只会是增加开发人员复制粘贴的工作量。这部分测试用例真正需要关注的,应是dispatch的那一部分代码逻辑:我们对actions的dispatch是否符合预期?对Service返回数据的处理是否符合预期?诸如此类。

export const getOrderById = (orderId: string): AppThunk => async (dispatch) => {
  try {
    dispatch(orderLoading);
    const orders = await requestOrderAPI([mockOrder]);
    dispatch(addOrders(orders));
  } catch (error) {
    dispatch(orderLoadingError(error));
  }
};
  • Reducer

由于所有对于store state的操作,都应该放在action中来完成,因而大多数情况下,Reducer都是模版代码。确实对于这类纯函数,编写测试用例会轻松很多,但就实际情况而言,大部分的这类模板代码都没有测试的必要。

当然,如果reducer中还包含了对state的逻辑处理,甚至于涉及业务的分支逻辑,UT覆盖还是很有价值的。

4. Redux Selectors

不同应用场景中,Selectors的复杂程度可高可低。若Selectors只是简单且直接地返回store中存储的某项数据时,不需要UT覆盖;然而若涉及数据聚合、清洗等逻辑操作时,UT覆盖不能偷懒。

5. Service

不同项目或团队对Service的定义各不相同,这里我们要聊的主要指负责处理HTTP请求的request和response,以及相应的异常处理的数据层。Service主要的功能是对接Action,因而理想情况下Service只需要包含与API通信的代码,这种情况下,UT可有可无。但一些场景下,如果项目中没有使用BFF承担数据处理的角色,后端也没能提供完全符合前端数据结构需求的接口时,不可避免的,开发人员需要在此处完善数据处理的逻辑,以便获取清洗或聚合后的数据,因而这种情况下,UT覆盖是非常有必要的。

6. Utils/Helpers

Utils/Helpers主要包含以下几类类型:

  • 数据结构的转化,各种convert工具函数
  • 数据结构的处理,比如数据提取、合并压缩、整理工具函数
  • 公共的工具函数

根据我们目前的项目习惯,当一段逻辑需要在Utils/Helpers中实现时,那么它一定是纯函数,其中多数情况又会包含一定程度的数据处理逻辑,所以基本都需要UT覆盖。

二、如何让我们的测试用例更易编写、维护?

回答这个问题,我们需要先思考一下,什么样的测试用例编写起来最轻松?答案可能因人而异,但输入输出简单明了的纯函数一定能算上一个。从这个观点出发,结合黑盒测试的特性,我们可以将这个问题拆分为以下两点:

1. 如何让输入输出更清晰?

这个问题,说到底是管理mock数据的问题。随着项目的不断膨胀,组织mock数据会逐渐成为编写UT时负担最重的那个环节。随手mock在项目前期可能会稍显方便,但这无异于给自己挖坑。

最直接的解决方案还是首选集中管理mock数据:项目中可以考虑集中维护一个DTO mock集合,其中提供不同类型的Base DTO mock数据,由各个测试用例在使用时按需导入,再在其内部转化成他所需要的数据,具体实现方式可因项目而异,在搭建出框架后,通过使用的方式来进一步明确项目中的需求,进行调整。

2. 如何让过程更简单?

要回答这个问题,既“简单”又“困难”,因为答案的核心很明确,即降低代码的深度和复杂度,控制代码分支数量,如此这般在一定程度上减少测试用例。但在实际场景中,无论是编码水平有限,项目框架限制还是需求时限要求,总有各种各样“合理”的理由阻碍开发人员将代码写得简单。这种情况下,不妨多了解一些关于TDD的实践方法,在避免形式主义的前提下,结合项目情况,尝试改变一些既定的编码习惯。同时,有舍有得,根据F.I.R.S.T.原则[3],对已有UT测试用例进行优化和重构。

三、UT与E2E的边界在哪里?

在实践E2E的过程中,我们意识到为了提高E2E的可维护性及测试用例的运行效率,E2E的关注点应更侧重于从更高的维度,对于项目整体的流水线进行测试,而非过分关注具体的细节,如某一个按钮的显隐。

且随着E2E测试用例数量的增加,在维护的过程中,只有不断进行精简与合并,逐渐删减掉那些过于独立的测试用例,并将不同环节的独立测试用例串联为完整的流程,如此才能保证E2E的健壮。

因此,显而易见的,UT与E2E在编写或维护过程中,确实存在重叠的可能性,但它们最终形态的关注点却是完全不同的,而关注点的差异,正是其边界所在。

export

最后,为 TL;DR 的同学简单总结一下:

  • UT应关注代码中最具测试价值的部分,以尽可能小的成本换取最大化的收益
  • 测试价值取决于项目本身的侧重点及开发人员的编码习惯,这里提供一种思路供参考:
    • Component:应覆盖包含复杂显示逻辑的组件,除此之外可以不覆盖
    • Hooks: 应全部覆盖
    • Redux:应覆盖action函数,及包含数据处理逻辑的reducer函数,除此之外可以不覆盖
    • Selectors:应覆盖包含数据处理逻辑的函数,除此之外可以不覆盖
    • Service:应覆盖包含数据处理逻辑的函数,除此之外可以不覆盖
    • Utils/Helpers:应全部覆盖
  • 确定关注点,同时通过对测试用例不断的分解和组合,在实践中明确UT及E2E的边界

参考资料:

  1. React单元测试策略及落地

  2. testing-library/react-hooks-testing-library

  3. Agile in a Flash - F.I.R.S.T

“卓派前端工作志,聚焦实用前端技术,让编程更有趣!”

前端技术组 @ 西安卓派科技 NEXT Trucking — 拉勾 | Boss | 知乎 | 掘金 | 简书

如果觉得本文对你有帮助的话,快来关注我们吧!