You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

337 lines
20 KiB
Markdown

2 years ago
# 13集成测试携手开发集测省力又省心
你好,我是柳胜。
专栏里我一直强调这样一个观点,**以全局ROI最高为导向把各个测试类型综合考虑而非割裂、独立地分析某一层的测试**。不谋全局者,不足谋一域,测试领域如此,开发和测试领域协同也一样。比如前面咱们学过的单元测试,把单元测试做好,实际就能推动开发代码的结构优化。
集成测试也是一样做好集成测试也需要开发的支持。在上一讲里我提到了集成测试轻量化的想法Mock服务和Happy Path。你学完了后可能已经跃跃欲试盘算着自己的项目该怎么做Mock去哪里寻找那条Happy Path了。
但理论推演畅通无阻现实挑战却障碍重重。很可能现实里你面临两难做Mock成本高需要修改很多开发代码调用链条又长又复杂根本理不出那条Happy Path。面临这样的困难要么只能放弃ROI不高的集成测试要么硬着头皮去设计和执行一些测试案例而且这些案例你也不确定是否有效。
有没有第三种方法呢这第三种方法不仅能让集成测试保持轻量和高效而且还能让测试进入一个Bug越来越少的正反馈循环。但需要你主动介入代码世界和开发人员一起推动集成测试的可测试性。后面的知识点不少但只要你跟住我的思路愿意跟着我思考一定不虚此行。
## 接口与Mock
先从开发Mock说起这是集成测试里一项必不可少的工作内容。怎么能又快又简单地开发Mock呢带着这个问题我们来看一下Mock的原理。
在面向对象语言里Interface接口是个常被提及的概念。一个Interface接口定义了契约而实现细节由继承类去完成。在代码里你经常会看到Interface-Class成对出现的情况
```c#
public interface IMessageBus
public class MessageBus : IMessageBus
public interface IUserRepository
public class UserRepository : IUserRepository
```
从设计的角度这叫做解耦,目的是**分离定义和实现**有一个Interface可以对应多个实现Class。那从集成测试的角度来看Mock和Real不就是一个Interface的两个实现么
这么说你可能还没理解,做个对比你就更清晰了,这个场景就像图灵实验。
![图片](https://static001.geekbang.org/resource/image/bb/42/bb9b4954748179217baa116fdcyy4a42.jpg?wh=1920x1260)
在图灵实验里人类C隔着一堵墙分别跟B和A交流。当他分辨不出来哪个是人哪个是电脑的时候我们就说电脑A已经具备了和人类B一样的人工智能了。
在集成测试里,就变成了下图这样:
![图片](https://static001.geekbang.org/resource/image/fb/0c/fbyy7bbe3e8e3e61a2c54e3bb59e150c.jpg?wh=1920x1272)
C通过Interface定义的规范和A、B打交道而Mock和Real都是对Interface MessageBus的实现。所以C不能分辨出哪个是A哪个是B因为它们都遵循Interface MessageBus。
在Java语言里MessageBus的Interface定义如下
```java
public interface IMessageBus{
//发送消息
public void sendEmailChangeMessage(Srting userID,String changedEmail);
//接收消息
public Message fetchMessageById(Integer msgId);
............................
}
```
有了Interface后你可以借助工具生成Mock实现。
下面的例子是Mockito框架直接通过Interface MessageBus生成Mock messageBus
```java
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
public class FoodComeTest{
@Mock
private IMessageBus mockMessageBus;
public class InterfaceMethodMockingUsingMockAnnotationTest {
........
user.changeEmail(userID,changedEmail);
mockMessageBus.sendEmailChangeMessage(userID,changedEmail);
........
}
}
```
通过这个例子我们看得出Interface的存在对于集成测试十分有用有很多Mock框架支持通过Inteface生成Mock Class这就大大降低了Mock的成本。
现在,我们得出了一条**可测试性实践原则**开发人员在实现对外部服务访问的时候应该要设立并通过Interface来完成访问哪怕是一个Interface只有一个实现Class也需要有Interface的存在。
## 高效Happy Path
很好现在Mock的成本降低的原则找到了咱们再接再厉再分析下集成测试的另外一项重要工作。
还记得上一讲我们提到过的Happy Path么它是一条代码执行路径如果你能找到有效的Happy Path集成测试可以做得简单而高效。
但是有没有Happy Path你又能不能找到它这很大程度取决于代码的结构。有的项目代码写得混乱调用链复杂甚至还有回路死循环这样的代码你想集成测试恐怕是走入迷宫难回头。
因此,从集成测试的可测试性角度,代码要满足三条要求:
1.**结构:**领域逻辑和依赖管理分离,清楚可见;
2.**层次:**调用链简洁而短,减少无效代码;
3.**调用关系:**调用链单向,避免回路的产生。
这三条要求具体怎么实现,我们一一来看。
### 领域逻辑和依赖管理的分离
之前讲单元测试时我们已经学习了通过代码四象限法则把代码分解到各个功能象限里去我们以FoodCome为例做了练习分解后的结果就是我们建立Controller专门管理依赖建立User类、Restaurant类专门做业务逻辑相关的操作。
![图片](https://static001.geekbang.org/resource/image/8e/9b/8e678e078dd95b5ae0e2aa95f2ede29b.jpg?wh=1920x1521)
不过明确了分工思路还不够,今天我们就展开说说代码层面的实操细节。
领域逻辑和依赖管理的边界不能只存在开发人员脑子里,否则,项目开发了一段时间后,这个边界就模糊了,甚至埋下项目未来走向腐化的隐患。要让这个边界持续可维护,它应该是可见的。
怎么让这个边界可见呢最直观的做法就是把领域逻辑和依赖管理的代码放在一个项目的不同的package下。
FoodCome划分了如下几个package
* Controller: 用来存放Controller类还有外部依赖的Interface
* Service: 用来存放Service类Controller类调用Service在Service里实现领域逻辑计算
* Dao: Service类调用Dao类实现对数据的持久化。
这样通过不同package组织的代码开发人员在后面修改的时候会自然遵循这些规律把不同功能的代码添加到相应的package里去。
### 调用链条短而简洁
再结合例子说说调用链的问题在FoodCome应用里调用关系是这样的
![图片](https://static001.geekbang.org/resource/image/31/63/318d6700e73d7cbe77eff3c36757b363.jpg?wh=1920x768)
这个调用链非常简单有效从Controller处理请求到最后数据入库就走了三个截面分别是Controller层Service层和DAO层。
在很多项目实践中调用链比这个复杂有的刚开始简单后来项目规模变大后开发人员不断地往里“加货”。比如有的开发人员特别喜欢加抽象层加Interface而且还会引用一堆软件的理论说得有理有据认为Interface可以增加将来的可扩展性。
这些理论没错但这里我想提醒你添加抽象层这件事需要保持一个度。在极限编程里还有一个YAGNI理论YAGNI是英文“You arent gonna need it” 的缩写YAGNI 原则指出,程序员应该在面临确凿的需求时,才要实现相应功能。
### 调用链条单向,避免回路产生
单向指的是我们从Controler调Service从Service调DAO这是调用链的方向而不能反着来。
![图片](https://static001.geekbang.org/resource/image/b3/ff/b3d8ce1c50ce1657078e363b0e1777ff.jpg?wh=1920x709)
回路指的是存在两个或以上的Class它们在调用时互相依赖。
比方说我们开发FoodCome遇到这样的问题在结账时需要检查详细列表而产生详细列表时又需要先结账这就成了一个死循环。
示例代码如下,实际调用路径比这个绕了好几道圈。
```java
public class CheckOutService
{
    public void CheckOut(int orderId)
    {
        var service = new ReportGenerationService();
        service.GenerateReport(orderId, this);
        /* other code */
    }
}
public class ReportGenerationService
{
    public void GenerateReport(
        int orderId,
        CheckOutService checkOutService)
    {
        /* calls checkOutService when generation is completed */
    }
}
```
解决这样的问题,我们就需要理清工作流,从业务逻辑上彻底解除这样的回路依赖关系。
## 集成测试神器
前面我们从查看代码的角度来寻找Happy Path对代码的结构、层次和调用关系提出了一系列要求。
现在,我们再换个思路,能不能从**结果**上观测Happy Path呢也就是说我们有没有办法直观地看到这个调用链条呢
### Happy Path探测神器
你可以在头脑中想象这样一个场景客户端向系统发起一个请求就相当于客户要求物流公司把一个货物送到目的地物流公司先把货物运送到A站点然后A站点再运到B站点以此类推经过多个站点后最后到达目的地仓库。
这个场景里物流公司就是软件系统货物经过的A站点、B站点就是一个个软件服务而货物走过的ABCD路径就是服务的调用链。
要想追溯物流的派送路径,那就得问物流公司的负责人,要想知道服务的调用链,我们就得问系统的管理员。在现实中,这个系统的管理员往往是生产环境的运维人员,它们可以**借用各种运维工具,来观测和追踪生产环境的负载**。
能做链路观测和追踪的运维工具有很多有Zipkin、Dapper、Skywalking等。这里我们以SkyWalking为例来看一下分布式追踪是怎么做的。
SkyWalking是Apache基金项目下的一个开源项目用来做可观测性分析提供分布式跟踪、服务网格遥测分析、度量聚合和可视化一体化解决方案。
在Skywalking [官方网站](https://skywalking.apache.org),你可以看到它的架构图:
![图片](https://static001.geekbang.org/resource/image/47/7a/478582b42e61489f844e47b716c2c27a.jpg?wh=1920x949)
安装配置SkyWalking你参照[这个文档](https://skywalking.apache.org/docs/skywalking-showcase/latest/readme)就能自学。这里我重点说说Skywalking是怎么工作的呢
Skywalking需要在每一个服务上启用Skywalking的Agent也就是图的左上方Tracing部分。
每当服务处理Inbound和Outbound请求时Agent就把这些信息发送到SkyWalking的OAP服务器上去OAP服务将这些信息经过链接、聚合、分析后用户就能通过左下方的UI来查看这些调用信息组织形成的调用链条了。
SkyWalking UI上的功能很多寻找Happy Path我们可以使用下面这2个功能。
先看Topology功能下的服务拓扑图通过拓扑图可以看到所有的服务和它们的调用关系。
![图片](https://static001.geekbang.org/resource/image/ba/55/bacb1aec464202821141yyaaa18d0355.jpg?wh=1920x845)
如果我们想观测一个特定的请求看它走过了哪些路径可以看Skywalking UI上的Trace Tree。
![图片](https://static001.geekbang.org/resource/image/ca/39/cac202a1035e73554d8b0588968eff39.jpg?wh=1920x830)
Trace图里左边导航栏里是一个个的请求右边的Tree视图里展现了每个请求走过的调用链。这张页面截图主要是让你大概了解。清晰的调用关系你可以参看下面这张逻辑图。
![图片](https://static001.geekbang.org/resource/image/65/b4/65f226413ec1381b6564e8784506c7b4.png?wh=1872x706)
有了调用Tree后我们就可以运行两个测试案例A和B使用白盒测试的路径覆盖法则查看哪个测试案例能覆盖更多的路径哪个就是Happy Path了
如果你对白盒测试的路径覆盖方法不太熟悉,可以通过[这个资料](https://www.jianshu.com/p/8814362ea125),了解一下白盒测试的基本理论。
### Docker构建随时可测的数据库
上一讲,我们提到直接用真实的数据库实例来做集成测试。不过这个在实践中,你可能会遇到一系列问题,这个真实的数据库实例是生产环境么?如果不能用生产环境数据库,自己搭建数据库怎么能够保证真实模拟的测试的效果?
这些问题解决不了,集成测试的成本也会很高,甚至导致在实践中,集成测试无法开展。
应该怎么办呢?我先抛出开展集成测试的三个关键目标。
第一,集成测试的环境应该是独立的,和生产环境分离;
第二,集成测试的数据库上的数据库对象应该和生产环境保持同步;
第三,集成测试数据库实例的生成应该是快速的。
要实现第一条和第三条我们可以采用Docker的方式来准备集成测试环境。Docker比较轻量速度也快。但是要做到第二个目标通过Docker构建一个和生产环境同样对象版本的环境这就需要开发人员对数据库的对象、脚本的维护都能进行版本化管理。
这里我说说我的实践经验希望你能灵活应用帮助DBA和开发人员管理好数据库。
第一,**版本化管理**。所有的数据库对象、脚本、数据都要和应用代码一起进行版本化管理。结合自己的实践验证,我特意给你梳理了后面的自检清单。
![图片](https://static001.geekbang.org/resource/image/0a/66/0a29abbd1b644cbf92f766699f458e66.jpg?wh=1920x1006)
这项工作的完成标准,就是你随时可以从版本库里,拉出来一个版本的应用代码和数据库脚本,部署成一个可运行的应用+数据库。
第二,跟应用代码升级一样,数据库的变更也要通过版本化的脚本来完成。
一些开发人员有一个不好的习惯直接用Console等工具变更数据库比如给表加一个字段、修改一个字段的长度等等。这些变更没有记录之后数据库的维护、回滚、重建都会遇到困难。
正确的姿势是开发人员应该写脚本来完成数据库的变更。如果变更数据库schema那就写一段ALTER SQL在开发环境测试通过后把它提交到版本库里**和提交、合并应用代码是一样的流程**。然后Ops通过部署把应用代码和数据库变更一起更新到生产环境。
如果你的开发人员和DBA完成了这些工作那就可以享受集成测试的方便和敏捷了你可以写一个Dockerfile来描述Mysql数据库的生成步骤比如这样
```dockerfile
FROM mysql:5.6
ENV MYSQL_ROOT_PASSWORD liusheng12345
COPY sql/my_install.sql /tmp/
# https://hub.docker.com/_/mysql
COPY sql/seed_data.sh /docker-entrypoint-initdb.d/
COPY cnf/my.cnf /etc/mysql/
```
然后你再运行一个Docker 命令:
```json
Docker build -t "myintegration:1.0" .
```
这样一来,你的数据库实例就启动了,可以测试了!
### 集成测试环境搭建神器
通过前面的学习我们发现Docker具有快速、轻量、低成本的特点是我们搭建集成测试环境的理想技术方案。沿着这个方向你可以学习Kubernetes、Helm、Docker Compose这些Docker部署技术把它们应用在集成测试里但这个学习过程也是个耗时又耗脑细胞的过程。
那有没有一种方案能封装这些技术让测试人员不用懂Docker也能管理集成测试环境呢
有痛点就一定会有人去做解决方案。所以测试容器化应运而生有Testcontainers、Arquillian等工具。我们这就来看一下Testcontainers是怎么做的。
Testcontainer实际就是一组Java Library自动化测试开发人员通过调用Java代码来生成Docker容器实例。
```java
public class OrderTest{
@Container
PostgreSQLContainer<?> postgreSQLContainer = new PostgreSQLContainer<>("postgres:latest");
@Before
public void setup(){
String jdbcURL = postgreSQLContainer.getJdbcUrl();
String username = postgreSQLContainer.getUsername();
String password = postgreSQLContainer.getPassword();
//使用刚创建的数据库容器作为测试数据库
OrderRepository.initializeDB(jdbcUrl,username,password);
}
public void testCreateOrder(){
//创建订单
.........createOrder............
//从数据库容器获得订单数据,验证创建成功
........ fetch order from containerized postgreSQL............
}
}
```
上面的Java代码在new PostgreSQLContainer对象的时候实际上创建并启动了一个用Postgres作为镜像的Docker容器用这个Docker容器作为Order数据库这样创建的订单会被保存在容器里。
容器对象建好以后在后面的Test方法我们就可以连上这个容器对象来验证里面的订单数据了。是不是很简单甚至你根本不需要懂Docker是怎么启动的就可以集成测试了。
利用容器代码化这个便捷方法,你还可以创建错误数据的数据库,做异常测试。
```java
@Container
public PostgreSQLContainer<?> goodPostgreSQLContainer = new PostgreSQLContainer<>("postgres:alpine")
.withInitScript("db_init.sql")
.waitingFor(Wait.forLogMessage("good database startup", 1));
@Container
public PostgreSQLContainer<?> badPostgreSQLContainer = new PostgreSQLContainer<>("postgres:alpine")
.withInitScript("db_wrong.sql")
.waitingFor(Wait.forLogMessage("bad database startup", 1));
```
TestContainer的解决方案不限于数据库还有RabbitMQ、Kafaka消息中间件Elastic search服务实例、Azure云服务等等。更多同类的解决方案你还可以到[这个网站](https://www.testcontainers.org)上去看一下模块列表。
![图片](https://static001.geekbang.org/resource/image/26/28/260cff883d213be85ca940b51a192d28.jpg?wh=1507x1050)
## 小结
一口气学到这里不容易,给坚持学习的你点赞。今天围绕集成测试的可测试性这个主题,我们一起学习了不少开发知识。
开发需要定义Interface这样测试程序就可以通过Interface快速生成可用的Mock对象。简单高效的Happy Path实际要依赖**设计合理、边界清楚、调用简捷的代码**。
除了代码规范我还介绍了一些链路观测、追踪工具。有了这些神器加持你就能直观看到某个请求的调用路径从结果上寻找Happy Path。
结合实践经验来看搭建集成测试环境这项工作也不容忽视。想要快速搭建集成测试环境还需要开发人员和DBA有效管理好数据库对象所有的数据库对象、脚本、数据都要和应用代码一起进行版本化管理。
此外测试环境Docker容器化也是一个必然的趋势我们需要学会怎么利用Docker来容器化集成测试所需的外部实例甚至我们的测试环境也可以被容器化达到开箱即用的效果。
说句题外话,[第十一讲](https://time.geekbang.org/column/article/506638)和这一讲,是第二模块我认为最有价值的两篇内容,它们的出发点都是**从测试推动开发**。
也许现在的你,并没有决定未来选择什么方向发展,但我希望你可以掌握全栈知识,看到更多立足测试思维去解决问题的可能性,自由穿梭于测试和开发之间,这样你的工作前景也会越来越宽广。
## 思考题
分享一下你有哪些好办法来搭建集成测试环境?
欢迎你在留言区和我交流互动,也推荐你把今天这一讲分享给你的同事、朋友。