gitbook/自动化测试高手课/docs/506638.md
2022-09-03 22:05:03 +08:00

337 lines
15 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 11|单元测试(二):四象限法让你的单测火力全开
你好,我是柳胜。
上一讲,我们写了OrderServiceTest测试类,来测试FoodCome的OrderService类。这样不管开发代码还是测试代码,都是简单清楚的。这是因为FoodCome的开发人员对代码有一个好的设计,实现了Controller、Service、Repository等Class的职能划分,OrderService类里专注订单管理,我们写出的OrderServiceTest才能集中火力测试。
但是好的设计不是天上掉下来的,有的团队在刚一开始写的代码结构性并不好,这有可能是项目的问题,需求不明确、赶进度,也有可能是开发人员的技术功底不扎实、抽象能力不够。所以,在软件生命周期内,需要持续重构,才能打磨出好的设计。
今天我们就用一个例子,来看看好的代码设计是怎么打磨出来的,而且,我们要从测试的角度来评估设计的效果,那就是单元测试容易写、覆盖率高、干净易懂,这又叫代码可测试性。
作为自动化测试架构师,你需要掌握观察和评估代码可测试性的能力,有能力推动你的开发团队做好代码设计,走向单元测试。
## 从一个需求说起
有一天,老板给FoodCome订餐系统提了一个需求,希望用户在页面上可以修改自己的邮箱地址。
![图片](https://static001.geekbang.org/resource/image/7a/45/7aa7d008a286da48897364228a21cc45.jpg?wh=1920x1284)
这个修改行为后端实现起来似乎很简单,修改邮箱就是更新数据库里的用户信息。但在FoodCome平台上有一个业务逻辑,邮箱地址域名如果是@foodcome.com,这说明是平台上注册的餐馆商家,否则,就是普通顾客。
这样,我们就需要在用户修改邮箱的时候,加入一个判断,如果邮箱地址的修改是从普通域到@FoodCome域,商家在平台里的数量就+1,否则,就-1。
这个需求看起来很简单,是吧。但是我要告诉你,即使这么简单的逻辑,也需要做好设计,否则,写出来的代码可能完全无法单元测试,不信就来看看吧。
## 版本V1
既然老板提了需求,自然要赶紧完成。开发人员小胜同学立刻在下班前完成了一个版本,新建了一个User类,在User类里有一个changeEmail的方法,负责修改邮箱地址。这是第一版代码**User.java**下的内容:
```java
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(int userId, string newEmail)
{
//查询出来用户的ID,email和type
        object[] data = Database.GetUserById(userId);             
UserId = userId;
Email = (string)data[1];
Type = (UserType)data[2];
//如果和修改前的email相同,直接返回
if (Email == newEmail)
return;
//从数据库里获得商家的数量
int numberOfRestaurtants = Database.GetRestaurant();
//判断用户要修改的类型是商家还是顾客
string emailDomain = newEmail.Split('@')[1];
bool isEmailRestaurant = emailDomain == "foodcome.com";
        UserType newType = isEmailRestaurant                       
            ? UserType.Restaurant                                   
            : UserType.Customer;                                  
//如果是修改成为商家,那么商家数量+1,如果是修改为顾客,商家数量-1
if (Type != newType)
{
int delta = newType == UserType.Restaurant ? 1 : -1;
int newNumber = numberOfRestaurtants + delta;
Database.SaveRestaurant(newNumber);
MessageBus.SendEmailChangedMessage(UserId, newEmail);
}
//提交用户信息修改,入库
Database.SaveUser(this);
}
}
public enum UserType
{
Restaurant = 1,
Employee = 2
}
```
上面的代码并不复杂,主要功能是判断邮件地址是否为FoodCome,然后更新数据库,发送通知到消息总线上。
那它的可测试性怎么样呢?
我们引入一个代码复杂度四象限法则来完成这个评估,它的原理是,用以下2个维度来评估代码:
1.领域复杂度,代码的领域复杂度越高,里面的业务逻辑就越多。
2.对外依赖度,依赖度越高,说明代码和外部的Class交互点多。
每个维度都有高低,2个维度的高低组合在一起就成了四个象限。
1.业务复杂、依赖又少,是业务逻辑集中的代码。比如业务算法。
2.业务简单、依赖又少,是一些简单代码,比如构造函数,数据对象等。
3.业务复杂、依赖多,是复杂度高的代码,测试起来也会比较困难。
4.业务简单、依赖多,是管理依赖的代码。比如消息总线。
![图片](https://static001.geekbang.org/resource/image/b8/00/b828cec1b566e1df1ff6640fe5f32900.jpg?wh=1920x1547)
我们要把开发的代码放到四个象限里,每个象限对应不同的测试策略。那小胜同学的User Class,应该放到哪个象限呢?
User Class里包含了changeEmail的业务逻辑,就是根据Email的地址决定如何变更Restaurant的数量。同时,User Class里还有2个外部依赖:Database和MessageBus,这个依赖还是隐式的,没有声明在成员变量里,你得看代码才能找到2个外部依赖是怎么使用的。
显然,User class属于领域和依赖混合的代码,应该归属于**复杂代码**象限。
那么在这种情况下,怎么测试User Class呢?
为了调用changeEmail方法,你可能会想到Mock Database和MessageBus两个对象,但难点是,根本没有办法把Mock对象传递changeEmail方法里去。
所以,现在我们是没有办法单元测试User Class的,它的可测试性为0。
因此,小胜同学需要重构代码,把**复杂代码**象限的代码分解到**领域代码**象限和**依赖代码**象限。
![图片](https://static001.geekbang.org/resource/image/02/16/0257c4ff30f7be0e4fb0e3f14aa82e16.jpg?wh=1920x1571)
## 版本V2
下面来到第二个版本,小胜将业务逻辑和与外部依赖的交互分离,创建了一个新的UserController Class来专注管理外部依赖。
**UserController.java**的代码如下:
```java
public class UserController
{
//在controller里声明两个外部依赖
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
//changeEmail方法里调用依赖
public void ChangeEmail(int userId, string newEmail)
{
object[] data = _database.GetUserById(userId);
string email = (string)data[1];
UserType type = (UserType)data[2];
var user = new User(userId, email, type);
int numberOfRestaurants = _database.GetRestauruant();
string companyDomainName = "foodcome.com";
//调用User的changeEmail,返回变化的商家数量。
int newNumberOfRestaurants = user.ChangeEmail(
newEmail, companyDomainName, numberOfRestaurants);
//调用依赖,变更数据库和发布消息
if(newNumberOfRestaurants!=numberOfRestaurants){
_database.SaveCompany(newNumberOfRestaurants);
_database.SaveUser(user);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}
}
```
同时我们要修改User Class,它不负责任何依赖,只关注changeEmail的业务逻辑,根据输入参数,算出来新的商家的数量。
**User.java**的代码如下:
```java
public class User
{
public int ChangeEmail(string newEmail,
string companyDomainName, int numberOfRestaurants)
{
if (Email == newEmail)
return numberOfRestaurants;
string emailDomain = newEmail.Split('@')[1];
bool isEmailCorporate = emailDomain == companyDomainName;
UserType newType = isEmailCorporate
? UserType.Restaurant
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
int newNumber = numberOfRestaurants + delta;
numberOfRestaurants = newNumber;
}
return numberOfRestaurants;
  }
}
```
我们发现,相比版本1 ,版本2有了进步,多了UserController Class来专门负责管理外部交互,隐式依赖的Database和MessageBus变成了显式成员变量。User class专门负责业务逻辑,判断为餐馆还是客户。
我们再用复杂度四象限来观察现在的代码,它的分布情况变为下图这样。
可以看到,复杂代码消失了,原先一个User.java文件里的逻辑被分散到了两个文件里,一个是UserController.java,它专注于管理依赖,位于依赖代码象限;还有修改后的User.java,专注于业务逻辑实现,位于领域代码象限。
![图片](https://static001.geekbang.org/resource/image/18/86/181afae377e2d60f74916703810a8386.jpg?wh=1920x1526)
现在可以测试了!
User Class只有一个changeEmail的方法,方法里只是对邮件地址做判断,并计算新的餐馆数量,将新值返回,不会涉及到操作数据库和消息总线。User Class可以测试了,因为不需要做任何Mock,我叫它**简单测试**。
而2个外部依赖Database和MessageBus都被显式地封装在UserController Class里,我们可以通过构造函数来传递Mock对象,UserController也是可以测试的,我叫它**Mock测试**。
但是,版本2依旧不够清爽,还存在两个问题。
1. UserController里实例化了User class,这包含了业务逻辑(如何构造一个User对象),而我们希望UserController更为纯粹,专注依赖管理;
2. User class和Restaurant数据也存在耦合,通过User Class的ChangeEmail函数返回了Restaurant的newNumofRestaurant,这和User的行为模型毫无关系。
好,撸起袖子加油干,我们来完成第三个版本,一次性解决上面提到的问题。
## 版本V3
首先,使用UserFactory模式封装User实例化逻辑,如下:
```java
public class UserFactory
{
public static User Create(object[] data)
{
Precondition.Requires(data.Length >= 3);
int id = (int)data[0];
string email = (string)data[1];
UserType type = (UserType)data[2];
return new User(id, email, type);
}
}
```
可以看到UserFactory也是可以简单测试的✌️!这是阶段性胜利,可喜可贺。
然后,我们常见一个Restaurant Class用来封装关于Restaurant的业务逻辑,如下:
```java
public class Restaurant
{
public string DomainName { get; private set; }
public int NumberOfRestaurant { get; private set; }
public void ChangeNumberOfRestaurants(int delta)
{
Precondition.Requires(NumberOfRestaurants + delta >= 0);
NumberOfRestaurants += delta;
}
public bool IsEmailCorporate(string email)
{
string emailDomain = email.Split('@')[1];
return emailDomain == DomainName;
}
}
```
Restaurant Class也是可以简单测试的!
参考UserFactory的思路,再做一个RestaurantFactory,专门做Restaurant对象的构建,此处省略代码,实现起来其实并不复杂,有兴趣你可以自己写写看。
而UserController更关注与外部依赖的管理和交互,如下:
```java
public class UserController
{
private readonly Database _database = new Database();
private readonly MessageBus _messageBus = new MessageBus();
public void ChangeEmail(int userId, string newEmail)
{
object[] userData = _database.GetUserById(userId);
User user = UserFactory.Create(userData);
object[] restaurantData = _database.GetRestaurant();
Restaurant restaurant = RestaurantFactory.Create(restaurantData);
user.ChangeEmail(newEmail, restaurant);
_database.SaveUser(user);
//如果restaurant数量有变化,就写数据库,发送通知信息
if(restaurant.numberChanged()){
_database.SaveRestaurant(restaurant);
_messageBus.SendEmailChangedMessage(userId, newEmail);
}
}
}
```
UserController Class里没有业务逻辑,只有Database和MessageBus的管理和操作。在单元测试时,只需要用Mock替代,就OK了!UserController是可以轻松Mock测试的!
经过前面的操作,User Class变化如下:
```java
public class User
{
public int UserId { get; private set; }
public string Email { get; private set; }
public UserType Type { get; private set; }
public void ChangeEmail(string newEmail, Restaurant restaurant)
{
if (Email == newEmail)
return;
UserType newType = company.IsEmailCorporate(newEmail)
? UserType.Employee
: UserType.Customer;
if (Type != newType)
{
int delta = newType == UserType.Employee ? 1 : -1;
restaurant.ChangeNumberOfRestaurants(delta);
}
}
}
```
Oh Yeah, User Class是可以简单测试的!
现在我们再来看一下V3版本代码在复杂度四象限的位置,变成了这样:
![图片](https://static001.geekbang.org/resource/image/8e/9b/8e678e078dd95b5ae0e2aa95f2ede29b.jpg?wh=1920x1521)
经过3个版本后,我们把代码重构完,复杂代码完全被消解掉了,分解到了其他3个象限。
现在,我们就可以为不同象限的代码,制定好单元测试策略和优先级了。我特意准备了一张总结表放在了后面。
![图片](https://static001.geekbang.org/resource/image/8e/4e/8e17702d4ab6f9af4555cbc88837634e.jpg?wh=1920x889)
有了这张表,你就可以使用[上一讲](https://time.geekbang.org/column/article/505695)提到的测试方法来做业务逻辑测试和Mock测试了!
## 小结
向着提高代码可测试性的目标,复杂代码经过一步步的重构,被分解到了依赖管理和业务逻辑两个领域,如果也算上数据库代码的话,正好和软件架构的MVC模式一致。这不是凑巧,这恰恰说明好的代码结构等同于单元测试高覆盖率。如果你的项目,单元测试做不下去,测试覆盖率提不上来,那应该重构代码了。
反之也一样,如果开发人员声称做了非常好的设计,那么我们通过单元测试,就应该能直观感受到这个好的设计。期待今天这讲内容成为你开启开发领域大门的一把钥匙。
![图片](https://static001.geekbang.org/resource/image/35/6b/35ddf28fc9c17585e7ef43c40f574f6b.jpg?wh=1920x999)
另外,结合今天的三个版本的代码例子,你也应该发现了,单元测试并不是眉毛胡子一把抓。
首先,你应该关注业务逻辑能否在单元测试里充分测试,这是跟我们自动化测试高度关联的地方。而Mock的目标,则是辅助业务逻辑能够更早更快地测试。以后,当开发人员骄傲地跟你说:“代码行覆盖率达到80%”的时候,你应该和他一起检查下,他到底测了什么。
单元测试看起来已经是个完美的测试方案了,那在单元测试层面还有什么做不到的事么,下一讲我们就来揭晓答案。
## 思考题
制定单元测试覆盖率100%的目标有价值么?如果让你制定目标,你会怎么做?
欢迎你在留言区跟我交流互动,也推荐你把这讲内容分享给更多同事、朋友。