十年架构师耗尽心血带你如何进行微服务的单元、集成和系统测试?
如何进行微服务的测试
对于测试工作而言,微服务架构对于传统的架构引入了更多的复杂性。一方面,随着微服务数量的增长,测试的用例也会持续增长;另一方面,由于微服务之间存在着一定的依赖性,在测试过程中如何来处理这些依赖,就变得极为重要。
本节将从微服务架构的单元测试、集成测试和系统测试三个方面来展开讨论。
微服务的单元测试
单元测试要求将测试范围局限在服务内部,这样可以保证测试的隔离性,将测试的影响减少到最小。在实际编码之前,TDD要求程序员先编写测试用例。当然,一开始,所有的测试用例应该是全部失败的,然后再写代码让这些测试用例逐个通过。也就是说,编写足够的测试用例使测试失败,编写足够的代码使测试成功。这样,程序员编码的目的就会更加明确。
当然,编写测试用例并非是TDD的全部。在测试成功之后,还需要对成功的代码及时进行重构,从而消除代码的“坏味道”。
1.为什么需要重构代码
所谓重构,简而言之,就是在不改变代码外部行为的前提下,对代码进行修改,以改善程序的内部结构。
重构的前提是代码的行为是正确的,也就是说,关于代码功能已经经过测试,并且测试通过了,这是重构的前提。只有正确的代码才有重构意义。
那么,既然代码都正确了,为什么还要花费时间再去改动代码、重构代码呢?
重构的原因是大部分程序员无法写出完美的代码。他们无法对自己编写的代码完全信任,这也是需要对自己所写的代码进行测试的原因,重构也是如此。归纳起来,以下几方面是软件需要重构的原因。
- ·软件不一定一开始就是正确的。天才程序员只是少数,大多数人不可避免会犯错,所以很多程序员无法一次性写出正确的代码,只能不断地测试、不断地重构,以改善代码。连MartinFowler这样的大师都承认自己的编码水平也同大多数人一样,是需要测试及重构的。
- ·随着时间推移,软件的行为变得难以理解。这种现象特别集中在一些规模大、历史久、代码质量差的软件里面。这些软件的实现,或者脱离了最初的设计,或者混乱不堪,让人无法理解,特别是缺少“活文档”来进行指导,这些代码最终会“腐烂变味”。
- ·能运行的代码,并不一定是好代码。任何程序员都能写出计算机能理解的代码,唯有写出人类容易理解的代码,才是优秀的程序员。
正是目前软件行业这些事实的存在,促使重构成为TDD中必不可少的实践之一。程序员对程序进行重构,是出于以下的目的。
- 消除重复。代码在首次编码时,单纯只是为了让程序通过测试,其间可能会有大量的重复代码,以及“僵尸代码”的存在,所以需要在重构阶段消除重复代码。
- 使代码易理解、易修改。在一开始,程序员优先考虑的是程序的正确性,在代码的规范上并未加以注意,所以需要在重构阶段改善代码。
- 改进软件的设计。好的想法也并非一气呵成,当对以前的代码有更好的解决方案时,果断进行重构来改进软件设计。
- 查找Bug,提高质量。良好的代码不但能让程序员易懂易于理解,同样,也能方便程序员来发现问题,修复问题。测试与重构是相辅相成的。
- 提高编码效率和编码水平。重构技术利于消除重复代码,减少冗余代码,提升程序员的编码水平。程序员编码水平的提升,同时也将体现在其编码效率上。
2.何时应该进行重构
那么,程序员应该在何时进行重构呢?
- 随时重构。也就是说,将重构当作是开发的一种习惯,重构应该与测试一样自然。
- 事不过三,三则重构。当代码存在重复时,就要进行重构了。
- 添加新功能时。添加了新功能,对原有的代码结构进行了调整,意味着需要重新进行单元测试及重构。
- 修改错误时。修复错误后,同样也是需要重新对接口进行单元测试及重构的。
- 代码审查。代码审查是发现“代码坏味道”非常好的时机,自然也是进行重构的绝佳机会。
3.代码的“坏味道”
如果一段代码是不稳定或有一些潜在问题的,那么代码往往会包含一些明显的痕迹,就好像食物要腐坏之前,经常会发出一些异味一样,这些痕迹就是代码“坏味道”。以下就是常见的代码“坏味道”。
- DuplicatedCode(重复代码):重复是万恶之源。解决方法是将公共函数进行提取。
- LongMethod(过长函数):过长函数会导致责任不明确、难以切割、难以理解等一系列问题。解决方法是将长函数拆分成若干函数。
- LargeClass(过大的类):会导致职责不明确、难理解。解决方法是拆分成若干类。
- LongParameterList(过长参数列):过长参数列其实是没有真正地遵从面向对象的编码方式,对于程序员来说也是难以理解的。解决方法是将参数封装成结构或类。
- DivergentChange(发散式变化):当对多个需求进行修改时,都会动到这种类。解决方法是对代码进行拆分,将总是一起变化的东西放在一起。
- ShotgunSurgery(霞弹式修改):其实就是在没有封装变化处改动一个需求,然后会涉及多个类被修改。解决方法是将各个修改点集中起来,抽象成一个新类。
- FeatureEnvy(依恋情结):一个类对其他类存在过多的依赖,比如某个类使用了大量其他类的成员,这就是FeatureEnvy。解决方法是将该类并到所依赖的类里面。
- DataClumps(数据泥团):数据泥团是常一起出现的大堆数据。如果数据是有意义的,解决方法是就将结构数据转变为对象。
- PrimitiveObsession(基本类型偏执):热衷于使用int、long、String等基本类型。其解决方法是将其修改成使用类来替代。
- SwitchStatements ( switch惊悚现身):当出现 switch语句判断的条件太多时,则要考虑少用switch语句,采用多态来代替。
- ParallelInheritanceHierarchies(平行继承体系):过多平行的类,使用类继承并联起来。解决方法是将其中一个类去掉继承关系。
- LazyClass(冗赘类):针对这些冗赘类,其解决方法是把这些不再重要的类里面的逻辑合并到相关类,并删除旧的类。
- SpeculativeGenerality(夸夸其谈未来性):对于这些没有用处的类,直接删除即可。
- TemporaryField (令人迷惑的暂时字段):对于这些字段,解决方法是将这些临时变量集中到一个新类中去管理。
- MessageChains(过度耦合的消息链):使用真正需要的函数和对象,而不要依赖于消息链。
- MiddleMan(中间人):存在这种过度代理的问题,其解决方法是用继承替代委托。
- InappropriateIntimacy(狎昵关系):两个类彼此使用对方的private值域。解决方法是划清界限拆散,或合并,或改成单项联系。
- AlternativeClasseswithDifferentInterfaces(异曲同工的类):这些类往往是相似的类,却有不同的接口。解决方法是对这些类进行重命名、移动函数或抽象子类重复作用的类,从而合并成一个类。
- IncompleteLibraryClass(不完美的库类):解决方法是包一层函数或包成新的类。
- DataClass(纯稚的数据类):这些类很简单,往往仅有公共成员变量或简单的操作函数。解决方法是将相关操作封装进去,减少public成员变量。
- RefusedBequest(拒绝遗赠):这些类的表现是父类里面方法很多,但子类只用到有限几个。解决方法是使用代理来替代继承关系。
- Comments(过多的注释):注释多了,就说明代码不清楚了。解决方法是写注释前先重构,去掉多余的注释,“好代码会说话”。
4.减少测试的依赖
首先,我们必须承认,对象间的依赖无可避免。对象与对象之间通过协作来完成功能,任意一个对象都有可能用到另外对象的属性、方法等成员。但同时也认识到,代码中的对象过度复杂的依赖关系往往是不提倡的,因为对象之间的关联性越大,意味着代码改动一处,影响的范围就会越大,而这完全不利于系统的测试、重构和后期维护。所以在现代软件开发和测试过程中应该尽量降低代码之间的依赖。
相比于传统JavaEE的开发模式,DI(依赖注人)使代码更少地依赖容器,并削减了计算机程序的耦合问题。通过简单的new操作,构成程序员应用的 POJO对象即可在JUnit或TestNG下进行测试。即使没有Spring或其他loC容器,也可以使用mock来模拟对象进行独立测试。清晰的分层和组件化的代码将会促进单元测试的简化。例如,当运行单元测试的时候,程序员可以通过stub或mock来对DAO或资源库接口进行替代,从而实现对服务层对象的测试,这个过程中程序员无须访问持久层数据。这样就能减少对基础设施的依赖。
在测试过程中,真实对象具有不可确定的行为,有可能产生不可预测的效果(如股票行情、天气预报),同时,真实对象存在以下问题。
- 真实对象很难被创建。
- 真实对象的某些行为很难被触发。
- 真实对象实际上还不存在(和其他开发小组或和新的硬件打交道)等。
正是由于上面真实对象在测试的过程中存在的问题,在测试中广泛地采用mock测试来代替。
在单元测试上下文中,一个mock对象是指这样的一个对象——它能够用一些“虚构的占位符”功能来“模拟”实现一些对象接口。在测试过程中,这些虚构的占位符对象可用简单方式来模仿对于一个组件期望的行为和结果,从而让程序员专注于组件本身的彻底测试,而不用担心其他依赖性问题。
mock对象经常被用于单元测试。用mock对象来进行测试,就是在测试过程中,对于某些不容易构造(如HttpServletRequest必须在Servlet容器中才能构造出来)或不容易获取的比较复杂的对象(如JDBC中的ResultSet对象),用一个虚拟的对象( mock对象)来创建以便测试的测试方法。
mock最大的功能是把单元测试的耦合分解开,如果编写的代码对另一个类或接口有依赖,它能够模拟这些依赖,并验证所调用的依赖行为。
mock对象测试的关键步骤如下。
- 使用一个接口来描述这个对象。
- 在产品代码中实现这个接口。
- 在测试代码中实现这个接口。
- 在被测试代码中只是通过接口来引用对象,所以它不知道这个引用的对象是真实对象,还是mock对象。
目前,在Java阵营中主要的mock测试工具有Mockito、JMock、EasyMock 等。
5.mock与stub的区别
mock和 stub都是为了替换外部依赖对象,mock不是stub,两者有以下区别。
- 前者称为mockist TDD,而后者一般称为classic TDD。
- 前者是基于行为的验证(Behavior Verification ),后者是基于状态的验证(State Verification )。
- 前者使用的是模拟的对象,而后者使用的是真实的对象。
现在通过一个例子来看看mock与 stub之间的区别。假如程序员要给发送mail的行为做一个测试,就可以像下面这样写一个简单的stub。
//待测试的接口 public interface Mailservice(){ public void send(Message msg); } /lstub测试类 public class MailServiceStub implements MailService i private List<Message>messages = new ArrayList<Message>(); public void send (Message msg){ messages.add (msg); } public int numberSent( { return messages.size(); } } }
也可以像下面这样在stub 上使用状态验证的测试方法。
public class orserStateTester{ Order order = new Order(TALISKER, 51); MailServiceStub mailer = new MailserviceStub(); order.setMailer(mailer); order.fill (warehouse); //通过发送的消息数来验证 assertEquals(1 , mailer.numberSent();} }
当然这是一个非常简单的测试,只会发送一条message。在这里程序员还没有测试它是否会发送给正确的人员或内容是否正确。
如果使用mock,那么这个测试看起来就不太一样了。<br />
lass OrderInteractionTester. .. public void testorderSendsMail工fUnFilled() { Order order =new Order (TALISKER ,51); Mock warehouse = mock(Warehouse.class); Mock mailer = mock(MailService.class); order.setMailer((Mailservice)mailer.proxy()); order.expects(once()).method ("hasInventory").withAnyArgument() .will(returnvalue(false)); order.fill((Warehouse) warehouse.proxy() }
在这两个例子中,使用了stub和mock来代替真实的MailService对象。所不同的是,stub使用的是状态确认的方法,而mock使用的是行为确认的方法。
想要在stub中使用状态确认,需要在stub中增加额外的方法来协助验证。因此stub实现了MailService但是增加了额外的测试方法。
微服务的集成测试
集成测试也称组装测试或联合测试,可以说是单元测试的逻辑扩展。它最简单的形式是把两个已经测试过的单元组合成一个组件,测试它们之间的接口。从使用的基本技术上来讲,集成测试与单元测试在很多方面都很相似。程序员可以使用相同的测试运行器和构建系统的支持。集成测试和单元测试一个比较大的区别在于,集成测试使用了相对较少的mock。
例如,在涉及数据访问层的测试时,单元测试会简单地模拟从后端数据库返回的数据。而集成测试时,测试过程中则会采用一个真实的数据库。数据库是一个需要测试资源类型及能暴露问题的极好的例子。
在微服务架构的集成测试中,程序员更加关注的是服务测试。
1.服务接口
在微服务的架构中,服务接口大多以RESTfulAPI的形式加以暴露。REST是面向资源的,使用HTTP协议来完成相关通信,其主要的数据交换格式为JSON,当然也可以是XML、HTML、二进制文件等多媒体类型。资源的操作包括获取、创建、修改和删除资源,它们都可以用HTTP协议的GET、POST、PUT和DELETE方法来映射相关的操作。
在进行服务测试时,如果只想对单个服务功能进行测试,那么为了对其他相关的服务进行隔离,则需要给所有的外部服务合作者进行打桩。每一个下游合作者都需要一个打桩服务,然后在进行服务测试的时候启动它们,并确保它们是正常运行的。程序员还需要对被测试服务进行配置,保证能够在测试过程中连接到这些打桩服务。同时,为了模仿真实的服务,程序员还需要配置打桩服务,为被测试服务的请求发回响应。
下面是一个采用Spring 框架实现的关于“用户车辆信息”测试接口的例子。
import org.junit.*; import org.junit.runner.*; import org.springframework.beans.factory.annotation.*; import org.springframework.boot.test.autoconfigure.web.servlet.*; import org.springframework.boot.test.mock.mockito.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; import static org.springframework.test.web.servlet.request.MockMvc RequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvc ResultMatchers.*; @RunWith(SpringRunner.class) @WebMvcTest(UserVehicleController.class) public class MyControllerTests{ @Autowired private MockMvc mvc; @MockBean private UserVehicleService userVehicleService; @Test public void testExample( throws Exception { given(this.userVehicleService.getVehicleDetails("sboot")) .willReturn(new VehicleDetails("BMW","X7")); this.mvc.perform(get("/sboot/vehicle").accept(MediaType.TEXT_ PLAIN)) .andExpect(status().isok()).andExpect(content(). string("BMW x7")); ) } }
在该测试中,程序员用mock模拟了/sboot/vehicle接口的数据VehicleDetails("BMW","X7"),并通过MockMvc来进行测试结果的判断。
2.客户端
有非常多的客户端可以用于测试RESTful服务。可以直接通过浏览器来进行测试,如在本书前面介绍过的RESTClient、Postman等。很多应用框架本身提供了用于测试RESTful API的类库,如Java平台的像Spring的RestTemplate 和像Jersey的Client API等,.NET平台的RestSharp ( http:1restsharp.org)等。也有一些独立安装的REST测试软件,如SoapUI ( ttps:/www.soapui.org ),当然最简洁的方式莫过于使用cURL在命令行中进行测试。
下面是一个测试Elasticsearch是否启动成功的例子,可以在终端直接使用cURL来执行以下操作。
scurl 'http://localhost:9200/?pretty'
cURL提供了一种将请求提交到Elasticsearch的便捷方式,然后可以在终端看到与下面类似的响应。
"cluster name": "elasticsearch", "cluster uuid" :"uqcQAMTtTIO6CanROYgveQ", "version":{ "number": "5.5.0", "build_hash":"260387d" , "build_date":"2017-06-30T23:16:05.735Z"", "build_snapshot" :false, "lucene version":"6.6.O" }, "tagline":"You Know, for Search" }
微服务的系统测试
引入微服务架构之后,随着微服务数量的增多,测试用例也随之增多,测试工作也越来越依赖于测试的自动化。Maven或Gradle等构建工具,都会将测试纳入其生命周期内,所以,只要写好相关的单元测试用例,单元测试及集成测试就能在构建过程中自动执行,构建完成之后,也可以马上看到测试报告。
在系统测试阶段,除了自动化测试外,手工测试仍然是无法避免的。Docker等容器为自动化提供了基础设施,也为手工测试带来了新的变革。
在基于容器的持续部署流程中,软件会经历最终被打包成容器镜像,从而可以部署到任意环境而无须担心工作变量不一致所带来的问题。进入部署阶段意味着集成测试及单元测试都已经通过了。
但这显然并不是测试的全部,很多测试必须要在上线部署后才能进行,如一些非功能性的需求。
同时,用户对于需求的期望是否与最初的设计相符,这个也必须要等到产品上线后才能验证。所以,上线后的测试工作仍然是非常重要的。
1.冒烟测试
所谓冒烟测试,是指对一个新编译的软件版本在需要进行正式测试前,为了确认软件基本功能是否正常而进行的测试。软件经过冒烟测试之后,才会进行后续的正式测试工作。冒烟测试的执行者往往是版本编译人员。
由于冒烟测试耗时短,并且能够验证软件大部分主要的功能,因此在进行CI/CD每日构建过程中,都会执行冒烟测试。
⒉蓝绿部署
蓝绿部署通过部署新旧两套版本来降低发布新版本的风险。其原理是,当部署新版本后(绿部署),老版本(蓝部署)仍然需要保持在生产环境中可用一段时间。如果新版本上线,测试没有问题后,那么所有的生产负荷就会从旧版本切换到新版本中。
以下是一个蓝绿部署的例子。其中,vl代表的是服务的旧版本(蓝色),v2代表的是新版本(绿色),如图4-2所示。
这里面有以下几个注意事项。
- 蓝绿两个部署环境是一致的,并且两者应该是完全隔离的(可以是不同的主机或不同的容器)。
- 蓝绿环境两者之间有一个类似于切换器的装置用于流量的切换,如可以是负载均衡器、反向代理或路由器。
- 新版本(绿部署)测试失败后,可以马上回溯到旧版本。
- 蓝绿部署经常与冒烟测试结合使用。
- 实施蓝绿部署,整个过程是自动化处理的,用户并不会感觉到任何宕机或服务重启。
3.A/B测试
A/B测试是一种新兴的软件测试方法。A/B测试本质上是将软件分成A、B两个不同的版本来进行分离实验。AB测试的目的在于通过科学的实验设计、采样样本、流量分割与小流量测试等方式来获得具有代表性的实验结论,并确保该结论在推广到全部流量之前是可信赖的。例如,在经过一段时间的测试后,实验结论显示,B版本的用户认可度较高,于是,线上系统就可以更新到B版本上来。
4.金丝雀发布
金丝雀发布是增量发布的一种类型,它的执行方式是在原有软件生产版本可用的情况下,同时部署一个新的版本。这样,部分生产流量就会引流到新部署的版本,从而来验证系统是否按照预期的内容执行。这些预期的内容可以是功能性的需求,也可以是非功能性的需求。例如,程序员可以验证新部署的服务的请求响应时间是否在1秒以内。