关于 Node.js 测试

单元测试是大型 Web 项目必不可少的部分. 测试和其他部分一样, 从一开始就要设计好实现, 否则可能就可能不断给自己买埋坑. Node.js 的测试一般由测试框架和断言库两部分组成.

测试框架用的比较多的是 mocha, 我也用过一阵子的 ava. mocha 大家可能都比较熟悉, ava 可能实际使用的不多. 这两个其实差别也有点大, ava 的话是每个文件都开一个进程执行, 并且文件内的测试默认是并发执行, 听起来就很快. ava 的写法也比 mocha 简洁很多. ava 自带断言库, 并且支持异步断言(assert 通过插件也可以实现, 但没有 ava 简洁). 但 ava 对 typescript 支持不好, 需要 TypeScript 文件先编译成 JavaScript 文件后才能进行测试. 而 mocha 就不存在这个问题.

就如同 ava 官网介绍的, 面向未来的测试框架. 我个人还是非常看好 ava 的, 一旦它支持 TypeScript 了, 我立马就会转过来.

断言库的话一开始实习公司用的是 should. 我不知道为什么很多人喜欢用 should, api 长又难记. 我对它一直都很抗拒, 后面公司的断言库都是使用 assert(chai) 来进行. 除了 should 和 assert 之外还有一个比较有名的断言库就是 expect. 萝卜青菜各有所爱, 我个人偏爱是 assert, 现在主要是使用 power-assert, api 非常简洁, 而且错误提示做的一级棒.

// assert
assert.lengthOf(foo, 3)
// expect
expect(foo).to.have.length(3)
// should
foo.should.have.length(3)
// power-assert
assert(foo.length === 3)

正如 power-assert 官网说的, NO API is the best API. power-assert 简直不要太好, 特别是当断言错误时他的提醒更是极致. 就像下面这样:

    assert(types[index].name === bob.name)
           |    ||      |    |   |   |
           |    ||      |    |   |   "bob"
           |    ||      |    |   Person{name:"bob",age:5}
           |    ||      |    false
           |    |11     "alice"
           |    Person{name:"alice",age:3}
           ["string",98.6,true,false,null,undefined,#Array#,#Object#,NaN,Infinity,/^not/,#Person#]

    --- [string] bob.name
    +++ [string] types[index].name
    @@ -1,3 +1,5 @@
    -bob
    +alice

反观 assert, 原生的 assert 的问题在于功能太少. 所以一般会使用 chai. 而 expect 和 should, 总之我是写不下去.

因为我现在都是在写 TypeScript. 所以现在选定的测试套件是 mocha + power-assert.

测试遇到的问题

我个人参与了很多后端项目的开发, 特别是前期开发部分. 早期的测试文件也是由我来写, 我也认真思考过如何写一个比较清晰可维护的测试目录结构. 这过程也踩了不少坑, 这里简单介绍下.

测试前置数据

很多业务代码的测试是需要一些前置数据的, 一开始, 前置数据我们都是直接在该测试文件的 before 钩子完成, 然后在 after 钩子进行销毁. 但随着项目越来越大, 这种方式的弊端开始显现, 很多文件其实 before 部分是大体一致的, 这样对于代码修改起来就十分不便, 另外频繁的创建销毁也增加了开销.

很自然的就可以想到, 可以有一个全局的钩子, 批量创建测试需要的所有数据, 然后最后再统一销毁. 一开始没有了解到 mocha 的全局钩子, 我们是 mocha 运行一个文件, 该文件导入其他测试文件来进行的. 但其实 mocha 本身有全局钩子的功能, 利用全局钩子就可以干这种事情了. 这样就解决了上述的问题.

单进程测试应用奔溃

但其实还有一个一直被大多数人忽略的问题, 一般我们也是在测试里面启动项目, 然后对项目进行测试, 如果项目运行奔溃, 我们的测试进程也就跟着奔溃了. 结果就是 after 钩子无法被执行. 数据库有残余的测试数据插入. 对后面的再次测试也可能会产生影响.

要解决这个问题. 只能开多进程解决. 我们可以利用 cluster 来实现. mocha 自带延迟执行的功能, 我们需要延缓 mocha 的运行否则其全局钩子可能出问题.

测试覆盖率和持续集成

关于测试覆盖率报告, 我一直都是使用 nyc 来进行. nyc 的优点在于不需要进行额外配置, 兼容 babel, typescript. 测试覆盖统计也很准确, 使用起来非常简单. 并且也可以自定义测试覆盖率报告的输出格式. nyc 输出测试覆盖报告后我们可以再通过 codecov 来上传测试覆盖率报告.

持续集成方面, travis-ci 和 circle-ci 我都使用过. 简单的来说, travis-ci 用的比较多, 书写格式比较简洁, 用法比较简单, 文档也很容易看, 但测试速度贼慢. 而 circle-ci 支持多种格式, 支持 docker, 测试速度快, 但配置起来可能比较麻烦. 我目前基本都在使用 circle-ci, 因为 travis-ci 实在慢并且好像是要收费??

使用 worker 来辅助测试

目前我写的测试目录结构是这样的:

test/
  controllers/
  services/
  middlewares/
  utils/
  index.ts

也就是说基本上测试文件是和项目文件一一对应的. index.ts 内容如下:

import 'mocha'
import '../src/utils/config'
import { Test } from '../src/utils/Test'
import * as cluster from 'cluster'
import * as process from 'process'

if (cluster.isMaster) {
  const worker = cluster.fork()

  before('initial test data', async () => {
    await Test.Instance.connectDB()
    await Test.Instance.mockData()
    console.log('initial success')
  })

  after('destroy test data', async () => {
    await Test.Instance.destroy()
    console.log('destroy success')
  })

  worker.on('message', (msg: string) => msg === 'ok' && run())

} else if (cluster.isWorker) {
  Test.Instance.connectApp().then(() => process.send!('ok')).catch(e => console.log(e))
}

首先说说这个文件, 这个文件有两个进程执行, 主进程(master)运行测试, 在 before 钩子中连接数据库后创建测试数据, 在 after 钩子销毁测试数据. 子进程(worker) 启动项目, 启动后发送消息给主进程通知主进程可以开始测试.

首先我们要了解下 before 和 after 工作机制. mocha 一开始会执行所有文件, 执行结束后, 如果存在裸的 before 则执行, 否则就开始测试. 因此确保 before 在开始测试前就加载是必要的.执行结束后在查看是否存在裸的 after 如果存在在执行. 这里裸的意思是说, before 和 after 不被任何 describe 包围.

为什么要等子进程启动后来通知主进程运行 run 呢, run 的作用是什么? 这个文件中, 确保顺序是非常重要的. 在正式进入测试时, 主进程需要连接好数据库, 子进程需要启动项目. 如果主进程还没连接好数据库就进入测试或者子进程还没有启动项目, 测试就可能出现问题. 所以我们要推迟测试的执行, run 是 mocha 提供的一个方法, 运行 mocha 的时候传入 --delay 就可以使用这个方法.

这样测试, 就不至于应用奔溃导致测试数据或者中间数据无法被销毁的问题. 甚至我们可以捕捉到 worker 错误, 对 worker 进行重启. 但我们无法停止 mocha 所以然并卵.

再说说上面反复出现的 Test.Instance. 这里是一个单例. Test 类内容如下:

// 该文件经过大量删减
export class Test {
  private static _instance: Test

  gameRepository: repositories.GameRepository
  userRepository: repositories.UserRepository

  manager: EntityManager
  database: Connection
  user: entities.User
  accessToken: string
  app: string = `http://127.0.0.1:${port}`

  destroyFunc: () => Promise<void>

  private constructor () { }

  public static get Instance (): Test {
    return this._instance || (this._instance = new this())
  }

  async mockData (): Promise<void> {
    const users = await this.userRepository.save(this.userRepository.create([
      mock.mockUser(), mock.mockUser(), mock.mockUser()
    ]))

    const games = await this.gameRepository.save(this.gameRepository.create([
      mock.mockGame(), mock.mockGame(), mock.mockGame(), mock.mockGame(), mock.mockGame(), mock.mockGame()
    ]))

    this.destroyFunc = async () => {
      await this.gameRepository.remove(games)
      await this.userRepository.remove(users)
    }
  }

  async connectApp (): Promise<void> {
    const { connection } = await import('../index')
    await connection
  }

  async connectDB (): Promise<void> {
    await database()
    this.manager = getManager()
    this.gameRepository = getConnection().getCustomRepository(repositories.GameRepository)
    this.userRepository = getConnection().getCustomRepository(repositories.UserRepository)
  }

  async destroy (): Promise<void> {
    await this.destroyFunc()
  }
}

之所以列出这个文件来, 是因为有几个点也想提一提. 首先是单例的实现, TypeScript 支持设置 constructor 为私有, 这个特性简直就是为了单例而生的. 这样外面就只能通过 Test.Instance 来获取得到实例.

在调用单例的方法时, 都会产生一些副作用, 这些副作用都可以被记录下来, 然后被后面的测试所使用. 这个文件主要是在 connectDB, connectAppmockData 中修改了大量类属性, 这些属性很多会被后面测试所使用. 我们就不需要在后面的测试中再去用 before 去获取. 这样一个单例我认为在测试过程中起到帮助很大.

其次, 我测试路由用的是 supertest, 之前一贯的用法是 request 一个 httpserver. 然而实际上也可以是一个字符串如 http://127.0.0.1:8000. 也因此我们只需要确保 worker 中应用被启动即可, 不用考虑他的 server 如何被 master 使用, 事实上这也是很难做到的.

另外, 一般我们在入口文件就会进行数据库和应用连接, 因此我们导入该文件需要谨慎, 因为第一次导入的时候就会造成应用的启动. 而我只需要应用在 worker 中被使用, 因此我的方法是, 入口文件的导入是延缓执行的, 即上面的 connectApp 中.

async connectApp (): Promise<void> {
  const { connection } = await import('../index')
  await connection
}

只有调用该方法的时候他才会去加载入口文件. 如果我们把加载放到最前面了, 那就可能因为其他文件引用该 Test 文件导致应用连接建立, 进而和我们 worker 的应用建立产生端口冲突.

事实上 dynamic import 也是最近 TypeScript 新支持的功能, 在 2.4 中被加入. 不同于 require, 它是异步执行的, 返回一个 Promise.

最后在说一下, supertest 支持对 httpCode 进行断言, 但我并不推荐使用, 我还是建议使用 power-assert 来进行断言. 因为 supertest 的 expect 断言在断言出错时给的信息严重不全, 我们也无从得知 res.body. 建议不要使用.

使用测试数据库

上面说的方案也有问题, 每次测试都要创建数据, 测试结束后销毁数据. 创建和销毁也需要一些时间. 另外, 使用 nyc 进行测试覆盖率统计也会出现问题, 可能出现统计不全的情况. 同时, 也存在带入脏数据到数据库的隐患.

因此, 更加普遍的做法应该是创建专门用于测试的数据库. 在本地创建一个开发数据库的同时, 创建一个测试数据库. 当然, 如果你本地并不关心开发数据库污染的问题, 大可直接在开发数据库跑测试.

所以, 虽然上面使用多进程的方案看似很酷, 但实际效益可能并不好. 我建议还是采用测试数据库的方案. 可以一开始的时候就创建开发数据库和测试数据库, 同时跑一段脚本塞入初始化数据. 之后测试的时候连接测试数据库, 开发的时候连接开发数据库. 我目前就是这样使用的.

关于测试驱动开发

测试驱动开发(英语:Test-driven development,缩写为TDD)是一种软件开发过程中的应用方法,由极限编程中倡导,以其倡导先写测试程序,然后编码实现其功能得名。测试驱动开发始于20世纪90年代。测试驱动开发的目的是取得快速反馈并使用“illustrate the main line”方法来构建程序。

测试驱动开发是戴两顶帽子思考的开发方式:先戴上实现功能的帽子,在测试的辅助下,快速实现其功能;再戴上重构的帽子,在测试的保护下,通过去除冗余的代码,提高代码质量。测试驱动着整个开发过程:首先,驱动代码的设计和功能的实现;其后,驱动代码的再设计和重构。

测试驱动开发听起来很酷, 目前也比较火. 最近在个人项目中也进行了尝试.

在项目开始的时候, 写好全部接口, 全都返回相同结果(例如都返回 success), 功能先不进行实现. 接着立即开始写测试, 把全部写好的接口都进行测试, 很快就可以轻轻松松的全部测试通过. (理论上步骤应该反过来, 不过我比较习惯接口在控制器来定义好, 然后测试针对性的来测)

接着, 当我们需要开始写一个接口的功能时, 把测试的断言改成我们期望得到的结果, 这时候测试很明显会失败. 为了加快这个过程, 我们可以利用 mocha 的 only 标志来完成, 并在运行 mocha 的时候加上 —watch 参数.

之后我们就通过不断的调整代码, 直到测试通过为止, 然后继续改下一个测试的断言, 开发其功能, 如此往复.

TDD 也是目前正在尝试的一种开发方式. 我觉得, 如果测试单元是路由接口的话, 还是挺能提高效率, 也让开发变得很有目的性, 而且也可以快速测试结果, 得知是否按预期工作.

标签: JavaScript, Node.js, TypeScript, 测试

知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。

添加新评论