单元测试本地化方案以及jmokit 的使用

346 阅读9分钟

单元测试简介及方案探讨:

单元测试简介:

怎么定义一个单元

一个maven项目中,一般会有多个分层:

  • common 公共层: 这里主要提供一些工具包,包括对配置、redis 、消息中间件等的最底层访问。
  • repository / dao 数据库访问层:一般是以接口的形式定义数据库访的方法,以及一系列的实现类去实现
  • service 层: 业务逻辑主要处理的地方。
  • handler层:主要负责权限校验、参数校验等操作
  • controller层:如果有前端直接调用项目接口,那么就会有controller层,这一层主要用于接收请求,设置返回状态以及返回请求

那么一个单元怎么定义呢?取决于单元测试的方式,一般来说会有三种定义:

  • 大型测试:请求在handler构造,通过service、dao等多个模块的处理,最终返回到handler,然后校验响应结果是否符合预期。
  • 中型测试:使用mock(模拟)的方式mock掉一些类和一些方法的行为,使得我们可以着重关注我们所要测试的某一些模块。比如,mock掉 dao 层(使得dao层返回我们想要的数据,即假设dao层行为正确),这样我们便可以只关注业务逻辑,不必考虑数据访问是否正确
  • 小型测试:只针对一个类或者一个方法进行测试,所有不在该类中的方法全部进行mock,保证某一个类或某个方法的行为正确

UT (Unit Test)本地化

“ UT本应就是Local属性的。UT不应该依赖外部环境。”

对于一个项目的开发,可能会经历多个不同的环境:比如 dev开发环境、fat / fws测试环境、uat对比测试、lpt压测环境等等。而每一个环境,都会对应这个环境的数据库、配置文件等等。如果我们希望单元测试在任何一个环境都不失败,那么基本只有两种方式:

  • 保持每个环境数据库、配置文件等等相同。缺点当然显而易见:难以维护,一个数据的变更需要设置到每个环境
  • 使用模拟的方式,mock掉任何依赖环境的部分进行单元测试,也就是本地化,单元测试与环境无关。

单元测试本地化方案探讨:

单元测试粒度:

对于一个足够熟悉业务逻辑、足够了解整个项目,甚至整个项目就是他自己写的的人来说,单元测试的粒度是无关紧要的。因为可能在测试之前就知道了会接触哪些数据库,会接触什么配置以及会处理怎样的输入,得到哪些结果。

但是对于一个不熟悉的新手来说,写单元测试尽量采用小型测试,即对某个类或者某个方法进行单独测试,mock其它接触类的行为,理由如下:

  • 简单:当你只关注于某一个类的代码逻辑,只关注方法的输入参数和最后的返回值,而不用关注整个请求的流转时,所需要读的代码会大大减少。
  • 正确性:当我们模拟其它类对某个类进行测试时,我们一定能够保证这个类的正确性。而被模拟的类的正确性,应该由被模拟类的测试方法来检验。举个例子,我们在对handler进行测试时,mock掉service层,即便service层某些逻辑仍然没有依赖外部环境。因为在模拟service的行为对handler进行测试时,我们能保证handler类行为的正确性。对于service行为是否正确,应该由service的测试类进行测试并反馈。
  • 覆盖率高:很显然的一件事是,当测试的粒度越小,代码的覆盖率就会越高。对于跨越多个模块的测试,很难通过构造不同请求的方式走遍所有branch (分支,包括 if-else、switch、try-catch 等等)。然而对于一个方法,通过构造不同参数的方式走完所有分支是完全可行且简单的。

细粒度单元测试实战

依赖引入:

这里主要引入junit 和 jmokit,推荐官方引入方法:

note:

jmokit应该在junit依赖之后进行引入,否则会初始化失败

spring 项目

区别于一般项目,spring 项目中会使用许多@Autowired方法进行属性的注入。这给我们在单元测试时提供了许多方便。

我们知道 @Autowired 是通过读取配置的方式进行Bean的自动注入,所以,首先我们需要一个基础类引入配置,之后所有的类只需要继承这个类即可,不需要再次引入配置:

BaseTest.class

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = "SpringConfig.class")
public class BaseTest {
    @Before
    public void setUp(){
        
    }
    @Test
    public void doTest(){
        
    }
    @After
    public void shutDown(){
        
    }
}

大致解释一下几个注释的作用:

  • @RunWith :一个运行器,此处指明使用junit进行测试
  • @ContextConfiguration 配置Spring Bean的位置,在这里使用class进行Bean的配置
  • @Before、@Test、@After 分别是测试方法执行前、中、后的行为

SpringConfig.class

这一个类主要用于让spring容器找到bean存在的位置。这也是我们进行mock的关键一步。因为让Spring 找到什么bean 是我们自己去定义的,也就是说我们可以定义一些自己想要的bean来取代原有的bean,进行类行为的mock

假如项目中有一个BaseDao,是一个访问数据库的基础类,里面提供了最基本的增删改查方法。当我们在做UT本地化的时候,我们一定不希望类去访问数据库,这个时候我们便可以通过两种方式:继承类或者实现接口,来模拟这个类的行为。在这里我们假设BaseDao是一个类而不是接口。

@Configuration
@ComponentScan
public class SpringConfig{
  @Bean
  public BaseDao(){
    return new BaseDaoMock();
  }
}

BaseDao.class

原来项目中真正访问数据库的层,不在 **/test/ 包下,不是我们创建

public class BaseDao{
  // 与数据库连接
  public void add(Object parm){ dao.add(parm); }
  public void delete(Object parm){ dao.delete(parm); }
  public Object update(Object parm){return dao.update(parm);}
  public Object query(Object parm){return dao.query(parm);}
}

现在我们使用另一个类来模拟BaseDao的行为,原理是利用动态性:一个子类可以赋值给一个父类

BaseDaoMock.class

public class BaseDaoMock{
  public void add(Object parm){ 
    // do nothing 
  }
  public void delete(Object parm){}
  public Object update(Object parm){
    return new Object(); // anything you want return
  }
  public Object query(Object parm){return new Object();}
}

到这里我们就完成了BaseDao类行为的模拟,当项目中出现

@Autowired
BaseDao dao;

这种语句时,在测试时dao会被赋值成 BaseDaoMock 而不是 BaseDao

一般项目(包括Spring项目)

这一部分主要是针对一些难以自动注入的类,比如使用new 关键字在方法中直接new 出来,或者一些 static 修饰的静态变量以及初始化代码块等等。这一块主要使用jmokit 来进行模拟。 当然,前面讲解的Spring 也是可以使用jmokit来进行模拟的,但是当项目复杂后,一个service类可能会依赖多个外部环境,比如数据库、配置、消息队列甚至是一些复杂的服务。所以更加推荐使用Spring 配置的形式来进行类行为的模拟,不仅方便简洁,也能够避免很多错误,代码维护上面也较为简单。 下面依次对每种常见情况进行分析。

仍然以上面的BaseDao为例,假设我们现在已经创建了BaseDao和BaseDaoMock两个类(类的具体内容见上)

1,静态或非静态域中直接new 出对象

举个例子,现在有一个类AddService,该类的作用是调用BaseDao添加一个对象。

public class AddService{
  BaseDao dao = new BaseDao();
  
  public void add(Object o){
    dao.add(o);
  }
}

很显然我们不能够像刚才一样自动注入,但是我们的目的仍然是替换掉dao这个变量,变成我们想要的对象,此时,我们可以使用jmokit变量赋值的方式:

public class AddServiceTest extends BaseTest {
  AddService service;
  @Override
  public void setUp(){
    // 该方法在BeseTest 中已经用@Before关键字修饰过,直接重写就好
    service = new AddService();
    // 注入变量值
    Deencapsulation.setField(service,"dao",new BaseDaoMock());
  }
}

解释一些setField 的三个参数:

  • service : 需要被注入值的对象。如果我们需要对static 修饰的静态变量注入值,改为AddService.class 即可
  • dao : 被注入域的名称
  • BaseDaoMock: 注入的对象

这样,在访问dao这个变量时就是访问的其实是BaseDaoMock

2,方法中

同样是AddService.class ,我们现在换一种方法去写:

public class AddService{
  public void add(Object o){
    BaseDao dao = new BaseDao();
    dao.add(o);
  }
}

直接在方法中new一个对象出来,这个该怎么办呢?

最为暴力的方式就是直接使用MockUp。

public class AddServiceTest extends BaseTest {
  AddService service;
  @Override
  public void setUp(){
    // 该方法在BeseTest 中已经用@Before关键字修饰过,直接重写就好
    service = new AddService();
    // 强制模拟该类的行为
    new MockUp<BaseDao>(BaseDao.class){
      @Mock
      public void add(Object o){
         
      }
    }
  }
}

MockUp是一个很强大的方式,几乎可以解决一切问题。但是滥用可能会造成整个代码结构的复杂冗余,导致单元测试极难维护,有时也会出现一些意想不到的bug。关于MockUp的更多用法,请参考官方文档:

3,初始化代码块和构造方法:

有时,我们可能会遇到一些特别棘手的情况,比如现在有一个提供redis 缓存的对象provider,这个对象无法直接构造(或者说在构造方法或者静态方法中就已经依赖于外部环境了)。

CacheProvider.class
public class CacheProvider{
		static{
			// 里面的语句依赖于外部环境
		}
		public CacheProvider(){
			// 构造方法中的语句依赖了外部环境
		}
}

这个时候使用简单的MockUp mock掉静态代码块和构造方法即可

// 建议声明为一个MockUp的子类,避免重复Mock同一代码
public class CacheMock extends MockUp<CacheProvider> {
  @Mock
  public void $init(){
    // mock 构造方法
  }
  @Mock
  public void $clinit(){
    // mock 静态代码块
  }
}

这篇文章主要是个人的理解和开放性的探讨,如果有什么不对的地方或者有更好的思路欢迎下面留言大家一起探讨~