微服务测试
本章主要介绍Spring Boot单元测试、Mockito/PowerMockito测试框架、H2内存型数据库、REST API测试以及性能测试等内容。
1.Spring Boot单元测试
1.1 关于测试
软件测试的目的是保证程序员编写的程序达到预期的结果,保证发布的产品是产品经理(产品设计人员)的真实意愿表现。这些都需要软件测试来监督实现,避免将有缺陷的软件发布到生产环境。
软件测试的种类很多,粗略地可划分为单元测试、集成测试、端到端测试。从其他的角度来说,又有回归测试、自动化测试、性能测试等。当我们的项目进行服务化改造之后,尤其是进行了微服务设计之后,测试工作会变得更加困难。很多项目都是以独立服务的形式发布的,这些服务的发布如何保证已经进行充分测试?测试的入口应该在哪里?是直接进行集成测试,还是做端到端的用户体验测试?好像都不太合适。按照分层测试的思想,于是就有了服务测试的话题。微服务的测试理论和其他的测试应该是大体类似的,其中比较特殊的是,如何提供方便快捷的服务测试入口。
目前常见的微服务设计都采用分布式服务框架,这些框架从通信协议上可分为两种:
基于公共标准的HTTP协议的
第一种以HTTP协议的微服务接口比如使用Spring Boot开发的服务,这样的服务测试工具有很多,比如Postman、Swagger是常用的工具。如果想为测试人员做点事情的话,可以根据服务注册中心做一个所有服务的列表。基于私有的RPC调用协议
第二种是以私有协议暴露的服务测试,相对比较麻烦。为了打通服务接口和测试人员之间的屏障,以便让测试人员方便地测试到RPC协议的服务接口,为每个服务接口写一个客户端,将其转换为HTTP协议暴露,这是一种解决办法。但是,这样无形中增加了很多工作量,而且测试服务的质量还依赖于客户端编写的质量,明显是费力不讨好的工作。
那么,如何构建一个项目,它能提供所有服务的客户端,这样新开发一个服务只需要做极少的工作就能生成一个服务的测试客户端,从而快速地将接口提交测试,这是我们下面要讨论的问题。
1.2 微服务测试
微服务设计的项目一般都是基于分布式服务的注册和发现机制的,所有的服务都是在一个注册中心集中存储的,而且一般的分布式服务框架都支持丰富的服务调用方式,如基于Spring XML配置的和Spring注解以及API等调用方法,为编写公共的服务测试工具提供了便利的条件。
其所设计的服务测试工具在整个分布式服务架构中所扮演的角色如图所示。
微服务测试的流程可描述为以下5个步骤:
- 从服务注册中心获取到所有的服务接口,并将这些接口可视化地展示给测试人员,测试人员可以选择需要测试的服务接口,如图所示。
- 将服务的每个接口以一种易读易用的方式暴露给测试人员,比如将接口的请求参数转化为XML或者JSON的形式展示给测试人员,方便他们输入测试用例。
- 将测试人员提交的请求参数转换为请求对象,以便使用统一的API接口,调用到后端服务。
- 发起服务调用,使用API的方式来调用服务是因为我们做的工具是统一的服务调用入口,能够根据请求参数动态地调用不同的服务。
- 将服务的响应参数再次转换为XML或者JSON格式展示给测试人员查看,这时候可以顺便返回调用耗时等附加数据,帮助测试人员判断服务的效率等情况。
微服务测试的宗旨就是尽可能地简化服务测试过程,其中还有一些服务测试基础功能之外的拓展功能:
- 请求参数的自动化生成,例如请求流水号、请求时间、手机号码、身份证号码等,减少测试人员的输入参数时间。
- 后台保存服务测试的请求参数和响应参数,方便回归测试。
- 实现回归测试,在服务代码有变动之后,可根据保存的请求参数进行回归测试,并且可以和之前的响应参数进行对比,以便验证是否影响到当前测试服务接口。
- 服务的并发测试,在提交测试请求的时候可以指定每个服务测试请求的测试次数,这时后台会模拟多线程调用服务,可实现对服务接口的并发测试。
- 多个测试环境自由切换,通过选择不同环境的注册中心,来实现其他环境的测试。
- 服务测试出现异常的时候,将异常堆栈信息直接展示给测试人员,方便排查问题。
- 实现定时回归测试,有时候我们的测试环境也需要保持一定的稳定性,因为经常会有别的系统发起联调测试。定时回归测试,既能及时发现后端系统对服务的影响,又能保证服务持续稳定地对外提供服务。
- 开发公共的mock测试服务,避免后端未开发完成的服务耽误服务的测试。
2.Spring Boot单元测试
项目在投入生产之前,需要进行大量的单元测试,Spring Boot作为分布式微服务架构的脚手架,非常有必要来了解下Spring Boot如何进行单元测试。具体步骤如下:
- 创建一个Spring Boot项目,项目名为spring-boot-test。
- pring-boot-test项目创建完成后,在项目的pom.xml配置文件中,可以看到Spring Boot默认已经为我们添加了spring-boot-starter-test插件,具体代码如下:
<dependency><groupId>org.springframework.boot</groupId><artifactId>springstarter--boot--test</artifactId><scope>test</scope></dependency>
spring-boot-starter-test插件依赖了spring-boot-test、junit、assertj、mockito、hamcrest等测试框架和类库。
- 开发用户接口UserService和实现类UserServiceImpl。UserService接口如下:
// 用户接口publicinterfaceUserService{
AyUserfindUser(String id);}// UserServiceImple实现类@ComponentpublicclassUserServiceImplimplementsUserService{@Overridepublic AyUserfindUser(String id){
AyUser ayUser=newAyUser();
ayUser.setId(1);
ayUser.setName("ay");return ayUser;}}//用户实体publicclassAyUser{private Integer id;private String name;}
- Spring Boot的测试类主要放置在/src/test/java目录下面。项目创建完成后,Spring Boot会自动生成测试类DemoApplicationTests.java。测试类的代码如下:
@RunWith(SpringRunner.class)@SpringBootTestpublicclassDemoApplicationTests{@Resourceprivate UserService userService;@TestpublicvoidcontextLoads(){}@TestpublicvoidtestFindUser(){
AyUser ayUser= userService.findUser("1");
Assert.assertNotNull("user is null", ayUser);}}
- @RunWith(SpringRunner.class):@RunWith(Parameterized.class)参数化运行器,配合@Parameters使用JUnit的参数化功能。查源代码可知,SpringRunner类继承SpringJUnit4ClassRunner类,此处表明使用SpringJUnit4ClassRunner执行器,此执行器集成了Spring的一些功能。如果只是简单地JUnit单元测试,该注解可以去掉。
- @SpringBootTest:此注解能够测试SpringApplication,因为Spring Boot程序的入口是SpringApplication,基本上所有配置都会通过入口类去加载,而该注解可以引用入口类的配置。
- @Test:JUnit单元测试的注解,注解在方法上表示一个测试方法。
当右键执行DemoApplicationTests.java中的contextLoads方法时,可以看到控制台打印的信息和执行入口类中的SpringApplication.run()方法打印的信息是一致的。由此便知,@SpringBootTest是引入了入口类的配置。
在DemoApplicationTests.java类中添加测试用例testFindUser,并在方法上添加@Test注解,运行测试用例,通过使用JUnit框架提供的Assert.assertXXX()断言方法来验证期望值与实际值是否一致。如果不一致,将打印错误信息“user is null”,这就是单元测试的基本做法。
JUnit框架提供的Assert断言一方面需要提供错误信息,另一方面期望值与实际值到底谁在前谁在后,很容易犯错。好在Spring Boot已经考虑到这些因素,它依赖于AssertJ类库,弥补了JUnit框架在断言方面的不足之处。我们可以轻松地将JUnit断言修改为AssertJ断言,具体代码如下:
@TestpublicvoidtestFindUser(){boolean successfalse;int num10;
AyUser ayUser= userService.findUser("1");//JUnit断言
Assert.assertNotNull("user is null", ayUser);//AssertJ断言
Assertions.assertThat(ayUser.isNotNull());//JUnit断言
Assert.assertTrue("result is not true", success);//AssertJ断言
Assertions.assertThat(success).isTrue();//JUnit断言
Assert.assertEquals("num is not equal 10",10, num);//AssertJ断言
Assertions.assertThat(num).isEqualTo(10);}
3.Mockito/PowerMockito测试框架
3.1 Mockito概述
Mockito是用于生成模拟对象或者直接说就是“假对象”的模拟工具。其特点是对于某些不容易构造(如HttpServletRequest)或者不容易获取的复杂对象(如JDBC中的ResultSet对象),可用一个虚拟的对象(Mock对象)来创建以便完成测试。Mockito最大的优点是可帮你把单元测试的耦合分解开,如果你的代码对另一个类或者接口有依赖,它能够帮助你模拟这些依赖,并帮助你验证所调用的依赖的行为。我们先来看一个传统的测试用例调用流程图,如图所示。
当想要测试用户服务类UserService的某些接口时,需要依赖UserDao对象来完成相关测试,而UserDao对象还需要连接数据库。某些情况下我们无法连接数据库,比如无网络的情况下,此时,测试用例就无法正常执行。清楚了传统JUnit测试用例的局限性,我们来看一下Mockito如何规避这些缺点,如图所示。
利用Mockito框架提供的强大模拟对象功能,模拟出UserDao对象,并去掉UserDao与DB连接的关系,可以快速地开发出独立、稳定的测试用例,该测试用例不会因为DB异常而导致运行失败。实际中,JUnit和Mockito两者定位不同,项目中通常的做法是联合JUnit +Mockito来进行测试。
3.2 Mockito简单实例
上一节,简单了解了Mockito的概念和优点,这里列举几个简单实例来体验一下Mockito。
3.2.1 实例一
@TestpublicvoidtestMockito_1(){
List mock=mock(List.class);when(mock.get(0)).thenReturn("ay");when(mock.get(1)).thenReturn("al");//测试通过
Assertions.assertThat(mock.get(0)).isEqualTo("ay");//测试不通过
Assertions.assertThat(mock.get(1)).isEqualTo("xx");}
上面实例中,使用Mockito模拟List的对象,拥有 List的所有方法和属性。when(xxxx).thenReturn(yyyy)指定当执行了这个方法的时候,返回thenReturn的值,相当于是对模拟对象的配置过程,为某些条件给定一个预期的返回值。Mockito通过when(xxx).thenReturn(yyy)这样的语法来定义对象方法和参数(输入),然后在thenReturn中指定结果(输出),此过程称为stub打桩。一旦这个方法被stub了,就会一直返回这个stub的值。
stub打桩时,需要注意以下几点:
- 对于static和final方法,Mockito无法对其when(…).thenReturn(…)操作。
- 当连续两次为同一个方法使用stub的时候,它只会使用最新的一次。
3.2.2 实例二
首先,我们开发AyUser实体、UserDao和UserService接口、UserServiceImpl实现类,具体代码如下:
//用户实体publicclassAyUser{private Integer id;private String name;publicAyUser(Integer id, String name){this.id= id;this.name= name;}publicAyUser(){}}// UserDao@ComponentpublicclassUserDao{public AyUserfindUser(Integer userId){
AyUser user= null;// 查询数据库return user;}publicbooleandeleteUser(Integer userId){//操作数据库returntrue;}}// 用户接口publicinterfaceUserService{//查询用户
AyUserfindUser(Integer id);//删除用户booleandeleteUser(Integer id);}// UserServiceImple实现类@ComponentpublicclassUserServiceImplimplementsUserService{@Resourceprivate UserDao userDao;@Overridepublic AyUserfindUser(Integer id){
AyUser ayUser= userDao.findUser(id);return ayUser;}@OverridepublicbooleandeleteUser(Integer id){boolean isSuccess= userDao.deleteUser(id);return isSuccess;}}
然后,我们开发测试用例,具体代码如下:
@TestpublicvoidtestMockito_2(){
UserService userService=mock(UserServiceImp1.class);when(userService.findUser(1)).thenReturn(newAyUser(1, "ay));//通过mock,查询出模拟用户对象
AyUser ayUser= userService.findUser(1);//删除用户boolean isSuccess userService.deleteUser(ayUser.getId());
Assertions.assertThat(isSuccess).isFalse();}
在testMockito_2测试用例方法中,当mock对象UserServiceImpl查询用户的时候返回mock对象new AyUser(1,“ay”),最后删除用户对象。本节列举的实例非常简单,更多Mockito资料请参考官方文档(https://static.javadoc.io/org.mockito/mockito-core/2.25.0/org/mockito/Mockito.html)。读者可根据官方文档,编写出适合自己业务需求的测试用例,在之后的工作中,可以使用该测试框架模拟依赖,简化单元测试中复杂的依赖关系。
3.3 PowerMock概述
Mockito由于其可以极大地简化单元测试的书写过程而被许多人应用在自己的工作中,但是Mockito工具不可以实现对静态函数、构造函数、私有函数、Final函数以及系统函数的模拟,但是这些方法往往是我们在大型系统中需要的功能。
PowerMock就是在Mockito基础上扩展而来,通过定制类加载器等技术,PowerMock实现了上述所有模拟功能,使其成为分布式微服务架构必备的单元测试工具。
3.4 PowerMockito简单实例
PowerMock有两个重要的注解:
- @RunWith(PowerMockRunner.class)
- @PrepareForTest({ YourClassWithEgStaticMethod.class })
如果测试用例里没有使用注解@PrepareForTest,那么可以不用加注解@RunWith(PowerMockRunner.class),反之亦然。当需要使用PowerMock的强大功能(Mock静态、final、私有方法等)的时候,就需要加注解@PrepareForTest。使用PowerMock之前,需要在项目的pom.xml文件中添加依赖信息,具体代码如下:
<properties><org.powermock.version>1. 7.0</org.powermock.version></properties><dependency><groupId>org.powermock</groupId><artifactId>powermock-api-mockito</artifactId><scope>test</scope><version>${org.powermock.version}</version></dependency><dependency><groupId>org.powermock</groupId><artifactId>powermock-module-junit4</artifactId><scope>test</scope><version>${org.powermock.version}</version></dependency>
接下来,我们来看具体的实例:
publicclassPowerMockioTest{
Logger logger= LoggerFactory.getLogger(PowerMockioTest.class);@TestpublicvoidtestFindUser()throws Exception{//mock对象
UserService userService= PowerMockito.spy(newUserService());//设置MAX_TIME = 100
Whitebox.setInternalState(userService,"MAX_TIME",newAtomicInteger(100));
String name="ay";//模拟调用getUserFromDB方法, 返回new User(1,"ay")对象
PowerMockito.when(userService.getUserFromDB()).thenReturn(newUser(1,"ay"));
Assert.assertEquals(userService.findUser("ay").getName(),"ay");
Whitebox.setInternalState(userService,"MAX_TIME",newAtomicInteger(130));try{//调用findUser方法
PoerMockito.when(userService,"findUser", name);}catch(Exception e){
logger.error(e.getMessage());}}}// 用户服务classUserService{
Logger logger= LoggerFactory.getLogger(UserService.class);// 当前调用次数public AtomicInteger MAX_TIME;public UserfindUser(String name)throws Exception{//findUser方法一天只能调用120次if(MAX_TIME.get()>120){thrownewException("系统繁忙");}//模拟从数据库中查询到的数据
User user=getUserFromDB();
Integer maxTime= MAX_TIME.getAndIncrement();//记录日志
logger.info("the current time is :"+ maxTime);return user;}public AtomicIntegergetMAX_TIME(){return MAX_TIME;}publicvoidsetMAX_TIME(AtomicInteger MAX_TIME){this.MAX_TIME= MAX_TIME;}public UsergetUserFromDB(){returnnewUser(1,"al");}}classUser{private Integer id;private String name;//省略get和set}
上述实例中,PowerMockito.spy用来模拟对象,Whitebox.setInternalState用来模拟给对象设置值,PowerMockito.when用来模拟方法内部的逻辑。
4.H2内存型数据库
4.1 H2概述
H2是一个开源的、内存型嵌入式(非嵌入式设备)数据库引擎,它是一个用Java开发的类库,可直接嵌入到应用程序中,与应用程序一起打包发布,不受平台限制。更多H2的资料请参考官方文档(http://www.h2database.com/html/tutorial.html)。
4.2 Spring Boot集成H2
- 创建一个Spring Boot项目,项目名为spring-boot-h2
- 在spring-boot-h2项目的pom.xml文件中添加H2的依赖,具体代码如下:
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactid>spring-starter-boot--data-jpa</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></dependency>
- spring-boot-starter-data-jpa依赖:Spring Data JPA是SpringData的一个子项目,它通过提供基于JPA的Respository,极大地减少了JPA作为数据访问方案的代码量。通过Spring Data JPA框架,开发者可以省略实现持久层业务逻辑的工作,唯一要做的就只是声明持久层的接口,其他都交给Spring Data JPA来帮你完成。
- lombok依赖:Lombok能以简单的注解形式来简化Java代码,提高开发人员的开发效率。例如开发中经常需要写JavaBean,都需要花时间去添加相应的getter/setter方法,也许还要去写构造器、equals等方法。这些显得很冗长也没有太多技术含量,一旦修改属性,就容易出现忘记修改对应方法的失误。
Lombok能通过注解的方式,在编译时自动为属性生成构造器、getter/setter、equals、hashcode、toString等方法。在源代码中没有getter和setter方法,但是在编译生成的字节码文件中有getter和setter方法。这样就省去了手动重建这些代码的麻烦,使代码看起来更简洁。
- 在/resources目录下创建配置文件application-test.properties,并添加如下配置:
### 是否生成ddl语句
spring.jpa.generate-ddl=false### 是否打印sq1语句
spring.jpa.show-sql=true### 自动生成ddl,由于指定了具体的ddl,此处设置为none
spring.jpa.hibernate.ddl-auto=none### 使用H2数据库
spring.datasource.platform=h2## H2驱动
spring.datasource.driverclassName=org.h2.Driver### 指定生成数据库的schema文件位置
spring.datasource.schema=classpath:/db/schema.sql### 指定插入数据库语句的脚本位置
spring.datasource.data=classpath:/db/data.sql
- 在resources/db目录下创建data.sql文件和schema.sql文件,schema.sql用于定义数据库表的结构,data.sql为数据库表的初始化数据。
schema.sql文件内容如下:
CREATETABLE`ay_user`(`id`bigint(11)unsignedNOTNULL AUTO _INCREMENT,`name`varchar(11)DEFAULTNULL,`url`varchar(200)DEFAULTNULL,PRIMARYKEY(`id`))ENGINE=InnoDBDEFAULTCHARSET=utf8;
data.sql文件内容如下:
INSERTINTO ay_user(id, name, url)VALUES(1,'ay','https://huangwenyi. com');INSERTINTO ay_user(id, name, url)values(2,'al','https://al.com');
上述代码中,我们创建了用户表ay_user,同时往表里插入2条数据。随着项目启动,数据初始化到内存中,停止项目,数据消失。
- 开发UserRepository和User类,具体代码如下:
@RepositorypublicinterfaceUserepositoryextendsJpaRepository<User, Long>{
UserfindByName(String name);}@Entiry@Table(name="ay_user")@DatapublicclassUser{@Id@GeneratedValue(Strategy= GenerationType.IDENTITY)private Long id;private String name;private String url;}
上述代码中,我们创建了ay_user表对应的实体类User,同时开发了UserRepository类,用来与H2数据库交互,查询数据。类中定义了findByName方法,作用是通过用户名查询用户。
在测试类中开发测试用例,具体代码如下:
@RunWith(SpringRunner.class)@SpringBootTest@TestPropertySource("classpath:application-test.properties")publicclassDemoApplicationTests{@TestpublicvoidcontextLoads(){}@Resourceprivate UserRepository userRepository;@TestpublicvoidtestSave()