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,
)
}