# 如何用单元测试

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

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

## 问题

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

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

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

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

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

主要解决两个问题


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

## 实现

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

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

### 基础功能

一个基本的用例写法如下

```typescript
describe('test/service/plan.test.ts', () => {
  const { assert, getService, snapshotProxy } = init();
  describe('decide', () => {
    it('芝麻信用未开启', async () => {
      snapshotProxy(SnapshotType.READ_SNAPSHOT);
      // 新用户
      app.mockContext({ user: { userId: '2088302584018553' } });
      const ret = await getService(PlanService).decide({
        source: 'test',
      });
      assert(ret.needZMInvoke);
      const ret1 = await getService(PlanService).zmInvoke(PLAN_NO.XHB_MUTUAL_PLAN);
      assert(!ret1.accessible);
      assert(ret1.refuseMessage === '芝麻未开通');
    });
  });
});
```

 ![](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS81ZWQyYjVlOC01MTE4LTRmOGEtODIxMS0wZjFlZmU3NDdjMjAvaW1hZ2UiLCJ0eXBlIjoiYXR0YWNobWVudCIsImlhdCI6MTc5MDQzODQwMCwiZXhwIjoxNzkwNTI0ODAwfQ.2WRdIWV6LOq8vxoyH1QpUvWpWAJjv5rymTd9HckFbZM)

### 手动 mock

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

```typescript
it('cdp data error', async () => {
  const mockProxy = snapshotProxy();
  mockProxy('spaceQueryService', 'queryBySpaceCode', {
    success: true,
  });

  const data = await getService(HomeService).getHomeData({
    source: '',
    brandCode: BRAND_CODE.XHB,
  });
  assert(data);
  const cards = data.moduleList.find(o => o.name === HOME_MODULE.CONTRACT_LIST);
  assert(!cards.generatedData.imatchData);
});
```

### toMathSnapshot

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

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

```typescript
it('join', async function() {
  snapshotProxy();
  const ret = await app.gwRequest()
  .user({ userId: '2088302534629060' })
  .invoke('page.entry')
  .send({
    source: 'abced',
    noRedirect: '1',
  });
  assert(ret.success);
  toMatchSnapshot(this, ret);
});
```

## 使用方式

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

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

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

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

### 开发阶段，用于联调

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

 ![image.png](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS82YWU4Zjc1ZC02NzU1LTQwZjAtOWFmMy1iMGJiOTljYzFiOTEvaW1hZ2UucG5nIiwidHlwZSI6ImF0dGFjaG1lbnQiLCJpYXQiOjE3OTA0Mzg0MDAsImV4cCI6MTc5MDUyNDgwMH0.WHmO5hJsUYVmWrs5HRpK-N1K_bltHtY1EhKIaEmsDA8)

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

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

 ![image.png](https://iling.fun/api/files.get?sig=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiJ1cGxvYWRzL2MyMzFmODM0LTY3ZGQtNGJjZi1iM2RmLWYxMTQ2NGUxM2Y0MS80ZWExZGZkNC1mMWRmLTQ0YzMtYTAyZi03NjYwNWFhZDY1ZWQvaW1hZ2UucG5nIiwidHlwZSI6ImF0dGFjaG1lbnQiLCJpYXQiOjE3OTA0Mzg0MDAsImV4cCI6MTc5MDUyNDgwMH0.w9Jy01CMGDkzAM11C56w1N7N4uWR2fT91LN-U9-WdLg)

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

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

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

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

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

```diff
- snapshotProxy();
+ snapshotProxy(SnapshotType.WRITE_EMPTY);
```

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

### 问题排查

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

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

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

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

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

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

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

```typescript
it('公示中, 公示接口异常返回', async () => {
  const mockProxy = snapshotProxy();
  mockProxy('publicityClaimFacade', 'queryLatestPeriodSummaryByBrand', {
    success: true,
    model: {
      brandPeriodVO: null,
      apportionReports: [],
      publicityClaimSummaries: [],
    },
  });

  const data = await getService(HomeService).getHomeData({
    source: '',
    brandCode: BRAND_CODE.XHB,
  });
  assert(data);
  const menu = data.moduleList.find(o => o.name === 'system_recommend_card').generatedData as any;
  assert(menu.length === 2);
});
```

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

```diff
    let periodTitle = '';
    if (data.brandPeriodVO) {
      const { yearOfPeriod, monthOfPeriod, periodLogicNo } = data.brandPeriodVO;
      periodTitle = `\${yearOfPeriod}年\${monthOfPeriod}月第\${periodLogicNo}期公示`;
+   } else {
+     return null;
    }

    return {
      apportionDetail,
      period: {
        status: data.brandPeriodVO.status,
      },
    };
  }
```

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

```typescript
const moduleList = await Promise.all(data.moduleList.map(async item => {
    const name = item.templateCode.replace(sceneCode + '_', '').toLocaleLowerCase();
    return this.handleModuleData({
        name,
        data: item.content,
        isNewUser,
    }, query, commonData);
});
```

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

```typescript
const moduleList = await Promise.all(data.moduleList.map(async item => {
    try {
        const name = item.templateCode.replace(sceneCode + '_', '').toLocaleLowerCase();
        return this.handleModuleData({
            name,
            data: item.content,
            isNewUser,
        }, query, commonData);
    } catch (e) {
        this.logger.error(e);
        return null;
    }
});
```

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

```typescript
const mockProxy = snapshotProxy();
mockProxy('publicityClaimFacade', 'queryLatestPeriodSummaryByBrand', {
  success: true,
  model: {
    brandPeriodVO: {},
    apportionReports: [],
    publicityClaimSummaries: {}, // 这里本来应该是一个数组的，这里会导致函数直接 throw
  },
});
```

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

### 总计

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

---

**Documents**

- [基于实践谈谈软件架构设计](https://iling.fun/s/blog/doc/5z65lqo5a6e6le16lci6lci6l2v5lu25p625p6e6k66k6h-tsGrQnNSNu)
- [rspack 使用体验过程](https://iling.fun/s/blog/doc/rspack-u8S4Mtjkfz)
- [E2E test with Playwright](https://iling.fun/s/blog/doc/e2e-test-with-playwright-JLFKAvWQ0U)
- [Vim everywhere](https://iling.fun/s/blog/doc/vim-everywhere-v6WZyLzGA1)
- [滴答 FaaS 研发平台小结](https://iling.fun/s/blog/doc/faas-XQxhEO3e5r)
- [Cod 服务编排之路](https://iling.fun/s/blog/doc/cod-0CFsOfiX20)
- [Cod 起源和用武之地](https://iling.fun/s/blog/doc/cod-1aAA2rVJZY)
- [2019 年度总结](https://iling.fun/s/blog/doc/2019-RhbTD2SiCn)
- [凤蝶区块历史及其问题排查记录](https://iling.fun/s/blog/doc/5yek6j225yy65z2x5y6g5yy5yk5yw26zeu6aky5o6s5pl6k6w5b2v-WoaaJ9QHpq)
- [如何用单元测试](https://iling.fun/s/blog/doc/5aac5l2v55so5y2v5ywd5rwl6kv-3zwG1cJvTn)
- [小钱袋 Node 应用开发经验总结](https://iling.fun/s/blog/doc/node-QpFfYCuoE0)
- [简单 JS 算数表达式求值算法的实现探索](https://iling.fun/s/blog/doc/js-FXcWkpW2vw)
- [凤蝶可视化编辑实现原理](https://iling.fun/s/blog/doc/5yek6j225yv6keg5yyw57yw6l6r5a6e546w5y6f55cg-ZsBj0CS1ev)
- [凤蝶服务端渲染经验总结二](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut5lqm-a4Kd59d7Td)
- [凤蝶服务端渲染经验总结](https://iling.fun/s/blog/doc/5yek6j225pyn5yqh56uv5riy5pt57up6aqm5oc757ut-GBhTESymbn)
- [Node.js 中 sourcemap 使用问题总结](https://iling.fun/s/blog/doc/nodejs-sourcemap-duex3wDhWt)
- [如何实现一个 mvvm 组件](https://iling.fun/s/blog/doc/mvvm-8TXAVSXe77)
- [webpack 热加载原理探索](https://iling.fun/s/blog/doc/webpack-7Wjn9z1fUB)
- [再谈 vim](https://iling.fun/s/blog/doc/vim-UBf5u57ko7)
- [How to realize velocity template interpreters](https://iling.fun/s/blog/doc/how-to-realize-velocity-template-interpreters-4s1KEznjYR)
- [joycss](https://iling.fun/s/blog/doc/joycss-bGvBvDGF7k)