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

aSe*_*emy 10 unit-testing mockito kotlin spring-boot junit5

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

设置

  • 两个都
    • Kotlin 1.4.30(用于单元测试和配置类)
    • Java 15(带有 --enable-preview)(用于业务逻辑)
  • 春季启动2.4.2
  • 朱尼特 5.7.1
  • Mockito(mockito 内联)3.7.7
  • Maven 3.6.3_1

我想用不同的配置测试 FtpService (a @Service,其中有 a )。RestTemplate

FtpService 的属性来自 Kotlin 数据类 - UrlProperties - 用ConstructorBinding和注释@ConfigurationProperties

注意:FtpService 的构造函数从 UrlProperties 中提取属性。这意味着在Spring 加载 FtpService之前必须对 UrlProperties 进行模拟存根

错误

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

Cannot bind @ConfigurationProperties for bean 'urlProperties'. Ensure that @ConstructorBinding has not been applied to regular bean
Run Code Online (Sandbox Code Playgroud)

代码

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

Cannot bind @ConfigurationProperties for bean 'urlProperties'. Ensure that @ConstructorBinding has not been applied to regular bean
Run Code Online (Sandbox Code Playgroud)

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

唯一的“解决方法”是手动定义所有 bean(这意味着我在测试过程中错过了 Spring 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.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())
  }
}
Run Code Online (Sandbox Code Playgroud)

春季应用 | `src/main/kotlin/com/example/MyApp.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())
  }
}
Run Code Online (Sandbox Code Playgroud)

示例@Service | `src/main/kotlin/com/example/service/FtpService.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)
}
Run Code Online (Sandbox Code Playgroud)

@ConfigurationProperties 和 @ConstructorBinding - `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,
  )
}
Run Code Online (Sandbox Code Playgroud)

Dir*_*lte 0

选项1

您可以使用注释提供自定义配置@TestPropertySource并提供不同的配置。通过在注释中提供属性可以实现类似的结果@SpringBootTest。那么您将不会创建自定义 URL 属性 bean。

另请参阅: https: //www.baeldung.com/spring-tests-override-properties

这种方法可能会很昂贵,因为您的 Spring 上下文将为具有此注释的每个测试类以及以下测试重新构建,这可能会为您的整体测试运行时间增加几秒。

选项2

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

例子:

@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"
     }
  }
}
Run Code Online (Sandbox Code Playgroud)

在 Spring 中使用模拟也会对性能产生影响,而这个影响会比选项 1 中的小。

选项3

由于您只是测试 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)
  }
}
Run Code Online (Sandbox Code Playgroud)

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

选择

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