本文主要介绍一下 insxhbbff 测试用方面的有些经验。测试用例是很小的一个点,在我看来确实最重要的一个点,因为这同时决定了我们的开发习惯。

首先,讲讲这套方案的背景,当然是从现有问题出发。

问题

在我以前的应用中,整个应用包括数据库层都在 node 端,所以跑测试用例的时候,是从数据库端开始的。跑用例前,先清空数据库,然后每个用例跑完,再重新清空数据库。需要的用户数据,也可以通过创建一个符合条件的用户来实现。

来保险以后,做 insbffweb 开发,数据全部都来自于 Java 服务端。insbffweb 的开发方式,写用例成本是非常高的,数据 mock 不方便,mock 的数据也不够准确,每个用例需要声明用到的所有 proxy 接口,并且定义 mock 数据。

写用例成本应该尽可能低,并且能够方便 mock 各种不同逻辑状态的数据。

同时,insbffweb 类型缺失,也是一个很麻烦的问题,遇到过一次参数兼容导致线上数据丢失的问题。另外,多个业务三四个项目并行的时候,insbffweb 的环境冲突问题,导致测试效率低下。

这一些列问题,感觉最靠谱的是重建一个系统,刚好我们有申请过 insxhbbff 应用。于是,在我来相互宝做第一个大项目的时候,顺便把 insxhbbff 应用搭起来了。

主要解决两个问题

  1. 通过TypeScript 解决类型问题,提高可维护性
  2. 写用例方式优化

实现

理想的方式,查询类的接口,线下接口是稳定的,可以直接调用。但很显然是不可能,stable 环境虽然号称 stable ,但一点都不稳定。

insxhbbff 中,我们参考了 insbffweb 的方式,把接口数据缓存下来,但数据缓存是自动保存的。

基础功能

一个基本的用例写法如下

  1. describe('test/service/plan.test.ts', () => {
  2. const { assert, getService, snapshotProxy } = init();
  3. describe('decide', () => {
  4. it('芝麻信用未开启', async () => {
  5. snapshotProxy(SnapshotType.READ_SNAPSHOT);
  6. // 新用户
  7. app.mockContext({ user: { userId: '2088302584018553' } });
  8. const ret = await getService(PlanService).decide({
  9. source: 'test',
  10. });
  11. assert(ret.needZMInvoke);
  12. const ret1 = await getService(PlanService).zmInvoke(PLAN_NO.XHB_MUTUAL_PLAN);
  13. assert(!ret1.accessible);
  14. assert(ret1.refuseMessage === '芝麻未开通');
  15. });
  16. });
  17. });

如何用单元测试 - 图1

手动 mock

上面基本模式跑了一段时间,亦池加入开发,为我们引入了手动 mock 能力。有些情况,造用户数据太麻烦了,这时候可以 mock 需要用到的接口。最终,我们重新设计 api ,在缓存读取部分之前,可以先进入到手动 mock 环节。

  1. it('cdp data error', async () => {
  2. const mockProxy = snapshotProxy();
  3. mockProxy('spaceQueryService', 'queryBySpaceCode', {
  4. success: true,
  5. });
  6. const data = await getService(HomeService).getHomeData({
  7. source: '',
  8. brandCode: BRAND_CODE.XHB,
  9. });
  10. assert(data);
  11. const cards = data.moduleList.find(o => o.name === HOME_MODULE.CONTRACT_LIST);
  12. assert(!cards.generatedData.imatchData);
  13. });

toMathSnapshot

心智之战的时候,乘五加入开发,说感觉断言太弱了。insbffweb 是有对整个返回接口数据结构进行断言的,在 insxhbbff,有时候我们就简单断言一下对象是否存在,这个问题亦池也有提及。

当然回到 insbffweb 那种首先接口的模式,是不可能的了。这个使用 jest-snapshot 是最靠谱的了

  1. it('join', async function() {
  2. snapshotProxy();
  3. const ret = await app.gwRequest()
  4. .user({ userId: '2088302534629060' })
  5. .invoke('page.entry')
  6. .send({
  7. source: 'abced',
  8. noRedirect: '1',
  9. });
  10. assert(ret.success);
  11. toMatchSnapshot(this, ret);
  12. });

使用方式

API 是非常简单的,这里我想更多的介绍一下如何使用的。

大家来 insxhbbff 开发的时候,首先都会问题,如何启动本地 server 。实际上,本地 server 是没必要存在的,因为用例可以解决一次本地 server 可以做到的。

我们应该把测试用例当成一种高效的工具,而不是负担

这一点非常重要,技术的发展都是在往着更高效的方向发展。具体操作上,就是使用测试用例来完成所有开发相关的工作。相对于起本地 server ,测试用例能够完成同样的功能,并且过程是可以重复执行的,可以做到自动回归。这也可以称之为测试驱动开发。

开发阶段,用于联调

这里举一个具体的例子,我在开发相互宝首页分摊卡需求的,首先拿到产品、设计稿,完成系分后,开始开发了。

image.png

第一步,写 demo 了。这时候服务端接口还没开发好,先让他们设计一下接口,然后打一个 jar 包给我。不过服务端接口理论上应该完全不限制前端模型的设计的。在开发demo的时候,我会首先设计前端需要的数据结构,通过 anymock 来 mock 所需的数据结构。

使用 anymock ,因为可以配置 ide 开发,ide 开发效率要高很多,至少能够自动刷新页面,虽然比起 hot-reload 体验差了一数量级,但比起小程序模拟手动刷新,还是优秀非常多的。

image.png

第二步,写接口。demo 搞定了,接口只需要按照约定的数据格式构造数据就好了。

第三步,联调。写一个用例

  1. it.only('分摊卡', async function() {
  2. snapshotProxy();
  3. app.mockContext({ user: { userId: '2088302529123058' } });
  4. const data = await getService(HomeService).getHomeData({
  5. source: 'apportion',
  6. brandCode: BRAND_CODE.XHB,
  7. });
  8. const card = data.moduleList.find(o => o.name === 'system_recommend_card') as any;
  9. assert(card.generatedData.length === 3);
  10. toMatchSnapshot(this, card.generatedData.map((o: RECOMMEND_MODULE) => ({ ...o, meta: null })));
  11. });

这里首先 mock 了当前登录的账号,这个是开发那边给的账号,配置 config.unittest.ts 到开发提供的测试。用例执行一遍,用例执行成功,看到准确的数据,正常情况联调就成功了。

联调成功后,把当前请求到的数据缓存到本地,很多接口状态都是和时间周期相关的,把接口缓存到本地,可以保证接口每次执行,请求的数据是稳定的。

  1. - snapshotProxy();
  2. + snapshotProxy(SnapshotType.WRITE_EMPTY);

如果下一次遇到接口变更,结构调整了,我们可以通过 SnapshotType.WRITE_FORCE_ALL 来强制更新当前用例的数据。

问题排查

测试用例是一种非常好的交流工具,当我们遇到问题无法解决的时候,我们可以去找其他人咨询,比如 chair 咨询群。这时候最佳的沟通手段是写一个用例,提交到一个单独的分支。测试用例是可以重复执行的,这会非常有利于其他人继续排查问题。

同样,我们处理线上问题,测试用例也是一种非常高效的工具。再来一个实例

有一天晚上,加班到十二点,终于可以下班了。路上接到一路的报警,首页接口 4002 。一开始,还没有意识到问题,因为直接访问页面,看起来还是正常的。后来仔细一想,页面正常是本地缓存,赶紧排查。

问题很快定位到了,一个接口返回了一段奇怪的数据。对二、亦池,我们三个一起排查,大家各自献计。关键问题是服务端突然,到 0 点公示状态切换,返回了一个所有字段都为空的结构。

这个结构理论上是有问题的,一开始想到的方案是让服务端熔断。但服务端处理起来太慢了,审批了很久。突然想到前端也可以熔断,就先把公式卡下线。

线上危机解除,然后继续排查问题。大家各自分析问题,但那都是纸上谈兵,不一定能够找到真正错误所在。当时对二还发了一个 mr 过来,但实际上那个修复方式发上线,还是会有没有解决问题的。

那么正确的方式应该是,写一个用例

  1. it('公示中, 公示接口异常返回', async () => {
  2. const mockProxy = snapshotProxy();
  3. mockProxy('publicityClaimFacade', 'queryLatestPeriodSummaryByBrand', {
  4. success: true,
  5. model: {
  6. brandPeriodVO: null,
  7. apportionReports: [],
  8. publicityClaimSummaries: [],
  9. },
  10. });
  11. const data = await getService(HomeService).getHomeData({
  12. source: '',
  13. brandCode: BRAND_CODE.XHB,
  14. });
  15. assert(data);
  16. const menu = data.moduleList.find(o => o.name === 'system_recommend_card').generatedData as any;
  17. assert(menu.length === 2);
  18. });

正确的修复方式应该是这样的,只有测试用例能够证明这是正确的。

  1. let periodTitle = '';
  2. if (data.brandPeriodVO) {
  3. const { yearOfPeriod, monthOfPeriod, periodLogicNo } = data.brandPeriodVO;
  4. periodTitle = `${yearOfPeriod}年${monthOfPeriod}月第${periodLogicNo}期公示`;
  5. + } else {
  6. + return null;
  7. }
  8. return {
  9. apportionDetail,
  10. period: {
  11. status: data.brandPeriodVO.status,
  12. },
  13. };
  14. }

这解决了当前的问题,再往下思考,首页是积木话的结构,每个模块都有可能出现异常,但如果一个模块突然异常,导致整个接口挂了,这是不可接受的。整个列表查询如下

  1. const moduleList = await Promise.all(data.moduleList.map(async item => {
  2. const name = item.templateCode.replace(sceneCode + '_', '').toLocaleLowerCase();
  3. return this.handleModuleData({
  4. name,
  5. data: item.content,
  6. isNewUser,
  7. }, query, commonData);
  8. });

然后,我想应该修复一下,这里加一层 try catch,保证任意一个接口挂了,不会影响全局。

  1. const moduleList = await Promise.all(data.moduleList.map(async item => {
  2. try {
  3. const name = item.templateCode.replace(sceneCode + '_', '').toLocaleLowerCase();
  4. return this.handleModuleData({
  5. name,
  6. data: item.content,
  7. isNewUser,
  8. }, query, commonData);
  9. } catch (e) {
  10. this.logger.error(e);
  11. return null;
  12. }
  13. });

代码改好了,看起来还不错。但是,我们必须记得,只有经过测试的代码,才是靠谱的。我们再加一个用例,还是上面那个用例,构建一个更离谱的数据

  1. const mockProxy = snapshotProxy();
  2. mockProxy('publicityClaimFacade', 'queryLatestPeriodSummaryByBrand', {
  3. success: true,
  4. model: {
  5. brandPeriodVO: {},
  6. apportionReports: [],
  7. publicityClaimSummaries: {}, // 这里本来应该是一个数组的,这里会导致函数直接 throw
  8. },
  9. });

用例跑一边,发现上面的代码是无效的,没有 catch 住。

总计

实际上,我们可以用测试用例来开发、联调,可以排查问题,并且验证问题准确性。