Spring Boot 应用的测试

Spring Boot 应用的测试

《Spring Boot 实战开发》(陈光剑)
—— 基于 Gradle + Kotlin的企业级应用开发最佳实践

本书写到这里,Spring Boot 2.0.0.RC1版本已经于2018.1.31 发布。这是本书最后一章,本章介绍 Spring Boot 应用的测试(质量保障)相关的内容。我们在项目开发中使用分层架构,在测试中也进行分层测试。
1.1 准备工作
本节先来创建一个基于Spring MVC、 Spring Data JPA的 Spring Boot, 完成Dao 层、 Service 层、Controller 层代码的编写,为后面的测试代码的编写做准备。
使用http://start.spring.io/ 创建项目、导入此 Gradle 项目到 IDEA 中。配置 Kotlin Compiler 版本与Target JVM 版本。最后等待项目构建完毕。我们将得到一个初始Spring Boot 工程。详细的代码参考本章给出的示例工程源码。
下面我们来详细讲解怎样针对 Spring Boot 项目进行分层测试。
1.2 分层测试
我们在开发阶段过程中,单元测试通常是必要的。Spring Boot 提供的spring-boot-test 模块基于 spring-test 模块和junit 框架,封装集成了功能强大的结果匹配校验器assertj 、hamcrest Matcher、 Web 请求 Mock 对象、 httpclient、JsonPath (测试 JSON 数据)、mockito、selenium等。
测试代码通常放在 src/test 目录下,包目录规范是跟 src/main 目录保持一致。测试代码目录结构设计如下

图15-1 测试代码目录结构
测试代码的分层逻辑与项目源代码中的 dao层、service 层、controller 层各自对应。
下面我们来开发具体的测试类。
1.2.1 Dao 层测试
在包com.easy.springboot.demo_testing_and_deploy.dao下面添加UserDaoTest.kt测试类,代码如下

@RunWith(SpringRunner::class)
@SpringBootTest
class UserDaoTest {
    @Autowired lateinit var userDao: UserDao
    @Test
    fun testFindAll() {
        Assert.assertTrue(userDao.findAll().size == 2)
    }
}

其中,需要测试类上需要添加@RunWith(SpringRunner.class) 和 @SpringBootTest 注解。这里的 @RunWith这里就不多做解释了,在 JUnit中这个是最常用的注解。
@SpringBootTest这个注解是SpringBoot项目测试的核心注解,标识该测试类以SpringBoot方式运行,该注解的定义如下:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(SpringBootTestContextBootstrapper.class)
public @interface SpringBootTest{}

在上面的 @SpringBootTest 注解源码中最重要的是 @BootstrapWith,该注解配置了测试类的启动核心类SpringBootTestContextBootstrapper。
在UserDaoTest测试类中可以直接使用@Autowired来装配UserDao这个 Bean。而且,@SpringBootTest 注解会自动帮我们完成启动一个 Spring 容器 ApplicationContext,然后连接数据库,执行一套完整的业务逻辑。
1.2.2 Service 层测试
Service 层的代码测试类跟 Dao 层类似,例如UserServiceTest.kt 测试代码如下

@RunWith(SpringRunner::class)
@SpringBootTest
class UserServiceTest {
    // 直接使用@Autowired注解注入 Service 对象
    @Autowired lateinit var userService: UserService

    @Test
    fun testFindAll() {
        Assert.assertTrue(userService.findAll().size == 2)
    }
}

1.2.3 使用 Mockito 测试 Service 层代码

上面的测试代码是连接真实的数据库来执行真实的 Dao 层数据库查询逻辑。
而在实际开发的场景中,我们有时候需要独立于数据库进行 Service 层逻辑的开发。这个时候就可以直接把数据库Dao层代码Mock 掉。例如在UserService中有一个 getOne()方法,具体的实现代码是

interface UserService {
    ...
    fun getOne(id:Long):User?
}

@Service
class UserServiceImpl : UserService {
    @Autowired lateinit var userDao: UserDao
    ...

    override fun getOne(id: Long): User? {
        return userDao.getOne(id)
    }
}

下面,我们就使用 Mockito 来把 UserDao 层代码 Mock 掉。Mockito 主要用于 service 层的 mock 测试。mock 的对象一般是对 DAO 层的依赖; 另外就是别人的Service实现类。
新建测试类MockUserServiceTest.kt 代码如下:

@RunWith(MockitoJUnitRunner::class)
class MockUserServiceTest {
    @Mock
    lateinit var mockUserDao: UserDao // mock 一个DAO层的接口
    @InjectMocks
    lateinit var userService: UserServiceImpl// Mock一个 Service 的实现类,用 @InjectMocks。注意这里是实现类 UserServiceImpl

    @Before
    fun setUp() {
        // initMocks 必须,否则 @Mock 注解无效
        MockitoAnnotations.initMocks(this)
    }

    @Test
    fun testGetOne() {
        val mockUser = User()
        mockUser.id = 101
        mockUser.username = "mockUser"
        mockUser.password = "123456"

        val roles = mutableSetOf<Role>()
        val r1 = Role()
        r1.role = "ROLE_USER"
        val r2 = Role()
        r1.role = "ROLE_ADMIN"
        roles.add(r1)
        roles.add(r2)
        mockUser.roles = roles
        //模拟 UserDao对象
        `when`(mockUserDao.getOne(1)).thenReturn(mockUser)

        val u = userService.getOne(1)
        println(ObjectMapper().writeValueAsString(u))
        Assert.assertTrue(u?.password == "123456")
    }
}

需要注意的是,该测试的执行 Runner 是 @RunWith(MockitoJUnitRunner::class) 。
 使用 @Mock 注解标记这个对象是被 Mock 的。
 使用 @InjectMocks 注解标注一个实现类UserServiceImpl,Mockito 会自动把 @Spy 或 @Mock标注的 Mock 对象注入到实现类UserServiceImpl的方法执行中,相当于把实现类中的UserDao对象使用mockUserDao对象给“偷梁换柱”了。
运行上面的测试类,可以发现测试成功

图15-2 MockUserServiceTest测试成功
在测试代码的打印日志中,输出的 getOne(1)方法的返回对象是我们 Mock 的对象mockUser :

{"id":101,"gmtCreate":"2018-02-09 01:48:33","gmtModify":"2018-02-09 01:48:33","username":"mockUser","password":"123456","roles":[{"id":-1,"gmtCreate":"2018-02-09 01:48:33","gmtModify":"2018-02-09 01:48:33","role":"ROLE_ADMIN"},{"id":-1,"gmtCreate":"2018-02-09 01:48:33","gmtModify":"2018-02-09 01:48:33","role":"ROLE_USER"}]}

提示:更多关于 Mockito 的使用请参考官网文档:http://site.mockito.org/

1.2.4 Controller 层测试
通过上面的实例,我们已经了解了在实际项目开发测试中对dao层代码和service层代码的测试,还学习了 Mockito 技术的相关内容。spring-boot-starter-test中提供了对项目测试功能的强大支持,更难得的是其中增加了对Controller层测试的支持。
下面我们来测试接口 http://127.0.0.1:8012/user/1 。该接口的输出的JSON数据如下

{
  "id": 1,
  "gmtCreate": "2018-02-08 12:58:14",
  "gmtModify": "2018-02-08 12:58:14",
  "username": "user",
  "password": "user",
  "roles": [
    {
      "id": 1,
      "gmtCreate": "2018-02-08 12:58:14",
      "gmtModify": "2018-02-08 12:58:14",
      "role": "ROLE_USER"
    }
  ]
}

UserControllerTest测试代码如下

@RunWith(SpringJUnit4ClassRunner::class)
@SpringBootTest
class UserControllerTest {

    @Autowired
    lateinit var context: WebApplicationContext
    lateinit var mvc: MockMvc
    @Before
    fun setUp() {
        mvc = MockMvcBuilders.webAppContextSetup(context).build()
    }

    @Test
    fun testFetchUser1() {
        mvc.perform(MockMvcRequestBuilders.get("/user/1")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .accept(MediaType.APPLICATION_JSON))
                .andExpect(MockMvcResultMatchers.status().isOk)
                .andDo(MockMvcResultHandlers.print())
                .andExpect(MockMvcResultMatchers.content().string(Matchers.containsString("""
                    "username":"user"
                """.trimIndent())))
                .andDo {
                    println("it.request.method=${it.request.method}")
                    println("it.response.contentAsString=${it.response.contentAsString}")
                }
                .andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.equalTo(1)))
                .andExpect(MockMvcResultMatchers.jsonPath("$.roles[0].role", Matchers.equalTo("ROLE_USER")))

    }
}

其中, MockMvc是一个被final修饰的类型,该类无法被继承使用。这个类在包org.springframework.test.web.servlet下面,是Spring提供的模拟SpringMVC请求的实例类,该类由MockMvcBuilders通过WebApplicationContext实例进行创建。MockMvcBuilder接口签名如下

package org.springframework.test.web.servlet;
public interface MockMvcBuilder {
  MockMvc build();
}

上面的代码简单说明如下表15-1。
表15-1
方法名
功能说明
Perform()
方法其实只是为了构建一个请求,并且返回ResultActions实例,使用该实例可以获取到请求的返回内容。

MockMvcRequestBuilders
支持构建多种请求方法对象,如:Post、Get、Put、Delete等常用的请求方式,其中的参数"/user/1"则是我们需要请求的本项目的相对路径,/ 则是项目请求的根路径。另外,还可以调用param() 方法用于在发送请求时携带参数。

andExpect()
是ResultActions中成员,入参是ResultMatcher类型:
ResultActions andExpect(ResultMatcher matcher)
在发送请求后对响应结果进行匹配校验时调用。其中MockMvcResultMatchers 抽象类是一个静态工厂,用于生产ResultMatcher对象。MockMvcResultMatchers中提供了丰富的匹配器。

1.2.5 JSON接口测试
使用 JsonPath 我们可以像 JavaScript 语法一样方便地进行 JSON 数据返回的访问操作。例如下面的这两行代码
.andExpect(MockMvcResultMatchers.jsonPath("$.id", Matchers.equalTo(1)))
.andExpect(MockMvcResultMatchers.jsonPath("$.roles[0].role", Matchers.equalTo("ROLE_USER")))
这里的Matchers类是org.hamcrest包下面的类。org.hamcrest.Matchers 类中提供了丰富的断言方法,这些方法的具体使用可以阅读Matchers 类的源码深入了解。
其中,"$.id" 和 "$.roles[0].role" 就是 JsonPath的表达式语法。
提示:更多关于 JsonPath 的内容可以参考: https://github.com/json-path/JsonPath

运行上面的测试代码,测试成功:

图15-3 UserControllerTest测试成功
使用命令 $ gradle test 可以一次性全部执行 src/test 目录下面的测试类。在 IDEA 中可以直接邮寄 src/test 目录,选择 Run > All Tests执行所有测试类,如下图所示

图15-4 选择 Run > All Tests执行 所有测试类
另外,Gradle Test 生成的测试报告在 build/reports/tests/test/index.html 中,如下图

图15-5 Gradle Test 生成的测试报告在 build/reports/tests/test/index.html 中
测试报告的部分内容截图如下

图15-6 测试报告Summary

图15-7 UserControllerTest测试报告

图15-8 MockUserServiceTest测试报告

1.3 本章小结

本章介绍了Spring Boot项目如何测试。Spring Boot 应用对Web层测试提供强大的支持:采用MockMvc方式测试Web请求,根据传递的不用参数以及请求返回对象反馈信息进行验证测试。另外,针对 JSON 数据接口,使用 JsonPath 可以方便地进行 JSON 数据结果的校验。
提示:本章项目工程源代码:
https://github.com/KotlinSpringBoot/demo_testing_and_deploy

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343

推荐阅读更多精彩内容