Kotlin + SpringBootTest + Junit 5 + AutoConfigureMockMvc:测试通过时应该失败(似乎@BeforeEach没有生效)

Posted

技术标签:

【中文标题】Kotlin + SpringBootTest + Junit 5 + AutoConfigureMockMvc:测试通过时应该失败(似乎@BeforeEach没有生效)【英文标题】:Kotlin + SpringBootTest + Junit 5 + AutoConfigureMockMvc: test passing when it was supposed to fail (seems @BeforeEach not taking effect) 【发布时间】:2020-08-08 12:20:51 【问题描述】:

我在 Kotlin 中编写了一个非常简单且常见的 CRUD。我想做一些基本的测试,比如测试 post、delete、get 和 put。

可能我理解错了:我使用 Beforeeach 旨在插入一个寄存器,以便在 get 测试期间进行检查。我没有得到异常,但似乎在 get 测试期间它总是返回 ok,而在下面的测试中,对于任何其他 id 不同于 1 的 ID,它应该是 NOT_FOUND。

任何正确方向的线索或指导都会受到欢迎,即使根据我的目的看到下面的其他不良做法(简单的 CRUD 测试)。

测试

package com.mycomp.jokenpo

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.mycomp.jokenpo.controller.UserController
import com.mycomp.jokenpo.model.User
import com.mycomp.jokenpo.respository.UserRepository
import com.mycomp.jokenpo.service.UserService
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultHandlers
import org.springframework.test.web.servlet.result.MockMvcResultMatchers
import org.springframework.test.web.servlet.setup.MockMvcBuilders


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(SpringExtension::class)
@AutoConfigureMockMvc
class JokenpoApplicationTests 

    @Autowired
    lateinit var testRestTemplate: TestRestTemplate

    @Autowired
    private lateinit var mvc: MockMvc

    @InjectMocks
    lateinit var controller: UserController

    @Mock
    lateinit var respository: UserRepository

    @Mock
    lateinit var service: UserService

    //private fun <T> any(type: Class<T>): T = Mockito.any<T>(type)

    @BeforeEach
    fun setup() 
        MockitoAnnotations.initMocks(this)
        mvc = MockMvcBuilders.standaloneSetup(controller).setMessageConverters(MappingJackson2HttpMessageConverter()).build()
        `when`(respository.save(User(1, "Test")))
                .thenReturn(User(1, "Test"))

    

    @Test
    fun createUser() 
        //val created = MockMvcResultMatchers.status().isCreated

        var user = User(2, "Test")
        var jsonData = jacksonObjectMapper().writeValueAsString(user)
        mvc.perform(MockMvcRequestBuilders.post("/users/")
                .contentType(MediaType.APPLICATION_JSON)
                .content(jsonData))
                .andExpect(MockMvcResultMatchers.status().isOk)
                //.andExpect(created)
                .andDo(MockMvcResultHandlers.print())
                .andReturn()
    

    @Test
    fun findUser() 

        val ok = MockMvcResultMatchers.status().isOk

        val builder = MockMvcRequestBuilders.get("/users?id=99") //no matther which id I type here it returns ok. I would expect only return for 1 based on my @BeforeEach
        this.mvc.perform(builder)
                .andExpect(ok)

    

控制器

package com.mycomp.jokenpo.controller

import com.mycomp.jokenpo.model.User
import com.mycomp.jokenpo.respository.UserRepository
import com.mycomp.jokenpo.service.UserService
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import java.util.concurrent.atomic.AtomicLong
import javax.validation.Valid

@RestController
@RequestMapping("users")
class UserController (private val userService: UserService, private val userRepository: UserRepository)

    val counter = AtomicLong()

//    @GetMapping("/user")
//    fun getUser(@RequestParam(value = "name", defaultValue = "World") name: String) =
//            User(counter.incrementAndGet(), "Hello, $name")

    @GetMapping()
    fun getAllUsers(): List<User> =
            userService.all()

    @PostMapping
    fun add(@Valid @RequestBody user: User): ResponseEntity<User> 
        //user.id?.let  userService.save(it) 
        val savedUser = userService.save(user)
        return ResponseEntity.ok(savedUser)
    

    @GetMapping("/id")
    fun getUserById(@PathVariable(value = "id") userId: Long): ResponseEntity<User> 
        return userRepository.findById(userId).map  user ->
            ResponseEntity.ok(user)
        .orElse(ResponseEntity.notFound().build())
    

    @DeleteMapping("/id")
    fun deleteUserById(@PathVariable(value = "id") userId: Long): ResponseEntity<Void> 

        return userRepository.findById(userId).map  user  ->
            userRepository.deleteById(user.id)
            ResponseEntity<Void>(HttpStatus.OK)
        .orElse(ResponseEntity.notFound().build())

    

//    @DeleteMapping("id")
//    fun deleteUserById(@PathVariable id: Long): ResponseEntity<Unit> 
//        if (noteService.existsById(id)) 
//            noteService.deleteById(id)
//            return ResponseEntity.ok().build()
//        
//        return ResponseEntity.notFound().build()
//    

    /////

//    @PutMapping("id")
//    fun alter(@PathVariable id: Long, @RequestBody user: User): ResponseEntity<User> 
//        return userRepository.findById(userId).map  user  ->
//            userRepository. deleteById(user.id)
//            ResponseEntity<Void>(HttpStatus.OK)
//        .orElse(ResponseEntity.notFound().build())
//    


存储库

package com.mycomp.jokenpo.respository

import com.mycomp.jokenpo.model.User
import org.springframework.data.repository.CrudRepository

interface UserRepository : CrudRepository<User, Long>

型号

package com.mycomp.jokenpo.model

import javax.persistence.*


@Entity
data class User(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long,

        @Column(nullable = false)
        val name: String
)

gradle 依赖

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins 
    id("org.springframework.boot") version "2.2.6.RELEASE"
    id("io.spring.dependency-management") version "1.0.9.RELEASE"
    kotlin("jvm") version "1.3.71"
    kotlin("plugin.spring") version "1.3.71"
    kotlin("plugin.jpa") version "1.3.71"


group = "com.mycomp"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

val developmentOnly by configurations.creating
configurations 
    runtimeClasspath 
        extendsFrom(developmentOnly)
    


repositories 
    mavenCentral()


dependencies 
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    runtimeOnly("com.h2database:h2")
    //runtimeOnly("org.hsqldb:hsqldb")
    testImplementation("org.springframework.boot:spring-boot-starter-test") 
        exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
    
    testImplementation ("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")


tasks.withType<Test> 
    useJUnitPlatform()


tasks.withType<KotlinCompile> 
    kotlinOptions 
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
    driver-class-name: org.h2.Driver
    platform: h2
  h2:
    console:
      enabled: true
      path: /h2-console #jdbc:h2:mem:testdb

如果有用的话,可以从https://github.com/jimisdrpc/games 下载整个项目,但我相信以上所有文件都足以说明我的问题。

【问题讨论】:

一个想法:get 映射使用repository.findByUserId() 但你模拟repository.save()。这不适合。 @johanneslink,对不起,我没有明白你的意思。简而言之,我在每次测试之前尝试使用上述测试保存用户 1, Test 并在 findUser 测试期间使用“... MockMvcRequestBuilders.get("/users?id=99")...”我希望测试失败,如果我选择“... MockMvcRequestBuilders.get("/users?id=1") 测试通过。请问您要指出什么问题? 【参考方案1】:

为了解决您的问题,我建议使用 @MockBean,这是一个可用于向 Spring ApplicationContext 添加模拟的注解。

我将重写您的测试如下(请注意,我正在利用 mockito-kotlin 已经成为您项目的测试依赖项):

package com.mycomp.jokenpo

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.mycomp.jokenpo.model.User
import com.mycomp.jokenpo.respository.UserRepository
import com.nhaarman.mockitokotlin2.whenever
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.extension.ExtendWith
import org.mockito.junit.jupiter.MockitoExtension
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import org.springframework.web.util.NestedServletException

@AutoConfigureMockMvc. // auto-magically configures and enables an instance of MockMvc
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
// Why configure Mockito manually when a JUnit 5 test extension already exists for that very purpose?
@ExtendWith(SpringExtension::class, MockitoExtension::class)
class JokenpoApplicationTests 

    @Autowired
    private lateinit var mockMvc: MockMvc

    @MockBean
    lateinit var respository: UserRepository

    @BeforeEach
    fun setup() 
        // use mockito-kotlin for a more idiomatic way of setting up your test expectations
        whenever(respository.save(User(1, "Test"))).thenAnswer 
            it.arguments.first()
        
    

    @Test
    fun `Test createUser in the happy path scenario`() 
        val user = User(1, "Test")
        mockMvc.post("/users/") 
            contentType = MediaType.APPLICATION_JSON
            content = jacksonObjectMapper().writeValueAsString(user)
            accept = MediaType.APPLICATION_JSON
        .andExpect 
            status  isOk 
            content  contentType(MediaType.APPLICATION_JSON) 
            content  json(""""id":1,"name":"Test"""") 
        
        verify(respository, times(1)).save(user)
    

    @Test
    fun `Test negative scenario of createUser`() 
        val user = User(2, "Test")
        assertThrows<NestedServletException> 
            mockMvc.post("/users/") 
                contentType = MediaType.APPLICATION_JSON
                content = jacksonObjectMapper().writeValueAsString(user)
                accept = MediaType.APPLICATION_JSON
            
        
        verify(respository, times(1)).save(user)
    

    @Test
    fun findUser() 
        mockMvc.get("/users?id=99")
            .andExpect 
                status  isOk 
            
        verify(respository, times(1)).findAll()
    


话虽如此,这里有一些值得深思的地方:

任何测试都需要包含验证,以断言系统在各种类型的场景(包括负面场景)下的行为符合预期,例如 我们如何检查服务是否未能在数据库.

我注意到您已经在 ApplicationContext (H2) 中设置了测试数据库,那么为什么不使用它来创建测试记录,而不仅仅是模拟存储库层呢?然后您可以验证数据库是否包含任何新创建的记录。

作为一般规则,我避免将 Mockito 与 Kotlin 测试一起使用(搜索 *** 有几个原因),甚至避免使用 mockito-kotlin。现在的最佳做法是结合使用出色的 MockK 库和 AssertJ 或 assertk 来验证您的期望。

【讨论】:

谢谢,我会和 AssertJ 一起学习 MockK。拜托,我不明白回答的两点:(1)为什么需要“验证(存储库)......”?您已经在第一次和第三次测试中使用了“andExpect”,在第二次测试中使用了“assertThrows”。我了解您正在检查结果是预期的还是断言的。 “andExpect”和“assertThrows”不是用于验证/吗? (2) 在第三个测试 findUser 中,我希望只有在没有找到的情况下才能通过。这就是我搜索 99 的原因。为什么它返回“isOk”?我期待“isNotFound”(见控制器:.orElse(ResponseEntity.notFound().build())) 好心,“verify(respository, times(1)).save(user)”给我的测试增加了什么?我删除它,它仍然通过。我可以看到您试图帮助我警告“任何测试都需要包含验证才能断言”,但我肯定在这里遗漏了一些概念。 从概念上讲,使用模拟对象的单元测试包括验证,以检查您设置的调用是否与任何预期的输入一起发生。如果您可以抽出几分钟的时间,我建议您观看以下视频:youtube.com/watch?v=J7_4WrImJPk 至于第三个测试,我添加了一个验证语句来检查对您的存储库方法findAll 的调用是否确实发生了。它成功的原因是因为您的存储库的 mockito 模拟实际上确实返回了一个“不错的”默认值,尽管您没有设置一个(也许违反直觉?)。我可以向您保证,在使用 MockK 时,称为 relaxed mocks 的上述行为不是默认行为。使用 MockK 时,默认情况下,必须设置每个预期调用,您甚至可以使用 confirmVerified 强制验证(自 1.9 起可用)。【参考方案2】:

在以下设置中运行获取单元测试:

 - Kotlin 
 - Spring Boot
 - JUnit 5
 - Mockito
 - Gradle

您需要此配置才能开始:

build.gradle.kts

dependencies 
   // ...

   testRuntimeOnly(group = "org.junit.jupiter", name = "junit-jupiter-engine", version = "5.6.3")
   testImplementation(group = "org.mockito", name = "mockito-all", version = "1.10.19")
   testImplementation("org.springframework.boot:spring-boot-starter-test") 
       exclude(group = "org.junit.vin  tage", module = "junit-vintage-engine")
   

   // ...


tasks.withType<Test> 
   useJUnitPlatform()

测试文件

@org.springframework.boot.test.context.SpringBootTest
class YourTest 

    @org.mockito.Mock
    lateinit var testingRepo: TestingRepo

    @org.mockito.InjectMocks
    lateinit var testingService: TestingService


    @org.springframework.test.context.event.annotation.BeforeTestMethod
    fun initMocks() 
        org.mockito.MockitoAnnotations.initMocks(this)
    

    @org.junit.jupiter.api.Test
    fun yourTest() org.junit.jupiter.api.Assertions.assertTrue(true)


【讨论】:

以上是关于Kotlin + SpringBootTest + Junit 5 + AutoConfigureMockMvc:测试通过时应该失败(似乎@BeforeEach没有生效)的主要内容,如果未能解决你的问题,请参考以下文章

带有 Kotlin、TestContainers 和外部配置的 SpringBootTest

使用 Spring Boot 进行 Kotlin 集成测试

试图在 Spring Boot 中理解 kotlin DI

Kotlin,Spring book,Mockito,@InjectMocks,使用与创建的不同的模拟

Spring Boot 与 Kotlin 上传文件

Spring Boot 2.x 实践记:@SpringBootTest