本文将分享结合 京东智能设计平台羚珑 项目自身状况搭建的测试工做流的实践经验,针对于 Node.js 服务端应用的工具方法和接口的单元测试、集成测试等。实践经验能给你带来:javascript
文中涉及的基础技术栈有(须要了解的知识):html
羚珑 是京东旗下智能设计平台,提供在线设计服务,主要包括大类如:前端
基于行业领先技术,为商家、用户提供丰富的设计能力,实现快速产出。java
先介绍下羚珑项目的架构,方便后续的描述和理解。羚珑项目采用先后端分离的机制,前端采用 React Family 的基础架构,再加上 Next.js 服务端渲染以提供更好的用户体验及 SEO 排名。后端架构则以下图所示,流程大概是 浏览器或第三方应用访问项目 Nginx 集群,Nginx 集群再经过负载均衡转发到羚珑应用服务器,应用服务器再经过对接外部服务或内部服务等,或读写缓存、数据库,逻辑处理后经过 HTTP 返回到前端正确的数据。node
接下来,根据项目所需咱们对比下当下 Node.js 端主流的测试框架。git
Jest | Mocha | AVA | Jasmine | |
---|---|---|---|---|
GitHub Stars | 28.5K | 18.7K | 17.1K | 14.6K |
GitHub Used by | 1.5M | 926K | 46.6K | 5.3K |
文档友好 | 优秀 | 良好 | 良好 | 良好 |
模拟功能(Mock) | 支持 | 外置 | 外置 | 外置 |
快照功能(Snapshot) | 支持 | 外置 | 支持 | 外置 |
支持 TypeScript | ts-jest | ts-mocha | ts-node | jasmine-ts |
详细的错误输出 | 支持 | 支持 | 支持 | 未知 |
支持并行与串行 | 支持 | 外置 | 支持 | 外置 |
每一个测试进程隔离 | 支持 | 不支持 | 支持 | 未知 |
*文档友好:文档结构组织有序,API 阐述完整,以及示例丰富。github
分析:mongodb
综上,咱们选择了 Jest 做为基础测试框架。数据库
接下来,咱们从 0 到 1 开始实践,首先是搭建测试流,虽然 Jest 能够达到开箱即用,然而项目架构不尽相同,大多时候须要根据实际状况作些基础配置工做。如下是根据羚珑项目提取出来的简化版项目目录结构,以下。json
├─ dist # TS 编译结果目录 ├─ src # TS 源码目录 │ ├─ app.ts # 应用主文件,相似 Express 框架的 /app.js 文件 │ └─ index.ts # 应用启动文件,相似 Express 框架的 /bin/www 文件 ├─ test # 测试文件目录 │ ├─ @fixtures # 测试固定数据 │ ├─ @helpers # 测试工具方法 │ ├─ module1 # 模块1的测试套件集合 │ │ └─ test-suite.ts # 测试套件,一类测试用例集合 │ └─ module2 # 模块2的测试套件集合 ├─ package.json └─ yarn.lock
这里有两个小点:
@
开头的目录,咱们定义为特殊文件目录,用于提供些测试辅助工具方法、配置文件等,平级的其余目录则是测试用例所在的目录,按业务模块或功能划分。以 @
开头能够清晰的显示在同级目录最上方,很容易开发定位,凑巧也方便了编写正则匹配。test-suite.ts
是项目内最小测试文件单元,咱们称之为测试套件,表示同一类测试用例的集合,能够是某个通用函数的多个测试用例集合,也能够是一个系列的单元测试用例集合。首先安装测试框架。
yarn add --dev jest ts-jest @types/jest
由于项目是用 TypeScript 编写,因此这里同时安装 ts-jest @types/jest
。而后在根目录新建 jest.config.js
配置文件,并作以下小许配置。
module.exports = { // preset: 'ts-jest', globals: { 'ts-jest': { tsConfig: 'tsconfig.test.json', }, }, testEnvironment: 'node', roots: ['<rootDir>/src/', '<rootDir>/test/'], testMatch: ['<rootDir>/test/**/*.ts'], testPathIgnorePatterns: ['<rootDir>/test/@.+/'], moduleNameMapper: { '^~/(.*)': '<rootDir>/src/$1', }, }
preset: 预设测试运行环境,多数状况设置为 ts-jest
便可,若是须要为 ts-jest
指定些参数,如上面指定 TS 配置为 tsconfig.test.json
,则须要像上面这样的写法,将 ts-jest
挂载到 globals
属性上,更多配置能够移步其官方文档,这里。
testEnvironment: 基于预设再设置测试环境,Node.js 须要设置为 node
,由于默认值为浏览器环境 jsdom
。
roots: 用于设定测试监听的目录,若是匹配到的目录的文件有所改动,就会自动运行测试用例。<rootDir>
表示项目根目录,即与 package.json
同级的目录。这里咱们监听 src
和 test
两个目录。
testMatch: Glob
模式设置匹配的测试文件,固然也能够是正则模式,这里咱们匹配 test
目录下的全部文件,匹配到的文件才会当作测试用例执行。
testPathIgnorePatterns: 设置已经匹配到的但须要被忽略的文件,这里咱们设置以 @
开头的目录及其全部文件都不当作测试用例。
moduleNameMapper: 这个与 TS paths
和 Webpack alias
雷同,用于设置目录别名,能够减小引用文件时的出错率而且提升开发效率。这里咱们设置以 ~
开头的模块名指向 src
目录。
搭建好测试运行环境,因而即可着手编写测试用例了,下面咱们编写一个接口单元测试用例,比方说测试首页轮播图接口的正确性。咱们将测试用例放在 test/homepage/carousel.ts
文件内,代码以下。
import { forEach, isArray } from 'lodash’ import { JFSRegex, URLRegex } from '~/utils/regex' import request from 'request-promise' const baseUrl = 'http://ling-dev.jd.com/server/api' // 声明一个测试用例 test('轮播图个数应该返回 5,而且数据正确', async () => { // 对接口发送 HTTP 请求 const res = await request.get(baseUrl + '/carousel/pictures') // 校验返回状态码为 200 expect(res.statusCode).toBe(200) // 校验返回数据是数组而且长度为 5 expect(isArray(res.body)).toBe(true) expect(res.body.length).toBe(5) // 校验数据每一项都是包含正确的 url, href 属性的对象 forEach(res.body, picture => { expect(picture).toMatchObject({ url: expect.stringMatching(JFSRegex), href: expect.stringMatching(URLRegex), }) }) })
编写好测试用例后,第一步须要启动应用服务器:
第二步运行测试,在命令行窗口输入:npx jest
,以下图能够看到用例测试经过。
固然最佳实践则是把命令封装到 package.json
里,以下:
{ "scripts": { "test": "jest", "test:watch": "jest --watch", } }
以后即可使用 yarn test
来运行测试,经过 yarn test:watch
来启动监听式测试服务。
虽然上面已经完成基本的测试流程开发,但很明显的一个问题是每次运行测试,咱们须要先启动应用服务,共启动两个进程,而且须要提早配置 ling-dev.jd.com
指向 127.0.0.1:3800
,这是一个繁琐的过程。因此咱们引入了 SuperTest
,它能够把应用服务集成到测试服务一块儿启动,而且不须要指定 HTTP 请求的主机地址。
咱们封装一个公共的 request
方法,将它放在 @helpers/agent.ts
文件内,以下。
import http from 'http' import supertest from 'supertest' import app from '~/app' export const request = supertest(http.createServer(app.callback()))
解释:
app.callback()
而不是 app.listen()
,是由于它能够将同一个 app
同时做为 HTTP 和 HTTPS 或多个地址。app.callback()
返回适用于 http.createServer()
方法的回调函数来处理请求。http.createServer()
建立一个未监听的 HTTP 对象给 SuperTest,固然 SuperTest 内部也会调用 listen(0)
这样的特殊端口,让操做系统提供可用的随机端口来启动应用服务器。因此上面的测试用例咱们能够改写成这样:
import { forEach, isArray } from 'lodash’ import { JFSRegex, URLRegex } from '~/utils/regex' // 引入公共的 request 方法 import { request } from '../@helpers/agent' test('轮播图个数应该返回 5,而且数据正确', async () => { const res = await request.get('/api/carousel/pictures') expect(res.status).toBe(200) // 一样的校验... })
由于 SuperTest
内部已经帮咱们包装好了主机地址并自动启动应用服务,因此请求接口时只需书写具体的接口,如 /api/carousel/pictures
,也只需运行一条命令 yarn test
,就能够完成整个测试工做。
项目架构中能够看到数据库使用的是 MongoDB,在测试时,几乎全部的接口都须要与数据库链接。此时可经过环境变量区分并新建 test 数据库,用于运行测试用例。有点很差的是测试套件执行完成后须要对 test 数据库进行清空,以免脏数据影响下个测试套件,尤为是在并发运行时,须要保持数据隔离。
使用 MongoDB Memory Server
是更好的选择,它会启动独立的 MongoDB 实例(每一个实例大约占用很是低的 7MB 内存),而测试套件将运行在这个独立的实例里。假如并发为 3,那就建立 3 个实例分别运行 3 个测试套件,这样能够很好的保持数据隔离,而且数据都保存在内存中,这使得运行速度会很是快,当测试套件完成后则自动销毁实例。
接下来咱们把 MongoDB Memory Server
引入实际测试中,最佳方式是把它写进 Jest 环境配置里,这样只须要一次书写,自动运行在每一个测试套件中。因此替换 jest.config.js
配置文件的 testEnvironment
为自定义环境 <rootDir>/test/@helpers/jest-env.js
。
编写自定义环境 @helpers/jest-env.js
:
const NodeEnvironment = require('jest-environment-node') const { MongoMemoryServer } = require('mongodb-memory-server') const child_process = require('child_process') // 继承 Node 环境 class CustomEnvironment extends NodeEnvironment { // 在测试套件启动前,获取本地开发 MongoDB Uri 并注入 global 对象 async setup() { const uri = await getMongoUri() this.global.testConfig = { mongo: { uri }, } await super.setup() } } async function getMongoUri() { // 经过 which mongod 命令拿到本地 MongoDB 二进制文件路径 const mongodPath = await new Promise((resolve, reject) => { child_process.exec( 'which mongod', { encoding: 'utf8' }, (err, stdout, stderr) => { if (err || stderr) { return reject( new Error('找不到系统的 mongod,请确保 `which mongod` 能够指向 mongod') ) } resolve(stdout.trim()) } ) }) // 使用本地 MongoDB 二进制文件建立内存服务实例 const mongod = new MongoMemoryServer({ binary: { systemBinary: mongodPath }, }) // 获得建立成功的实例 Uri 地址 const uri = await mongod.getConnectionString() return uri } // 导出自定义环境类 module.exports = CustomEnvironment
Mongoose 中即可以这样链接:
await mongoose.connect((global as any).testConfig.mongo.uri, { useNewUrlParser: true, useUnifiedTopology: true, })
固然在 package.json
里须要禁用 MongoDB Memory Server
去下载二进制包,由于上面已经使用了本地二进制包。
"config": { "mongodbMemoryServer": { "version": "4.0", // 禁止在 yarn install 时下载二进制包 "disablePostinstall": "1", "md5Check": "1" } }
大多时候接口是须要登陆后才能访问的,因此咱们须要把整块登陆功能抽离出来,封装成通用方法,同时借此初始化一些测试专用数据。
为了使 API 易用,我但愿登陆 API 长这样:
import { login } from '../@helpers/login' // 调用登陆方法,根据传递的角色建立用户,并返回该用户登陆的 request 对象。 // 支持多参数,根据参数不一样自动初始化测试数据。 const request = await login({ role: 'user', }) // 使用已登陆的 request 对象访问须要登陆的用户接口, // 应当是登陆态,并正确返回当前登陆的用户信息。 const res = await request.get('/api/user/info')
开发登陆方法:
// @helpers/agent.ts // 新添加 makeAgent 方法 export function makeAgent() { // 使用 supertest.agent 支持 cookie 持久化 return supertest.agent(http.createServer(app.callback())) }
// @helpers/login.ts import { assign, cloneDeep, pick } from 'lodash' import { makeAgent } from './agent' export async function login(userData: UserDataType): Promise<RequestAgent> { userData = cloneDeep(userData) // 若是没有用户名,自动建立用户名 if (!userData.username) { userData.username = chance.word({ length: 8 }) } // 若是没有昵称,自动建立昵称 if (!userData.nickname) { userData.nickname = chance.word({ length: 8 }) } // 获得支持 cookie 持久化的 request 对象 const request: any = makeAgent() // 发送登陆请求,这里为测试专门设计一个登陆接口 // 包含正常登陆功能,但还会根据传参不一样初始化测试专用数据 const res = await request.post('/api/login-test').send(userData) // 将登陆返回的数据赋值到 request 对象上 assign(request, pick(res.body, ['user', 'otherValidKey...'])) // 返回 request 对象 return request as RequestAgent }
实际用例中就像上面示例方式使用。
从项目架构中能够看到项目也会调用较多外部服务。比方说建立文件夹的接口,内部代码须要调用外部服务去鉴定文件夹名称是否包含敏感词,就像这样:
import { detectText } from '~/utils/detect' // 调用外部服务检测文件夹名称是否包含敏感词 const { ok, sensitiveWords } = await detectText(folderName) if (!ok) { throw new Error(`检测到敏感词: ${sensitiveWords}`) }
实际测试的时候并不须要全部测试用例运行时都调用外部服务,这样会拖慢测试用例的响应时间以及不稳定性。咱们能够创建个更好的机制,新建一个测试套件专门用于验证 detectText
工具方法的正确性,而其余测试套件运行时 detectText
方法直接返回 OK 便可,这样既保证了 detectText
方法被验证到,也保证了其余测试套件获得快速响应。
模拟功能(Mock)就是为这样的情景而诞生的。咱们只须要在 detectText
方法的路径 utils/detect.ts
同级新建__mocks__/detect.ts
模拟文件便可,内容以下,直接返回结果:
export async function detectText( text: string ): Promise<{ ok: boolean; sensitive: boolean; sensitiveWords?: string }> { // 删除全部代码,直接返回 OK return { ok: true, sensitive: false } }
以后每一个须要模拟的测试套件顶部加上下面一句代码便可。
jest.mock('~/utils/detect.ts')
在验证 detectText
工具方法的测试套件里,则只需 jest.unmock
便可恢复真实的方法。
jest.unmock('~/utils/detect.ts')
固然应该把 jest.mock
写在 setupFiles
配置里,由于须要模拟的测试套件占绝大多数,写在配置里会让它们在运行前自动加载该文件,这样开发就没必要每处测试套件都加上一段一样的代码,能够有效提升开发效率。
// jest.config.js setupFiles: ['<rootDir>/test/@helpers/jest-setup.ts']
// @helpers/jest-setup.ts jest.mock('~/utils/detect.ts')
模拟功能还有方法模拟,定时器模拟等,能够查阅其文档了解更多示例。
快照功能(Snapshot)
能够帮咱们测试大型对象,从而简化测试用例。
举个例子,项目的模板解析接口,该接口会将 PSD 模板文件进行解析,而后吐出一个较大的 JSON 数据,若是挨个校验对象的属性是否正确可能很不理想,因此可使用快照功能,就是第一次运行测试用例时,会把 JSON 数据存储到本地文件,称之为快照文件,第二次运行时,就会将第二次返回的数据与快照文件进行比较,若是两个快照匹配,则表示测试成功,反之测试失败。
而使用方式很简单:
// 请求模板解析接口 const res = await request.post('/api/secret/parser') // 断言快照是否匹配 expect(res.body).toMatchSnapshot()
更新快照也是敏捷的,运行命令 jest --updateSnapshot
或在监听模式输入 u
来更新。
集成测试的概念是在单元测试的基础上,将全部模块按照必定要求或流程关系进行串联测试。比方说,一些模块虽然可以单独工做,但并不能保证链接起来也能正常工做,一些局部反映不出来的问题,在全局上极可能暴露出来。
由于测试框架 Jest 对于每一个测试套件是并行运行的,而套件内的用例则是串行运行的,因此编写集成测试很方便,下面咱们用文件夹的使用流程示例如何完成集成测试的编写。
import { request } from '../@helpers/agent' import { login } from '../@helpers/login' const urlCreateFolder = '/api/secret/folder' // POST const urlFolderDetails = '/api/secret/folder' // GET const urlFetchFolders = '/api/secret/folders' // GET const urlDeleteFolder = '/api/secret/folder' // DELETE const urlRenameFolder = '/api/secret/folder/rename' // PUT const folders: ObjectAny[] = [] let globalReq: ObjectAny test('没有权限建立文件夹应该返回 403 错误', async () => { const res = await request.post(urlCreateFolder).send({ name: '个人文件夹', }) expect(res.status).toBe(403) }) test('确保建立 3 个文件夹', async () => { // 登陆有权限建立文件夹的用户,好比设计师 globalReq = await login({ role: 'designer' }) for (let i = 0; i < 3; i++) { const res = await globalReq.post(urlCreateFolder).send({ name: '个人文件夹' + i, }) // 将建立成功的文件夹置入 folders 常量里 folders.push(res.body) expect(res.status).toBe(200) // 更多验证规则... } }) test('重命名第 2 个文件夹', async () => { const res = await globalReq.put(urlRenameFolder).send({ id: folders[1].id, name: '新文件夹名称', }) expect(res.status).toBe(200) }) test('第 2 个文件夹的名称应该是【新文件夹名称】', async () => { const res = await globalReq.get(urlFolderDetails).query({ id: folders[1].id, }) expect(res.status).toBe(200) expect(res.body.name).toBe('新文件夹名称') // 更多验证规则... }) test('获取文件夹列表应该返回 3 条数据', async () => { // 与上雷同,鉴于代码过多,先行省略... }) test('删除最后一个文件夹', async () => { // 与上雷同,鉴于代码过多,先行省略... }) test('再次获取文件夹列表应该返回 2 条数据', async () => { // 与上雷同,鉴于代码过多,先行省略... })
测试覆盖率是对测试完成程度的评测,基于文件被测试的状况来反馈测试的质量。
运行命令 jest --coverage
便可生成测试覆盖率报告,打开生成的 coverage/lcov-report/index.html
文件,各项指标尽收眼底。由于 Jest 内部使用 Istanbul 生成覆盖率报告,因此各项指标依然参考 Istanbul。
写完这么多测试用例以后,或者是开发完功能代码后,咱们是否是但愿每次将代码推送到托管平台,如 GitLab,托管平台能自动帮咱们运行全部测试用例,若是测试失败就邮件通知咱们修复,若是测试经过则把开发分支合并到主分支?
答案是必须的。这就与持续集成(Continuous Integration)不谋而合,通俗的讲就是常常性地将代码合并到主干分支,每次合并前都须要运行自动化测试以验证代码的正确性。
因此咱们配置一些自动化测试任务,按顺序执行安装、编译、测试等命令,测试命令则是运行编写好的测试用例。一个 GitLab 的配置任务(.gitlab-ci.yml
)可能像下面这样,仅做参考。
# 每一个 job 以前执行的命令 before_script: - echo "`whoami` ($0 $SHELL)" - echo "`which node` (`node -v`)" - echo $CI_PROJECT_DIR # 定义 job 所属 test 阶段及执行的命令等 test: stage: test except: - test cache: paths: - node_modules/ script: - yarn - yarn lint - yarn test # 定义 job 所属 deploy 阶段及执行的命令等 deploy-stage: stage: deploy only: - test script: - cd /app - make BRANCH=origin/${CI_COMMIT_REF_NAME} deploy-stage
持续集成的好处:
TDD 全称测试驱动开发(Test-driven development),是敏捷开发中的一种设计方法论,强调先将需求转换为具体的测试用例,而后再开发代码以使测试经过。
BDD 全称行为驱动开发(Behavior-driven development),也是一种敏捷开发设计方法论,它没有强调具体的形式如何,而是强调【做为何角色,想要什么功能,以便收益什么】这样的用户故事指定行为的论点。
二者都是很好的开发模式,结合实际状况,咱们的测试更像是 BDD,不过并无彻底摒弃 TDD,咱们的建议是若是以为先写测试能够帮助更快的写好代码,那就先写测试,若是以为先写代码再写测试,或一边开发一边测试更好,则采用本身的方式,而结果是编码功能和测试用例都须要完成,而且运行经过,最后经过 Code Review 对代码质量作进一步审查与把控。
笔者称之为【师夷长技,聚于自身】:结合项目自身的实际状况,灵活变通,造成一套适合自身项目发展的模式驱动开发。
自动化测试提供了一种有保障的机制检测整个系统,能够频繁地进行回归测试,有效提升系统稳定性。固然编写与维护测试用例须要耗费必定的成本,须要考虑投入与产出效益之间的平衡。
欢迎关注凹凸实验室博客:aotu.io
或者关注凹凸实验室公众号(AOTULabs),不定时推送文章: