Trilha de Kotlin · parte 56 de 59
Testes com Kotest, MockK e Testcontainers
Aula da fase 8 da trilha de Kotlin: Quando usar testes de propriedade, por que MockK lida melhor com Kotlin do que Mockito, e como subir Postgres e MongoDB reais nos testes, além de testar o Azure…
intermediárioPublicada em · 16 min de leitura
Um teste que passa sempre não prova nada. Um teste que passa com um banco de mentira pode esconder um erro que só aparece em produção. E um teste que depende de um ambiente compartilhado falha por motivos que não têm nada a ver com o código, como alguém ter apagado uma tabela na véspera. Esta aula trata das ferramentas que a stack do projeto usa para escrever testes que confiam no que testam: Kotest para as asserções e os testes de propriedade, MockK para os dublês em Kotlin, e Testcontainers para subir um PostgreSQL de verdade dentro do teste. No fim, há uma seção sobre mensageria, em que o Azure Service Bus aparece como exemplo do problema, porque o projeto de exemplo não tem um broker.
As versões são as da fase 7: Kotlin 2.3.21, Spring Boot 4.1.1 com Spring Framework 7.0.9, Kotest 6.2.6, MockK 1.14.11 e Testcontainers 2.0.5. Os testes citados aqui rodaram com Maven, em JDK 21, sobre o Docker Desktop desta máquina.
Uma analogia de laboratório ajuda. Um teste unitário é a bancada em que se testa um componente sozinho, com instrumentos simulados. Um teste de integração é a planta-piloto, com as máquinas de verdade ligadas. Um teste de propriedade é uma máquina que testa a bancada milhares de vezes com entradas sorteadas, em vez de um técnico escolhendo cinco casos. As três coisas são úteis, e o erro comum é usar a planta-piloto para tudo, porque ela é a mais convincente e a mais lenta.
o mapa da aula
A aula começa pela divisão de responsabilidades entre os tipos de teste no projeto, porque cada tipo tem um custo diferente. Depois apresenta o Kotest como framework de asserções e de testes de propriedade, com a diferença entre checkAll e forAll. A seção sobre o MockK mostra por que a biblioteca lida bem com classes final do Kotlin. A seção seguinte trata do Testcontainers com @ServiceConnection, que liga o container ao Spring sem configuração manual de URL. A última parte fala de mensageria e de como testar sem depender de um ambiente compartilhado.
1. quem testa o quê
No projeto de exemplo, cada módulo tem a ferramenta que combina com o que ele contém. O domínio tem regras pequenas e sem efeitos colaterais, então testes de propriedade sobre o Centavos são baratos e fortes. A aplicação orquestra portas, então um dublê de cada porta basta para verificar o fluxo. Os adaptadores falam com o mundo real, e por isso precisam de um PostgreSQL de verdade. O teste de arquitetura não testa comportamento: testa a forma do código, com o ArchUnit, que a Aula Arquitetura hexagonal com ArchUnit explica.
A figura abaixo resume essa divisão e o custo de cada ferramenta. Ela mostra que a dependência de Docker aparece só nos adaptadores.
O olhar percorre a figura da esquerda para a direita, e a seta do meio marca a mudança de custo. Até a aplicação, nenhum teste precisa de Docker. A partir dos adaptadores, o teste sobe um container, e o tempo sobe junto. Não medi quanto tempo a suíte leva por camada, então não informo números de duração. O que o projeto mostra com segurança é a separação: o domínio e a aplicação rodam sem Docker, e apenas o adaptador de persistência depende dele.
2. Kotest: asserções e testes de propriedade
O Kotest é um framework de testes para Kotlin. A versão usada aqui é a 6.2.6, e o artefato do runner JUnit 5 é io.kotest:kotest-runner-junit5. Ele oferece vários estilos de especificação. O mais simples é o FunSpec, que escreve cada teste como uma chamada de função com um nome em texto:
class CentavosPropriedadesTest : FunSpec({
test("a soma de Centavos preserva o valor numérico") {
checkAll(Arb.long(0L..1_000_000_000L), Arb.long(0L..1_000_000_000L)) { a, b ->
(Centavos(a) + Centavos(b)).valor shouldBe a + b
}
}
})
O shouldBe é uma asserção do pacote io.kotest.matchers. O que muda em relação a um teste com exemplos fixos é o checkAll. Em vez de escolher duas somas à mão, o teste recebe dois geradores, Arb.long, e roda a mesma regra com muitos valores. A documentação oficial do Kotest diz que um teste de propriedade executa a função centenas ou milhares de vezes, e que o padrão é 1000 execuções. A propriedade é o que deve continuar verdadeiro para qualquer entrada: a soma de dois valores é a soma dos números.
Os testes de propriedade cobrem casos que um humano esquece. Um segundo teste do projeto verifica que nenhum valor negativo vira Centavos:
test("nenhum valor negativo vira Centavos") {
checkAll(Arb.long(Long.MIN_VALUE..-1L)) { negativo ->
shouldThrow<IllegalArgumentException> { Centavos(negativo) }
}
}
O gerador cobre a faixa inteira de Long negativos, incluindo os extremos, que um autor de teste manual raramente escolheria. Quem já viu um bug de overflow entende o valor disso.
Existem duas funções para o mesmo papel, e a documentação do Kotest as separa pelo tipo de retorno. forAll recebe uma função que devolve Boolean, e o teste passa se ela devolver true para todos os valores. checkAll recebe uma função que devolve Unit, em que as asserções rodam, e o teste é válido se nenhuma exceção for lançada. Por isso checkAll combina com shouldBe e shouldThrow, que dão mensagens claras na falha. forAll serve quando a regra cabe numa expressão booleana.
Há um cuidado que a documentação deixa implícito e que decide se o teste vale alguma coisa: um teste de propriedade só é tão bom quanto o gerador. Se a faixa escolhida exclui o caso que quebra a regra, o teste passa e a falha continua escondida. Por isso o segundo teste acima cobre a faixa inteira de negativos, e não apenas -1.
O shrinking, que reduz automaticamente um valor que falhou até o menor caso que ainda quebra a regra, não foi exercitado nesta revisão, porque nenhuma propriedade do projeto falhou de propósito. A descrição dele fica como exercício, e a aula não afirma números de tentativas para essa etapa.
3. MockK: dublês que entendem Kotlin
O MockK é uma biblioteca de mocks feita para Kotlin. Ela oferece a DSL every e verify, suporte a corrotinas com coEvery e coVerify, e a capacidade de criar mocks de classes final. Essa última é a diferença que mais importa aqui, porque classes Kotlin são final por padrão, como a aula de interfaces explica.
Nos testes da aplicação, o MockK dubla a porta de pagamento. O teste de cobrança recusada mostra o padrão:
test("cobrança recusada salva o pedido como recusado") {
val repositorio = mockk<PedidoRepositorio>(relaxed = true)
val pagamento = mockk<GatewayPagamento>()
every { pagamento.cobrar(any()) } returns ResultadoCobranca.Recusada("limite excedido")
val resultado = RegistrarPedido(repositorio, pagamento).executar(Pedido("p-2", Centavos(200_000)))
resultado.estado shouldBe EstadoPedido.Recusado
}
O relaxed = true faz o mock devolver valores padrão para chamadas que não foram configuradas. Isso evita configurar o repositório inteiro quando o teste só se importa com o pagamento. O pagamento não é relaxado, de modo que uma chamada não prevista a ele faria o teste falhar. O comportamento de mocks sem relaxamento não foi reconferido na documentação nesta revisão, mas o teste roda com essa configuração.
O teste de aprovação verifica a interação, e não só o retorno:
test("cobrança aprovada salva uma vez o pedido aprovado") {
val repositorio = mockk<PedidoRepositorio>(relaxed = true)
val pagamento = mockk<GatewayPagamento>()
every { pagamento.cobrar(any()) } returns ResultadoCobranca.Aprovada
RegistrarPedido(repositorio, pagamento).executar(Pedido("p-1", Centavos(100)))
verify(exactly = 1) { repositorio.salvar(match { it.estado == EstadoPedido.Aprovado }) }
}
A verificação responde a uma pergunta diferente: o repositório foi chamado exatamente uma vez com um pedido aprovado? Essa é a forma de testar um efeito colateral sem depender de um banco. O match é um matcher da própria DSL do MockK, por isso não precisa de import separado.
Há um terceiro teste que prova a afirmação sobre classes final. O Pedido é uma data class, portanto final, e o teste o mocka:
test("MockK também cria mock de classe final, como o próprio Pedido") {
val pedidoFalso = mockk<Pedido>()
every { pedidoFalso.id } returns "falso"
pedidoFalso.id shouldBe "falso"
}
O teste passou na execução. Não comparei com o Mockito, porque ele não está no projeto, então esta aula não afirma o que aconteceria com ele. O que se afirma é que o MockK cria o mock da classe final sem alterar o código de produção.
Uma limitação que a documentação do MockK descreve é a de funções inline, cujo corpo é copiado para o chamador na compilação, e por isso não há uma chamada a interceptar. Esse ponto não foi conferido nesta revisão, então a aula só o registra como ponto a verificar, e não como fato. A relação com a aula de funções inline da Fase 2 continua sendo a mesma: o compilador troca a chamada pelo corpo.
4. Testcontainers: PostgreSQL de verdade no teste
O Testcontainers sobe containers Docker durante os testes, e os remove ao final. A versão usada é a 2.0.5. Os módulos para banco de dados são org.testcontainers:testcontainers-postgresql e org.testcontainers:testcontainers-mongodb, na mesma versão. A extensão JUnit 5 fica em org.testcontainers:testcontainers-junit-jupiter. Esse nome merece atenção. A documentação do Spring Boot ainda mostra org.testcontainers:junit-jupiter, mas essa coordenada retorna 404 no Maven Central para a versão 2.0.5. A Aula Gradle Kotlin DSL e multi-módulo mostra o erro de compilação que aparece com o nome antigo.
O teste de integração do repositório Postgres usa @SpringBootTest com a extensão do Testcontainers:
@SpringBootTest
@Testcontainers
class PedidoJdbcRepositorioIT {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer(DockerImageName.parse("postgres:17"))
}
@Autowired
lateinit var repositorio: PedidoJdbcRepositorio
@Test
fun `salva e recupera um pedido do Postgres real`() {
repositorio.salvar(Pedido("it-1", Centavos(4_200)))
val lido = repositorio.buscar("it-1")
lido shouldNotBe null
lido!!.total shouldBe Centavos(4_200)
lido.estado shouldBe EstadoPedido.Pendente
}
}
O @Container marca o campo que o JUnit deve iniciar, e o companion object com @JvmStatic faz o container ser compartilhado entre os testes da classe. O @ServiceConnection é a parte mais útil. A documentação do Spring Boot diz que uma conexão de serviço tem precedência sobre as propriedades de conexão: “the connection details take precedence over any connection-related configuration properties”. Na prática, o teste não precisa apontar a URL com @DynamicPropertySource, porque o Spring cria os detalhes de conexão a partir do container. A forma alternativa é registrar a URL, o usuário e a senha à mão, com essa anotação de propriedade dinâmica. A documentação a apresenta como uma alternativa mais verbosa, mas também mais flexível.
O uso de !! no teste é uma escolha consciente. Ele aparece em um teste, onde uma falha de nulo é o comportamento desejado, e não em código de produção.
A execução usou o Docker Desktop desta máquina e a imagem postgres:17. O teste passou. Um detalhe de execução: o Surefire, plugin de testes do Maven, roda por padrão só classes com sufixo Test, então o IT precisou ser chamado explicitamente com -Dtest=PedidoJdbcRepositorioIT. Num build que roda todas as classes de teste, como o Gradle faz por padrão, esse detalhe não aparece. Essa diferença vem do Maven, e não foi conferida na documentação do Gradle nesta revisão.
O Testcontainers tem um cuidado de ciclo de vida. A documentação do Spring Boot alerta que, quando um container é declarado como campo static com a extensão JUnit, ele para depois da classe de teste. O contexto do Spring pode ser reaproveitado em outra classe, e um bean que depende de um container já parado pode falhar. Por isso a documentação recomenda gerenciar containers como beans do Spring quando o contexto fica em cache. No projeto, o container é static, e não houve falha porque só uma classe de integração existe. Num projeto com várias, essa recomendação passa a valer.
O módulo MongoDB segue o mesmo modelo, com MongoDBContainer e o módulo testcontainers-mongodb. Não incluí um teste de MongoDB no projeto, então esta aula não afirma que ele rodou.
5. mensageria sem ambiente compartilhado
O projeto de exemplo não tem broker de mensagens. Esta seção existe porque, quando uma fila entra no sistema, o problema de testá-la aparece, e a escolha mais comum é um namespace compartilhado na nuvem. Um ambiente compartilhado traz três males: o teste depende da rede, o estado de um teste vaza para o outro, e quem roda a suíte precisa de credenciais.
A documentação oficial da Microsoft oferece, para o Azure Service Bus, um emulador em forma de contêiner Docker, voltado a desenvolvimento e teste. Segundo a página de visão geral, consultada em 20/10/2026, o emulador roda como contêiner Linux e suporta streaming de mensagens pelo protocolo AMQP. As limitações registradas na página mudam o desenho do teste. Ele não suporta JMS, não é compatível com entidades particionadas, aceita apenas AMQP sobre TCP, e os dados e entidades não persistem após o reinício do contêiner. A página diz também que o emulador é voltado a cenários de teste sequenciais, que não deve ser usado para cargas de produção porque não oferece SLA, e que a configuração das entidades está num arquivo JSON que só é lido na inicialização.
Essas limitações sugerem duas regras. Cada classe de teste deve criar e apagar as entidades de que precisa, porque o contêiner não guarda estado entre reinícios e a ordem dos testes pode importar. E a configuração de entidades precisa estar pronta antes de o contêiner subir, porque mudanças no JSON não valem em tempo real.
A alternativa mais barata para a maioria das regras é a porta com dublê em memória. O caso de uso publica um evento por uma porta, e o teste troca a implementação real por uma lista:
interface PublicadorDeEventos {
fun publicar(evento: String)
}
class PublicadorEmMemoria : PublicadorDeEventos {
val publicados = mutableListOf<String>()
override fun publicar(evento: String) {
publicados.add(evento)
}
}
Esse trecho é ilustrativo e não foi compilado no projeto. Ele mostra o princípio: a regra de negócio que decide quando publicar é testada com o dublê, e o adaptador da fila é testado separadamente, com o emulador ou com um ambiente dedicado. O dublê testa a decisão, mas não testa a entrega. Confirmações, reentregas e mensagens mortas dependem do broker real, e o dublê não as reproduz. Essas semânticas não foram exercitadas nesta revisão.
Pergunta de entrevistaPor que testar com banco real em vez de H2?
O H2 emula um banco, mas não tem o mesmo SQL, os mesmos tipos nem as mesmas regras de índice e transação do PostgreSQL. Um teste que passa no H2 pode falhar em produção. O Testcontainers sobe o banco real num contêiner descartável, e o teste roda contra o mesmo motor que vai para produção. O custo é o tempo de execução, por isso separo testes unitários dos de integração.
6. erros comuns e boas práticas
O primeiro erro é usar Testcontainers para testar regra pura. Um teste de propriedade sobre Centavos roda em milissegundos, e um teste com container leva segundos só para subir o banco. A regra deve ficar no teste mais barato que a prova.
O segundo erro é o relaxed = true em todos os mocks. Um mock relaxado esconde chamadas inesperadas, e o teste passa mesmo quando o caso de uso chama algo que não deveria. A boa prática é relaxar só o que não importa para o teste.
O terceiro erro é o gerador estreito. Um teste de propriedade que sorteia só valores positivos nunca encontra o bug dos negativos. A boa prática é cobrir a faixa que a regra promete, incluindo os extremos.
O quarto erro é depender de uma fila compartilhada. O teste passa na máquina de quem escreveu e falha no CI, porque outra pessoa publicou uma mensagem na mesma fila. A boa prática é criar um recurso por teste, ou usar o emulador com a fila configurada no início.
O quinto erro é escrever a URL de conexão à mão, o que faz o teste funcionar numa máquina e quebrar em outra. O @ServiceConnection resolve isso, e a documentação do Spring Boot confirma que ele tem precedência sobre as propriedades de conexão.
O sexto erro é deixar um container static servir um contexto em cache para outras classes de teste, sem perceber que ele já parou. A boa prática é tratar o container como bean do Spring quando o contexto é reaproveitado.
7. exercícios
- Escreva uma propriedade para
Centavosque verifique a comutatividade da soma:a + beb + adão o mesmo valor. Rode comcheckAlle confirme que o teste passa. - Escreva uma propriedade que falha de propósito, por exemplo afirmando que
a + bé sempre menor quea. Leia a saída do Kotest, identifique o valor que quebra a regra e registre se houve redução do valor (shrinking) na sua versão. - Crie um teste com
verify(exactly = 2)para o repositório, com dois pedidos processados em sequência. Depois altere o teste para processar um pedido só e observe a falha. - Escreva um segundo teste de integração que use
@DynamicPropertySourceem vez de@ServiceConnection. Compare as duas versões em número de linhas e no risco de a URL divergir da do container.
8. glossário
Teste de propriedade: teste que verifica uma regra com muitas entradas geradas, em vez de exemplos escolhidos à mão.
Gerador (Arb): objeto do Kotest que produz valores de um tipo para um teste de propriedade.
Shrinking: redução automática de um valor que falhou até o menor caso que ainda quebra a regra.
Dublê (mock): objeto que substitui uma dependência no teste e registra ou responde chamadas.
Mock relaxado: mock que devolve valores padrão para chamadas não configuradas.
Testcontainers: biblioteca que sobe containers Docker durante os testes.
@ServiceConnection: anotação do Spring Boot que cria os detalhes de conexão a partir de um container, com precedência sobre as propriedades de conexão.
Emulador do Service Bus: contêiner oficial da Microsoft que imita a fila para desenvolvimento e teste, sem SLA para produção.
fontes
Consultadas em 20/10/2026.
- Kotest, testes de propriedade, com 1000 execuções como padrão: https://kotest.io/docs/proptest/property-based-testing.html
- Kotest, funções
forAll(retorno booleano) echeckAll(asserções sem exceção): https://kotest.io/docs/proptest/property-test-functions.html - Spring Boot, Testcontainers, precedência de
@ServiceConnectione ciclo de vida de containersstatic: https://docs.spring.io/spring-boot/reference/testing/testcontainers.html - Microsoft Learn, visão geral do emulador do Azure Service Bus, com limitações, persistência e SLA (página com data de atualização de 2026-09-18): https://learn.microsoft.com/en-us/azure/service-bus-messaging/overview-emulator
- Coordenada
org.testcontainers:junit-jupiter:2.0.5, que retornou 404: https://repo1.maven.org/maven2/org/testcontainers/junit-jupiter/2.0.5/junit-jupiter-2.0.5.pom - Assinatura do construtor
PostgreSQLContainer(String), marcado como obsoleto no artefato 2.0.5, conferido comjavap: https://repo1.maven.org/maven2/org/testcontainers/testcontainers-postgresql/2.0.5/testcontainers-postgresql-2.0.5.jar - MockK, página principal: https://mockk.io/ (não reconferida nesta revisão, exceto pelo uso do
mockk-jvmna versão 1.14.11)
Não verificado nesta revisão: o comportamento de shrinking e seus números de tentativa, o comportamento de mock estrito do MockK, a limitação de funções inline no MockK, o módulo testcontainers-mongodb, o kotest-extensions-spring, e o comportamento de confirmação e reentrega do Service Bus.
Executado em 20/10/2026, com Maven, JDK 21, Kotlin 2.3.21, Spring Boot 4.1.1 e PostgreSQL 17 num container Docker: os dois testes de propriedade de Centavos, os três testes de RegistrarPedido (incluindo o mock de classe final) e o teste de integração com @ServiceConnection passaram. O emulador do Azure e o dublê em memória não foram executados.