Spring Boot testing: Cannot bind @ConfigurationProperties - Ensure that @ConstructorBinding has not been applied

Viewed 2602

In a Spring Boot unit test, how can you mock @ConstructorBinding @ConfigurationProperties data class?

Setup

  • Both
    • Kotlin 1.4.30 (for unit tests and config classes)
    • Java 15 (with --enable-preview) (for business logic)
  • Spring Boot 2.4.2
  • Junit 5.7.1
  • Mockito (mockito-inline) 3.7.7
  • Maven 3.6.3_1

I want to test FtpService (a @Service, which has a RestTemplate) with different configurations.

Properties for the FtpService come from a Kotlin data class - UrlProperties - which is annotated with ConstructorBinding and @ConfigurationProperties.

Note: FtpService's constructor extracts a property from UrlProperties. This means that UrlProperties must be both mocked and stubbed before Spring loads FtpService

Error

When I try and mock UrlProperties so that I can set the properties for different tests, I either receive an error, or am unable to insert the bean

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

Code

`@SpringBootTest` of FtpService | `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())
  }
}

Workaround - manual definition every test

The only 'workaround' is to manually define all beans (which means I miss out on Spring Boot magic during testing) and in my view is more confusing.

Workaround - manual redefinition each test | `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 App | `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)
}

Example @Service | `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}!"
  }
}

@ConfigurationProperties with @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,
  )
}

0 Answers
Related