作者/PingCode&Worktile高级架构师 孙敬云
为什么要写测试
随着敏捷开发和DevOps的普及,我相信很多人都听过XXX一天可以部署上万次的例子,我们也很想在自己的项目中应用这样的流水线:
然而现实很骨感,在很多缺乏测试的项目中,真当有同学提交了代码之后,你会立刻将它们部署到服务器上吗?(本文主要是技术探讨,不考虑产品和商务层面限制发布时间的情况)
不会,因为你无法保证部署后的服务质量。
将一个有质量问题的版本部署到某个环境中,这会给很多人造成困扰的,比如依赖这个环境进行开发的开发工程师,正在根据这个环境进行测试的测试工程师,甚至是正在使用这个环境的最终用户。
我们在项目中推广持续交付,它的首要目标一定是保证质量,其次才是提高效率。
那么在这些项目中,如何持续的保证项目质量呢?答案是显而易见,那就是投资测试环节。
但是这里有个误区,很多人一提到测试,就会认为这是测试工程师的事,还有一部分人干脆就想象着让测试工程师们写一批非常全面非常有效的自动化测试脚本。我们且不说要写这样规模的脚本是多大的工作量,因为UI元素的频繁变更,光是持续地维护脚本的可用性就是一个让人非常头疼的事(这里我有一个个人的观点,如果你不能持续的维护测试脚本,那么写它将变的毫无意义)。
所以这里的”投资测试“具体指的是投资什么测试呢?
先不要急,我们根据测试用途和测试导向将软件工程中的测试手法放在一个四象限中,然后按照收益比进行标星,我们会得到下图:
上文我说的”投资测试“其实主要指的是投资”技术导向-支持开发过程“的测试。具体一点就是“静态检查”、“单元测试”和”集成测试“。这是不是说有了这三个测试就天不怕地不怕,想部署什么环境就部署什么环境了呢?那肯定不是的。这三种测试可以帮我们避免99%的问题,对于多数项目来说已经足够了,而对于稳定性、一致性要求更高的项目来说,依然需要自动化测试(重点覆盖用户的标准使用过程即可)、容量测试、安全测试等去避免剩下的1%的可能性。但是不管怎么样,这三种测试足够支撑持续部署到测试环境了。
下面我要分享的这个案例,是我参与的一个项目,它由十几个子产品或模块组成,由若干个Scrum团队参与,每个子产品或模块每天都有若干个版本发布到服务器上。
为了保证整个产品的持续可用性,我们做了很多事,下面主要分享我们如何通过集成测试保证测试环境的质量。
梳理测试场景
先介绍一下这个项目部分节点之间的逻辑关系:
和很多项目一样,这个项目里也存在a依赖b,b依赖c,c又依赖d的情况。现在a、b、c、d、e由不同的团队开发维护,我们不仅要保证每个服务本身是正常的,还要关注项目的整体质量。这就需要我们从全局的角度思考解决方案,那么我们现在主要思考这么几个问题:
- 服务a在开发过程中,如何保证自己和b、c、e、d是正确对接的?
- 服务c更新了之后,如何保证a和b的功能还能用?
- 如果全局测试的话,由谁来负责写和维护呢?发现了问题怎么推进解决呢?
- 单元测试会覆盖一部分逻辑,集成测试要再覆盖一遍吗?还是如何划分?如何保证没有遗漏呢?
- 如果新接入一个服务x,它应该怎么写测试呢?
带着这些问题,我们对”技术导向-支持开发过程“的测试有了这样一些要求:
- 将单元测试和集成测试写在一起,运行测试时在本地把所有依赖服务都启动起来。
- 支持单独运行单元测试(不启动依赖服务,不加载集成测试case)。
- 在本服务部署到测试环境之前,必须经过和通过集成测试。
- 当本服务部署到测试环境之后,提供最新的本服务镜像,并自动触发依赖自己的服务的集成测试。
对于服务a的开发人员,他的工作场景是这样的:
首先,他按开发任务写业务代码,然后在本地运行集成测试,这时系统会自动的构建测试所需的环境(如果整个项目过于庞大,可以在本地只运行单元测试),接着系统会加载单元测试case和集成测试case,依次执行并打印出执行结果。最后他向远程分支推送代码,向主分支发起PR/MR,PR/MR会自动触发CI流水线,在流水线中会运行集成测试,保证本次的PR/MR时健康的。
完成PR/MR的Review之后,分支代码会合并到主分支中,这时又会触发CD流水线,流水线会再次运行集成测试,通过后才会将最新代码部署到测试环境。到这里还没有结束,流水线会将最新的镜像推送到集成测试框架可以访问的制品库中,供其他依赖服务a的服务运行它们的集成测试。接着流水线触发依赖服务a的所有服务的集成测试,将运行结果用消息的方式通知给相关的开发人员,这样在保证服务a的质量的同时,也第一时间确认其他服务的最新状态。
定义集成测试
这里面集成测试的框架要实现这么几个关键点:
1.每个服务的最新镜像都要放在集成测试框架可以访问到的制品库中。
2.每个服务需要根据框架基类派生自己的Executor,实现以下内容:
- 服务启动方式
- 直接依赖的服务
- 环境变量
- 探活方式
- 外部访问地址
- 内部访问地址
- 初始化必要的系统数据
- 其他特殊操作
例如DevOps这个模块的Executor:
class DevOpsRPCDockerExecutor extends ServiceExecutor {
constructor(projectName: string) {
super(projectName, TestEnvServiceNames.devopsRPC, {
imageName: "harbor.pingcode.live/wt/pc-devops:latest",
port: 20004,
externalPort: 20004,
startCommand: "node package/bootstrap.js"
});
}
public getDefaultDependency(): TestEnvServiceNames[] {
return [TestEnvServiceNames.mongodb, TestEnvServiceNames.redis, TestEnvServiceNames.typhonRPC, TestEnvServiceNames.agileRPC];
}
public setup(config: unknown, services: ServiceExecutorDependency): void {
this.options.environments = [
{
name: "MONGODB_URI",
value: services.mongodb.getContractInternalUri()
},
{
name: "REDIS_URI",
value: services.redis.getContractInternalUri()
},
{
name: "AGILE_CLIENT_RPC_URL",
value: services["agile-rpc"].getContractInternalUri()
},
{
name: "TYPHON_CLIENT_RPC_URL",
value: services["typhon-rpc"]!.getContractInternalUri()
}
];
}
protected getWorkingContainerRegex(): string {
return `rpc started at port ${this.options.innerPort}`;
}
protected formatEndpoint(endpoint: DockerEndpoint): string {
const uri = `http://${endpoint.ip}:${endpoint.port}`;
console.log(`DevOpsRPC service is working at ${uri}`);
return uri;
}
public getContractInternalUri(): string {
return `http://${this.containerName}:${this.options.port}`;
}
protected async onExecuted(uri: string): Promise<void> {
console.log(`DevOpsSDK config is updated to ${uri}`);
}
public async initData(): Promise<void> {
await Promise.all([
DBConnecter.dbDriver.insertMany(`XXX`, XXXXX),
DBConnecter.dbDriver.insertMany(`XXX`, XXXXX),
DBConnecter.dbDriver.insertMany(`XXX`, XXXXX),
DBConnecter.dbDriver.insertMany(`XXX`, XXXXX),
DBConnecter.dbDriver.insertMany(`XXX`, XXXXX)
]);
console.log(`The data for DevOpsRPC is ready`);
}
}
3.每个服务都要在集成测试服务中注册,它们Executor的定义文件能够被获取到。
4.对于使用者,只需声明直接依赖的服务,框架即可启动所有相关服务。
await TestEnvironment.start("pc-flow-gaea", config, {
"mongodb": MongoDBDockerExecutor,
"redis": RedisDockerExecutor,
"elasticsearch": ElasticsearchDockerExecutor,
"agile-rpc": AgileRPCWithIrisDockerExecutor,
"typhon-rpc": TyphonRPCDockerExecutor,
"iris-rpc": IrisRPCDockerExecutor,
"testhub-rpc": TestHubCustomRPCDockerExecutor,
"devops-rpc": DevOpsRPCDockerExecutor
});
5.单元测试文件的后缀为.spec.xx
,集成测试文件的后缀为.integration.spec.xx
。启动测试时通过环境变量或参数区分是否加载集成测试文件。
6.集成测试服务启动时,引擎大体上要处理这么几件事:
- 分析服务之间的依赖关系
- 清理和准备Docker环境
- 下载所有相关的镜像
- 创建新网络,并启动所有的服务(服务之间使用内部访问地址通信)
- 初始化各个服务的系统数据
- 加载所有的测试文件,依次执行
完成这些关键点之后,我们运行集成测试就可以看到这样的效果:
方案完成之后,即可应用于CI/CD的流水线中,然后自然的实现了测试环境的持续部署。
最后,稍微提一下静态检查,静态检查其实很容易,每种开发语言都有现成的工具,只需简单的配置即可。配置完成后可以将启动命令放在集成测试命令之前,让他们顺序执行。
不止于集成测试
在一定程度上,测试环境是第一个完整的环境,如果能够实现测试环境上的持续部署,这在工程化上也是一个重要的里程碑,证明你已经拥有一天部署XX次的工程能力。不过这也是一个新的起点,集成测试只可以保证测试环境的可用性,那么生产环境呢?
说到这里,不得不说一下环境分级,环境分级是非常必要的,但是每个环境应该有它存在的意义,否则就是在浪费资源。下图是我建议的分级方式:
我建议将环境分为四级(数量和名字不重要,重要的是定位):
- 第一级环境要保证功能是完整的
- 第二级环境要保证功能是稳定的
- 第三级环境要保证部分用户可以提前使用
- 第四级环境要保证所有用户都能正常使用
测试环境是第一级环境,它的定位是”保证功能是完整的“,基于这个定位,集成测试是足够的(对于前端而言,需要先部署到测试环境再进行验证,所以UI的测试是放在部署操作之后,关于这一点今天不展开了)。
但是对于下一级环境,集成测试是无力的,我们需要更多其他的测试方式。因此我们对于集成测试也要有一个清晰的定位,不能因为它已经避免了99%的问题就直接上产环境,也不能因为它无法100%保证我们上生产环境而不去写,首先这笔投入是值得的,其次我们基于这笔投入可能更容易有针对性的补充其他的测试,这些组合的测试最终保证了我们稳定的持续交付能力。
最后我想说,不管是集成测试,还是其他的测试,它只是测试环节中的一种工程手法而已,不用神化或弱化它的价值,只要它能在某个时期保证我们项目的健康稳定,那他就是适合我们的,就是好的。
最后,如果你正在找敏捷开发管理工具,或者是研发管理工具,欢迎体验我家PingCode,25人以下可免费使用。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。