Testes com Micronaut, Kotlin, JUnit e Mockito
11 Jul 2021Introdução
Em nosso post anterior, nós fizemos um Hello World usando Micronaut, um teste de carga e comparamos a performance dele usando a compilação JIT e AOT. Mas não passamos por um tópico muito importante: como escrever testes para validar nossa aplicação Micronaut. Nesse post vamos usar ferramentas de testes muito conhecidas no mundo Java: JUnit e Mockito. Além do JUnit, o Micronaut dá suporte para Kotest e Spock, mas estão fora do escopo desse artigo.
Validando controllers
Nosso Hello World não tem lógica de negócio, é apenas um controller que retorna um DTO, e validar um Controller usando o Micronaut é bastante simples. Veja o exemplo abaixo:
@MicronautTest
class HelloControllerTest {
@Inject
@field:Client("/")
lateinit var httpClient: HttpClient
@Test
fun `should return Hello World message`() {
val response = httpClient.toBlocking().retrieve("/", Message::class.java)
assertEquals("Hello world!", response.message)
}
}
Para validar um controller adicionamos a anotação @MicronautTest
e injetamos um HttpClient
para chamar a API desejada. Veja que não estamos escrevendo um teste unitário, pois a nossa aplicação irá executar e retornar um resultado real. Quando executamos esse teste o Micronaut vai subir nossa aplicação e injetar um httpClient
apontando para ela. Dessa forma conseguimos fazer chamadas de APIs e validar o seu resultado. Não se preocupe muito com o HttpClient
, vou escrever um post exclusivo sobre ele.
Criando mocks de classes com Mockito
O primeiro passo é adicionar as dependências abaixo em nosso arquivo build.gradle.kts
.
dependencies {
// várias outras dependências aqui
testImplementation("org.mockito:mockito-core")
testImplementation("org.mockito.kotlin:mockito-kotlin:3.2.0")
}
Quando estamos escrevendo testes de unidade é comum o uso de mocks de classes para isolar nosso cenário de teste. Dessa forma é possível não depender de outros sistemas, além de facilitar a construção de cenários. Veja a classe abaixo, ela depende de um repositório que acessa um banco de dados:
@Singleton
class ProductService(
private val productRepository: ProductRepository
) {
fun save(product: Product) {
if (productRepository.findById(product.id) != null) {
throw IllegalArgumentException("Product already exists")
}
productRepository.save(product)
}
}
Para não depender do banco de dados queremos criar um mock do repositório. Para fazer isso basta criar em nossa classe de teste um método anotado com @MockBean(ClasseQueSeraMockada::class)
que retorna o mock. Esse método serve para a injeção de dependência do Micronaut trocar a classe real pela classe mockada.
@MockBean(ProductRepository::class)
fun productRepository(): ProductRepository {
return mock(ProductRepository::class.java)
}
Veja o teste completo a seguir. Note que usamos o productRespository
que foi injetado na classe para configurar seu comportamento ao invés de chamar o método productRepository()
@MicronautTest
class ProductTest {
@Inject
lateinit var productService: ProductService // (1)
@Inject
lateinit var productRepository: ProductRepository // (2)
@MockBean(ProductRepository::class)
fun productRepository(): ProductRepository {
return mock(ProductRepository::class.java) // (3)
}
@Test
fun `when saving a product, should throw IllegalStatementException when product already exists`() {
val response = Product("1", "Produto 1")
whenever(productRepository.findById("1")).thenReturn(response) // (4)
assertThrows<IllegalArgumentException> {
productService.save(Product("1", "Produto 1"))
}
}
}
Veja o que significam os comentários numerados do código acima:
- Injeto a classe que será testada
- Injeto a dependência que será mockada
- Crio um mock para productRepository
- Configuro o comportamento do mock
Fornecendo configurações para seu teste
Algumas vezes queremos sobrescrever configurações para rodar nossos testes, veja o controller abaixo.
@Controller
class ConfigController(
@Value("\${app.message:Good Bye.}") val config: String
) {
@Get("/config")
fun config(): Message {
return Message(config)
}
}
O controller recebe uma configuração no seu construtor, caso a configuração não exista o valor “Good Bye.” será usado. Para sobrescrever essa configuração podemos usar a anotação @Property(name = "app.message", value = "E aí?!")
, essa anotação pode ser usada tanto para toda a classe de teste como para um teste específico conforme exemplo que segue.
@MicronautTest
class ConfigControllerTest {
@Inject
@field:Client("/")
lateinit var httpClient: HttpClient
@Test
@Property(name = "app.message", value = "E aí?!") // (1) anotação em um teste específico
fun `should return configured message using TestPropertyProvider`() {
val response = httpClient.toBlocking().retrieve("/config", Message::class.java)
Assertions.assertEquals("E aí?!", response.message)
}
}
O Micronaut também oferece uma interface chamada TestPropertyProvider
, com ela é possível fazer o setup de alguma ferramenta, ler os valores dela e repassar para a aplicação como configurações. Para utilizá-la, seu teste precisa implementá-la e sobrescrever o método getProperties
que retorna um MutableMap<String, String>
com as configurações necessárias, além disso, é preciso adicionar a anotação @TestInstance(TestInstance.Lifecycle.PER_CLASS)
na sua classe de teste.
@MicronautTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ConfigControllerTest : TestPropertyProvider {
@Inject
@field:Client("/")
lateinit var httpClient: HttpClient
@Test
fun `should return configured message using TestPropertyProvider`() {
val response = httpClient.toBlocking().retrieve("/config", Message::class.java)
Assertions.assertEquals("E aí?!", response.message)
}
override fun getProperties(): MutableMap<String, String> {
// faz o setup de alguma ferramenta aqui e lê informações dela
return mutableMapOf(
"app.message" to "E aí?!"
)
}
}
Caso tenha dúvidas ou sugestões, utilize a caixa de comentários abaixo ou entre em contato pelo twitter em @john_owl