有关mock的是是非非

什么是Mock

在人类理解世界的时候,总是假定一个生命周期的主体即对象首先是存在的并且对象和对象之间是存在联系的,这样才能形成我们认知中的世界的样子。但当对象没有诞生或暂时缺失的时候,非常容易想到的是用其他方式模拟这个对象的存在,以便获取相关对象的状态,使互相的联系不至于丢失或导致后续的中断。这个模拟的行为我们称之为:Mock。

可见Mock包含了这两个内容:

  • 模拟的是一个对象或对象的活动(方法)的预期;

  • 模拟的目的是为了完成衔接也即我们通常说的对象上下文的切换。

  • 为了更好地对后续Mock进行描述,这里先介绍下大家可能会使用的Mock工具:

1) 支持单元测试级别的mock工具:EasyMock VS Mockito

EasyMock使用的是expect-run-verify的内部库,这种方式使人联想到TDD,如果中间出现失败,则需要及时做调整,具体的伪代码如下:

import static org.easymock.classextension.EasyMock.*; List mock1 = createMock(List.class); expect(mock1.get(1)).andStubReturn(“one”); expect(mock1.get(2)).andStubReturn(“two”); mock1.clear( ); replay(mock1); verify(mock1);

而Mockito没有使用这种内部库,而是使用了when-then-return,有点类似BDD,非常直观和有效。而且如果中间失败,只在最后显示相关的结果,无需开发在测试中间进行调整。Mockito具体伪代码如下:

import static org.mockito.Mockito.*; Listmock2 = mock(List.class); when(mock2.get(1)).thenReturn(“one”); when(mock2.get(2)).thenReturn(“two”); verify(mock2).clear( );

两者其实比较相近,但是还是有一定的区别,尤其是在失败后的错误输出上,Mockito相比EasyMock更加清晰地指出了具体的问题,而EasyMock只是抛出异常,非常难以定位到具体问题。

EasyMock和Mockito的使用,非常依赖使用者的嗜好,如果用TDD的思维则选择EasyMock,如果趋向于BDD,则可以使用Mockito。两者的优劣其实非常明显,相比起来Mockito因为顺滑的API,更好的truble shooting被更多的使用者接受。

2) 支持集成测试级别的Mock工具

前面我们说到了支持单元测试代码级别的mock,主要是为了快速模拟出相关的对象或方法,但是当出现外部需要模拟的数据或服务的时候,尤其是前端需要后端数据或服务支持的时候,则需要进行搭建server级别的mock,这里说的工具以阿里开源的RAP最为典型。

RAP提供了团队管理、项目管理、文档编写、Mock.js、可视化、接口过渡、文档历史版本、mock插件并且支持本地部署,可以非常好的对前端的提前联调、集成测试,都有非常好的支持。具体的使用这里不做过多的说明,RAP本身文档清晰,作者就不再画蛇添足了。当然,在实际的使用中为了满足的个性化需求,RAP完全可以被进行改造,打造自己的mockserver来支持平时的mock实施。

从软件架构理解如何正确的Mock

软件架构告诉我们,代码分为了两个部分:表达业务生命周期的代码即业务逻辑,表达用户访问生命周期的代码即服务通道。当业务代码非常内聚的时候,业务代码只负责对业务核心逻辑的实现,而服务代码只负责对外提供访问业务逻辑的通道,和用户打交道并且通过上下文切换完成自己的工作。在切分清楚的情况下,两者是一种合作的关系。然而现实却并不如此。

比如很多时候软件工程师错误地为了实现服务通道的重用,将业务逻辑散落在服务通道中,用户在使用服务的时候,也直接对业务进行了相关的处理。这个时候,如果对业务代码进行单元测试,就会发现业务代码需要模拟服务代码的实现,完成上下文切换才能进行单元测试。这就是我们通常说的Mock。这个时候的Mock是在原本错误的切分结果下进行的,势必非常艰难,以致于最后的结果也是错误的。我们要极力避免这种情况下的Mock。我们通过一个单元测试的实例来说明下这种情况。

测试小A童鞋拿到了如下代码,并且准备进行单元测试:

public ProductDAO getCustomerProduct(HttpRequest request) {

     String customerId=Request.getFunction("CustomerId");

     String ProductId=Request.getFunction("ProductId");                                 

     CustomerDAO customer=CustomerManager.getCustomer(CustomerId);      

     ProductDAO product =productManager.getProduct(ProductId); 

     if(product!=null&&product.getCustomerId!=null&&product.getCustomerId.equals(CustomerId)){
      product.setCustomer(Customer); 
      return product;
      }
       return null;
     }

没有经验的小A开始mock,他先mock出了一个HttpRequest容器,以便能够不启动服务器就能够将代码跑起来,然后,发现不仅仅如此,还需要模拟出数据库来,获取数据将代码跑成功。

这时候,mock已经超出了本身的意义,开始对代码进行侵入式的模拟。其实,这段代码的本意只是提供对外服务,而程序员将业务逻辑混入了服务代码,使模拟单元测试不得不通过mock才能跑起来。这就是我们所说的,服务和业务代码的切分不清晰,导致的单元测试的复杂度大大提高了。而对于我们来说,这里的mock其实根本没有必要,只要我们将业务逻辑从这段代码中拆出来(这里不讨论如何正确拆分,我们只说mock),这个时候单元测试根本不用过多地依赖于mock,只需要对服务代码进行顺序的组合,便能够顺利地完成单元测试。

使用Mock的正确姿势

一、在业务层代码逻辑内使用

如上我们已知,如果模拟的逻辑只处在业务逻辑的内部的一个部分,这种模拟是没有问题的。比如,在业务代码过程中,互相依赖或调用的业务无法并行,这个时候使用Mock加速一个业务周期的代码实现。这里还是想啰嗦一句,请尽量让你的代码分层清晰,在混乱的代码中进行mock,对于项目和代码本身,也是一种消耗和自损。为了说明问题,我们先来看下面这个代码:

public ProductDAO getCustomerProduct(HttpRequest request) {

    String customerId =  Request.getFunction("CustomerId");
    
    String ProductId=Request.getFunction("ProductId");        
    
    CustomerDAO customer=CustomerManager.getCustomer(CustomerId);
    
    ProductDAO product=productManager.getProduct(ProductId); 
    
    return anotherFunction.....;
  }

额?这不就是上面代码独立抽了部分出来吗?没错。我们前面说过,其实上面的代码原意是为了提供对外服务,所以混入业务逻辑是错误的。这种服务代码肯定是不存在内部Mock需求的,如果出现请先自己打脸哦!

然后,我们再看另外一个使用的例子:

public interface MyService {

string DoSomething(int i);    }  > public classMyClass {
private readonly MyService service; 

public MyClass(MyService service) {

this.service = service;
 } 

public string void Print( ) {
  var message = service.DoSomething(); Console.WriteLine(message); 

  return message;
   }    }

对应的Mock测试代码如下:

public void TestSomething( ) {

var service = newMock< MyService >( );
  
service.Setup(x => x.DoSomething(It.IsAny<int>())).Returns("XXXXX"); 
  
Assert.AreEqual("XXXXX", new MyClass(service).Print( )); 
  }    }

这里的mock主要的作用就是代替在业务逻辑中的一个未完成的部分,为了后续对业务逻辑代码的支持而存在。从而我们可见,在单元测试中只有涉及纯粹业务逻辑的mock才会对我们提供有效的作用。

二、对外部的模拟

在代码联调或测试阶段,如果外部存在不稳定性或者外部的服务存在诸如安全、资金损失和沟通成本过大,那么通过模拟的方式在一个阶段内可以降低这些风险,并且使用软件工程师能够集中注意力解决业务的核心问题,完成对业务的模拟实现。对于外部数据的模拟需要注意几点:尽量靠近原数据的状态、对数据的类型和转换需要全面、外部模拟不能成为各方沟通的障碍。

从以上描述,我们得出mock对我们的帮助:

  1. 提供了跨团队并行开发的可能

有了Mock,前后端人员只需要定义好接口文档就可以开始并行工作,互不影响,只在最后的联调阶段往来密切;后端与后端之间如果有接口耦合,也同样能被Mock解决;测试过程中如果遇到依赖接口没有准备好,同样可以借助Mock;不会出现一个团队等待另一个团队的情况。这样的话,开发自测阶段就可以及早开展,从而发现缺陷的时机也提前了,有利于整个产品质量以及进度的保证。

  1. 可以模拟那些因为外部原因不存在的对象

你需要调用一个第三方的服务,而第三方并没有准备资源来方便自己调试,就可以自己通过Mock来解决。

  1. 提高测试覆盖率

如果一个接口需要返回几十个结果,其中有些结果是不可能存在的,但是又不得不测试这些情况下的返回情况,这个时候,你通过Mock就能够非常方便地解决这个问题,提高测试的覆盖率,保证后续集成测试的正确率。

  1. 以接近TDD或BDD的方式来驱动开发

换种思路,当单元测试能够提供足够的开发质量保障,那么单元测试将编程开发过程中不可或缺的一环。当单元测试中出现不存的对象时,这个时候mock扮演着上下衔接的作用。间接驱动了业务的实现过程。

Mock在微服务测试中的实践

众所周知,微服务因为自治的开发和运维方式,而被很多现在的主流技术公司所使用,但是也是因为这种特性,使针对它的测试存在很多不确定因素,按照作者的经验,一般存在的很大的问题是各个服务的联调以及边界问题的引入。我们假定有个金融类的平台,使用了微服务的方式来实现,为来不将问题复杂化,这里抽取用户、交易以及基础系统服务为例,详细描述Mock的应用。为了表述清楚,先看下内部的时序图如下: 关系时序

了解了三个服务交互过程的上下文情况,下面我们来说下Mock在局部和整体测试过程中所解决的问题。

1. 单服务领域内的实现

说起单个服务领域内,我们很容易想到单元测试,我们用用户系统的注册服务来说。注册本身属于用户系统提供的一个服务,但是,注册本身的逻辑中还包含对于依赖基础服务的短信验证码和图形验证码,这个时候注册本身的服务看起来是这样:

> doRegister {...

     getCodeReturn( );   //获取手机验证码的返回 

    getRandomcode( );  //获取图形验证码的返回        

    ... 
 }

那么对于单个服务内进行逻辑的完善,这个时候对 getCodeReturn( )和getRandomcode()方法进行Mock来填补逻辑,使开发的专注力集中在注册本身的业务实现。

另外我们通过时序图发现,交易系统如果想实现自己的业务,基础是获得用户系统传递的交易申请单,这个部分不是交易系统独自能在领域内完成的,因为这里涉及两个服务之间的约定的问题,也即协议。假定整个两个系统间约定高于一切,那么如果交易系统单独实现这个部分,那么在后续的集成测试中就会遇到用户系统和交易系统无法“对话“的问题。因此需要在此之前基于约定Mock出一定的返回和获取逻辑。用户系统和交易系统的约定内容如下: 约定

那么在交易系统中我们需要根据约定,Mock用户系统的数据落地来对交易内部的业务进行模拟,代码看起来是这样的:

doRecharge { …
getRechargeID( );

getUserIDStaus( );

isAmountOK( );
… }

这三个方法的返回的数据应该是Mock出来的,例如:

mock {

response {      

  headers {
  ...
  }   

 "getMockData": {  

        "userId": "0001",                     

        "RechargeId":"0000001";                     

        "status": "1"       
 },    ...   }

这个Mock是在双方系统约定的前提下在交易系统领域内实现。从而在单个服务领域内可以实现三种情况下的Mock:

  • 单元测试内的Mock( 前文有所阐述,这里不再举例);

  • 对于其他服务依赖而实现的内部Mock;

  • 基于双方约定,单个服务内实现的Mock,主要为后续集成测试而服务,但不在集成测试时使用。

2. 跨服务领域的实现

在完成各自领域服务代码开发之后,服务和服务之间开始进行联调,之前基于独立服务的mock开始不在这个过程中起作用,而是需要对方实现一定的Mock服务,我们简单称为:“在它而不在我”。这里我们以用户系统和基础服务来详细阐述。

前文所说,用户系统通过在自己服务内的mock完成原本需要基于基础服务提供的依赖,到了联调测试阶段,这部分的mock需要被废弃,是基于几个原因:

用户系统无法知晓基础服务内部对于这些服务的提供方式是否有差异;

基础系统提供的这些服务需要统一到自己的平台,以便能够支持在不同环境下的联调测试;

基础系统之所以需要mock是为了联调测试屏蔽第三方的干扰。因此,基础服务自己需要实现诸如:短信、学历、图形验证码等mock,因此将这些服务进行收敛,形成一个基于基础系统的mock平台,结构如图:

平台

开关这里的定义是:能够支持在联调环境下自由地切换真实和mock数据源,mock平台更多的是为联调测试而存在。

另外,基础服务会通过各种各样的中间件对外发起rpc请求,可以通过平台配置的中间件隔离来设置,平台会对这些中间件进行aop处理实现自动的mock,不需要人工去配置具体的rpc接口,从而简化对外接入的难度,提供测试空间和时间上的保障。

3. 领域外的实现

对于各个微服务来说,联调的难点在于问题的定位,所以在所有的微服务系统要求一旦出现错误,则需要迅速地失败,以便调用链服务能够定位到具体的错误业务和涉及的对应服务,因此这些错误和crash的Mock非常重要,这个Mock超出了具体的服务领域,属于公用的提供给所有服务使用的工具。我们来看下具体的实现: 领域

这些error/crash的mock主要是以TXT文本存在,以JSON的格式提供各个业务场景下的问题汇总数据,这些数据来源于真实场景,并且这些数据被技术服务解析提供给各个服务来mock自己业务领域内的各种失败,某一个业务服务通过日志系统最终定位问题。

在测试环境下的集成测试完成之后,这个error/crash的mock也可以在预发环境进行使用。例如将缓存错误进行回归测试,那么如果预发环境下读缓存时有数据,那么直接可以用这些缓存数据落地到该工具内进行mock即可,确保了在预发环境下回归测试的完整性和正确性。

以上是Mock在微服务内的具体实践,包括了单领域内、跨领域和领域外公共的Mock设计和具体解决问题的思路。

撰写日期 March 9, 2018