测试
应用开发中,测试十分重要,在传统 Web 产品快速迭代的时期,每个测试用例都给应用的稳定性提供了一层保障。 API 升级,测试用例可以很好地检查代码是否向下兼容。 对于各种可能的输入,一旦测试覆盖,都能明确它的输出。 代码改动后,可以通过测试结果判断代码的改动是否影响已确定的结果。
所以,应用的 Controller、Service 等代码,都必须有对应的单元测试保证代码质量。 当然,框架和组件的每个功能改动和重构都需要有相应的单元测试,并且要求尽量做到修改的代码能被 100% 覆盖到。
测试目录结构
我们约定 test
目录为存放所有测试脚本的目录,测试所使用到的 fixtures
和相关辅助脚本都应该放在此目录下。
测试脚本文件统一按 ${filename}.test.ts
命名,必须以 .test.ts
作为文件后缀。
一个应用的测试目录示例:
➜ my_midway_app tree
.
├── src
├── test
│ └── controller
│ └── home.test.ts
├── package.json
└── tsconfig.json
测试运行工具
Midway 默认提供 midway-bin
命令来运行测试脚本。在新版本中,Midway 默认将 mocha 替换成了 Jest,它的功能更为强大,集成度更高,这让我们聚焦精力在编写测试代码上,而不是纠结选择那些测试周边工具和模块。
只需要在 package.json
上配置好 scripts.test
即可。
{
"scripts": {
"test": "midway-bin test --ts"
}
}
然后就可以按标准的 npm test
来运行测试了,默认脚手架中,我们都已经提供了此命令,所以你可以开箱即用的运行测试。
➜ my_midway_app npm run test
> my_midway_project@1.0.0 test /Users/harry/project/application/my_midway_app
> midway-bin test
Testing all *.test.ts...
PASS test/controller/home.test.ts
PASS test/controller/api.test.ts
Test Suites: 2 passed, 2 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 3.26 s
Ran all test suites matching /\/test\/[^.]*\.test\.ts$/i.
断言库
jest 中自带了强大的 expect
断言库,可以直接在全局使用它。
比如常用的。
expect(result.status).toBe(200); // 值是否等于某个值,引用相等
expect(result.status).not.toBe(200);
expect(result).toEqual('hello'); // 简单匹配,对象属性相同也为 true
expect(result).toStrictEqual('hello'); // 严格匹配
expect(['lime', 'apple']).toContain('lime'); // 判断是否在数组中
更多断言方法,请参考文档 https://jestjs.io/docs/en/expect
创建测试
不同的上层框架的 测试方法不同,以最常用的 HTTP 服务举例,如果需要测试一个 HTTP 服务,一般来说,我们需要创建一个 HTTP 服务,然后用客户端请求它。
Midway 提供了一套基础的 @midwayjs/mock
工具集,可以帮助上层框架在这方面进行测试。同时也提供了方便的创建 Framework,App ,以及关闭的方法。
整个流程方法分为几个部分:
createApp
创建某个 Framework 的 app 对象close
关闭一个 Framework 或者一个 app
为保持测试简单,整个流程目前就透出这两个方法。
// create app
const app = await createApp<Framework>();
这里传入的 Framework
是用来给 TypeScript 推导类型的。这样就可以返回对应的框架 app 实例了。
当 app 运行完成后,可以使用 close
方法关闭。
import { createApp, close } from '@midwayjs/mock';
await close(app);
事实上, createApp
方法中都是封装了 @midwayjs/bootstrap
,有兴趣的小伙伴可以阅读源码。
测试 HTTP 服务
除了创建 app 之外, @midwayjs/mock
还提供了简单的客户端方法,用于快速创建各种服务对应的测试行为。
比如,针对 HTTP,我们封装了 supertest,提供了 createHttpRequest
方法创建 HTTP 客户端。
// 创建一个客户端请求
const result = await createHttpRequest(app).get('/');
// 测试返回结果
expect(result.text).toBe('Hello Midwayjs!');
推荐在一个测试文件中复用 app 实例。完整的测试示例如下。
import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Framework } from '@midwayjs/web';
import { Application } from 'egg'; // 从特定的框架获取 App 定义
import * as assert from 'assert';
describe('test/controller/home.test.ts', () => {
let app: Application;
beforeAll(async () => {
// 只创建一次 app,可以复用
try {
// 由于Jest在BeforeAll阶段的error会忽略,所以需要包一层catch
// refs: https://github.com/facebook/jest/issues/8688
app = await createApp<Framework>();
} catch (err) {
console.error('test beforeAll error', err);
throw err;
}
});
afterAll(async () => {
// close app
await close(app);
});
it('should GET /', async () => {
// make request
const result = await createHttpRequest(app).get('/').set('x-timeout', '5000');
// use expect by jest
expect(result.status).toBe(200);
expect(result.text).toBe('Hello Midwayjs!');
// or use assert
assert.deepStrictEqual(result.status, 200);
assert.deepStrictEqual(result.text, 'Hello Midwayjs!');
});
it('should POST /', async () => {
// make request
const result = await createHttpRequest(app).post('/').send({ id: '1' });
// use expect by jest
expect(result.status).toBe(200);
});
});
示例:
创建 get 请求,传递 query 参数。
const result = await createHttpRequest(app).get('/set_header').query({ name: 'harry' });
创建 post 请求,传递 body 参数。
const result = await createHttpRequest(app).post('/user/catchThrowWithValidate').send({ id: '1' });
创建 post 请求,传递 form body 参数。
const result = await createHttpRequest(app).post('/param/body').type('form').send({ id: '1' });
传递 header 头。
const result = await createHttpRequest(app)
.get('/set_header')
.set({
'x-bbb': '123',
})
.query({ name: 'harry' });
传递 cookie。
const cookie = [
'koa.sess=eyJuYW1lIjoiaGFycnkiLCJfZXhwaXJlIjoxNjE0MTQ5OTQ5NDcyLCJfbWF4QWdlIjo4NjQwMDAwMH0=; path=/; expires=Wed, 24 Feb 2021 06:59:09 GMT; httponly',
'koa.sess.sig=mMRQWascH-If2-BC7v8xfRbmiNo; path=/; expires=Wed, 24 Feb 2021 06:59:09 GMT; httponly',
];
const result = await createHttpRequest(app).get('/set_header').set('Cookie', cookie).query({ name: 'harry' });
测试服务
在控制器之外,有时候我们需要对单个服务进行测试,我们可以从依赖注入容器 中获取这个服务。
假设需要测试 UserService
。
// src/service/user.ts
import { Provide } from '@midwayjs/decorator';
@Provide()
export class UserService {
async getUser() {
// xxx
}
}
那么在测试代码中这样写。
import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Framework } from '@midwayjs/web';
import * as assert from 'assert';
import { UserService } from '../../src/service/user';
describe('test/controller/home.test.ts', () => {
it('should GET /', async () => {
// create app
const app = await createApp<Framework>();
// 根据依赖注入 Id 获取实例
const userService = await app.getApplicationContext().getAsync<UserService>('userService');
// 根据依赖注入 class 获取实例
const userService = await app.getApplicationContext().getAsync<UserService>(UserService);
// 传入 class 忽略泛型也能正确推导
const userService = await app.getApplicationContext().getAsync(UserService);
// close app
await close(app);
});
});
如果你的服务和请求相关联(ctx),可以使用请求作用域获取服务。
import { createApp, close, createHttpRequest } from '@midwayjs/mock';
import { Framework } from '@midwayjs/web';
import * as assert from 'assert';
import { UserService } from '../../src/service/user';
describe('test/controller/home.test.ts', () => {
it('should GET /', async () => {
// create app
const app = await createApp<Framework>();
// 根据依赖注入 Id 获取实例
const userService = await app.createAnonymousContext().requestContext.getAsync<UserService>('userService');
// 也能传入 class 获取实例
const userService = await app.createAnonymousContext().requestContext.getAsync(UserService);
// close app
await close(app);
});
});
createApp 选项参数
createApp
方法用于创建一个框架的 app 实例,通过传入泛型的框架类型,来使得我们推断出的 app 能够是该框架返回的 app。
比如:
import { Framework } from '@midwayjs/grpc';
// 这里的 app 能确保是 grpc 框架返回的 app
const app = await createApp<Framework>();
createApp
方法其实是有参数的,它的方法签名如下。
async createApp(
appDir = process.cwd(),
options: IConfigurationOptions = {},
customFrameworkName?: string | MidwayFrameworkType | any)
)
第一个参数为项目的绝对根目录路径,默认为 process.cwd()
。
第二个参数为框架的启动参数,比如启动的端口等,由各个框架提供。
第三个参数为框架本身,一般用于自定义框架的测试,默认的框架在 API 内部已经有提供和排序。
比如,上面我们的示例,完整的写法为:
import { Framework } from '@midwayjs/grpc';
// 这里的 app 能确保是 grpc 框架返回的 app
const app = await createApp<Framework>(process.cwd(), { port: 6565 }, Framework);