kotlin Spring Boot 测试:无法绑定@ ConstructorProperties-确保尚未应用@ConstructorBinding

shstlldc  于 2023-10-23  发布在  Kotlin
关注(0)|答案(1)|浏览(86)

在Sping Boot 单元测试中,如何模拟@ConstructorBinding @ PractitionProperties数据类?

设置

  • Kotlin1.4.30(用于单元测试和配置类)
  • Java 15(带--enable-preview)(用于业务逻辑)
  • Spring Boot 2.4.2
  • Junit 5.7.1
  • Mockito(mockito-inline)3.7.7
  • Maven 3.6.3_1

我想用不同的配置测试FtpService(一个@Service,它有一个RestTemplate)。
FtpService的属性来自一个Kotlin数据类- UrlProperties -它用ConstructorBinding@ConfigurationProperties注解。
注意:FtpService的构造函数从UrlProperties中提取属性。这意味着在Spring加载FtpService之前,UrlProperties必须被mockedstubbed

错误

当我尝试模拟UrlProperties以便为不同的测试设置属性时,我要么收到一个错误,要么无法插入bean

Cannot bind @ConfigurationProperties for bean 'urlProperties'. Ensure that @ConstructorBinding has not been applied to regular bean

代码

FtpService的@SpringBootTest|src/test/Kotlin/com/example/FtpServiceTest.kt

import com.example.service.FtpService
import com.example.service.UrlProperties
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.mockito.Mockito.`when`
import org.mockito.Mockito.mock
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.client.AutoConfigureWebClient
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.context.annotation.Bean
import org.springframework.test.context.ContextConfiguration

@TestConfiguration
@SpringBootTest(classes = [FtpService::class])
@AutoConfigureWebClient(registerRestTemplate = true)
class FtpServiceTest
@Autowired constructor(
    private val ftpService: FtpService
) {

  // MockBean inserted into Spring Context too late,
  // FtpService constructor throws NPE
//  @MockBean
//  lateinit var urlProperties: UrlProperties

  @ContextConfiguration
  class MyTestContext {

    // error -
    // > Cannot bind @ConfigurationProperties for bean 'urlProperties'.
    // > Ensure that @ConstructorBinding has not been applied to regular bean
    var urlProperties: UrlProperties = mock(UrlProperties::class.java)

    @Bean
    fun urlProperties() = urlProperties

    // error -
    // > Cannot bind @ConfigurationProperties for bean 'urlProperties'.
    // > Ensure that @ConstructorBinding has not been applied to regular bean
//    @Bean
//    fun urlProperties(): UrlProperties {
//      return UrlProperties(
//          UrlProperties.FtpProperties(
//              url = "ftp://localhost:21"
//          ))
//    }
  }

  @Test
  fun `test fetch file root`() {

    `when`(MyTestContext().urlProperties.ftp)
        .thenReturn(UrlProperties.FtpProperties(
            url = "ftp://localhost:21"
        ))

    assertEquals("I'm fetching a file from ftp://localhost:21!",
        ftpService.fetchFile())
  }

  @Test
  fun `test fetch file folder`() {

    `when`(MyTestContext().urlProperties.ftp)
        .thenReturn(UrlProperties.FtpProperties(
            url = "ftp://localhost:21/user/folder"
        ))

    assertEquals("I'm fetching a file from ftp://localhost:21/user/folder!",
        ftpService.fetchFile())
  }
}

解决方法-每次测试手动定义

唯一的“变通方法”是手动定义所有bean(这意味着我在测试期间错过了Sping Boot 的魔力),在我看来这更令人困惑。
解决方法-手动重新定义每个测试|src/test/Kotlin/com/example/FtpServiceTest2.kt

import com.example.service.FtpService
import com.example.service.UrlProperties
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.mockito.Mockito.`when`
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.springframework.boot.web.client.RestTemplateBuilder

class FtpServiceTest2 {

  private val restTemplate =
      RestTemplateBuilder()
          .build()

  private lateinit var ftpService: FtpService

  private lateinit var urlProperties: UrlProperties

  @BeforeEach
  fun beforeEachTest() {
    urlProperties = mock(UrlProperties::class.java)

    `when`(urlProperties.ftp)
        .thenReturn(UrlProperties.FtpProperties(
            url = "default"
        ))

    ftpService = FtpService(restTemplate, urlProperties)
  }

  /** this is the only test that allows me to redefine 'url' */
  @Test
  fun `test fetch file folder - redefine`() {

    urlProperties = mock(UrlProperties::class.java)
    `when`(urlProperties.ftp)
        .thenReturn(UrlProperties.FtpProperties(
            url = "ftp://localhost:21/redefine"
        ))
    // redefine the service
    ftpService = FtpService(restTemplate, urlProperties)

    assertEquals("I'm fetching a file from ftp://localhost:21/redefine!",
        ftpService.fetchFile())
  }
  
  
  @Test
  fun `test default`() {
    assertEquals("I'm fetching a file from default!",
        ftpService.fetchFile())
  }

  @Test
  fun `test fetch file root`() {

    `when`(urlProperties.ftp)
        .thenReturn(UrlProperties.FtpProperties(
            url = "ftp://localhost:21"
        ))

    assertEquals("I'm fetching a file from ftp://localhost:21!",
        ftpService.fetchFile())
  }

  @Test
  fun `test fetch file folder`() {

    doReturn(
        UrlProperties.FtpProperties(
            url = "ftp://localhost:21/user/folder"
        )).`when`(urlProperties).ftp

    assertEquals("I'm fetching a file from ftp://localhost:21/user/folder!",
        ftpService.fetchFile())
  }

  @Test
  fun `test fetch file folder - reset`() {

    Mockito.reset(urlProperties)
    `when`(urlProperties.ftp)
        .thenReturn(UrlProperties.FtpProperties(
            url = "ftp://localhost:21/mockito/reset/when"
        ))

    assertEquals("I'm fetching a file from ftp://localhost:21/mockito/reset/when!",
        ftpService.fetchFile())
  }

  @Test
  fun `test fetch file folder - reset & doReturn`() {

    Mockito.reset(urlProperties)
    doReturn(
        UrlProperties.FtpProperties(
            url = "ftp://localhost:21/reset/doReturn"
        )).`when`(urlProperties).ftp

    assertEquals("I'm fetching a file from ftp://localhost:21/reset/doReturn!",
        ftpService.fetchFile())
  }
}

Spring应用程序|src/main/Kotlin/com/example/MyApp.kt

package com.example

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication

@SpringBootApplication
@EnableConfigurationProperties
@ConfigurationPropertiesScan
class MyApp

fun main(args: Array<String>) {
  runApplication<MyApp>(*args)
}

示例@服务|src/main/Kotlin/com/example/service/FtpService.kt

package com.example.service

import org.springframework.stereotype.Service
import org.springframework.web.client.RestTemplate

@Service
class FtpService(
    val restTemplate: RestTemplate,
    urlProperties: UrlProperties,
    val ftpProperties: UrlProperties.FtpProperties = urlProperties.ftp
) {

  fun fetchFile(): String {
    println(restTemplate)
    return "I'm fetching a file from ${ftpProperties.url}!"
  }
}

使用@ConstructorBinding的@ constructionProperties- src/main/Kotlin/com/example/service/UrlProperties.kt

package com.example.service

import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.ConstructorBinding

@ConstructorBinding
@ConfigurationProperties("url")
data class UrlProperties(val ftp: FtpProperties) {

  data class FtpProperties(
      val url: String,
  )
}
cgvd09ve

cgvd09ve1#

选项一

您可以使用@TestPropertySource注解提供自定义配置,并提供不同的配置。通过在@SpringBootTest注解中提供属性可以实现类似的结果。这样就不会创建自定义URL属性bean。
标签:https://www.baeldung.com/spring-tests-override-properties
这种方法可能代价很高,因为您的Spring上下文将为每个具有此annotation +以下测试的测试类重新构建,这可能会给您的整体测试运行时间增加几秒钟。

选项二

您可以使用@MockkBean(来自https://github.com/Ninja-Squad/springmockk)模拟您的配置bean并指定返回值。这与您的解决方法不同,因为您仍然有一个完整的Spring上下文和一个mock。
范例:

@TestConfiguration
@SpringBootTest(classes = [FtpService::class])
@AutoConfigureWebClient(registerRestTemplate = true)
class FtpServiceTest {

  @Autowired
  private lateinit var ftpService: FtpService
  
  @MockkBean
  private lateinit var urlProperties: UrlProperties

  @Nested
  inner class `scenario 1` {

     @BeforeEach
     fun setup() {
         every { urlProperties.ftp } returns "url for scenario 1"
     }
  }

  @Nested
  inner class `scenario 2` {

     @BeforeEach
     fun setup() {
         every { urlProperties.ftp } returns "url for scenario 2"
     }
  }
}

在spring中使用mock也会对性能产生影响,但这个影响要小于选项1中的影响。

选项三

由于您只是测试FTP服务本身,因此您也可以在Spring测试中再次示例化。因此,对于您的测试,您创建了一个使用所有bean+您的自定义属性的专用示例。
范例:

@TestConfiguration
@SpringBootTest(classes = [FtpService::class])
@AutoConfigureWebClient(registerRestTemplate = true)
class FtpServiceTest {

  // example for a bean from the context you want to inject but not define yourself
  @Autowired
  private lateinit var otherBean: OtherBean

  @Nested
  inner class `scenario 1` {

     private val urlProperties = UrlProperties(ftp = "url for scenario 1")
     private val ftpService = FtpService(urlProperties, otherBean)

  }

  @Nested
  inner class `scenario 2` {

     private val urlProperties = UrlProperties(ftp = "url for scenario 2")
     private val ftpService = FtpService(urlProperties, otherBean)
  }
}

这将是最快的方法-与Spring测试切片相结合,您可能会加快速度。但它不会测试实际的有线服务。

备选

我不确定它是否适用于您的用例:您还可以模拟您的FTP服务器(有一些现成的可用),并在每次测试运行期间提供不同的响应。这将允许您测试由spring构建的bean,并且您可能能够绕过spring上下文重建,因此具有相当快的测试。

相关问题