Pular para o conteúdo

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

Tamanho da fonte:
Largura da coluna:
Tom:
Espaço entre linhas:
Fonte:

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.

Ferramenta de teste por camada. dominio → aplicacao (sem Docker, rápido); aplicacao → adaptadores (com Docker, mais lento)…
figura 1 · Ferramenta de teste por camada

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

  1. Escreva uma propriedade para Centavos que verifique a comutatividade da soma: a + b e b + a dão o mesmo valor. Rode com checkAll e confirme que o teste passa.
  2. Escreva uma propriedade que falha de propósito, por exemplo afirmando que a + b é sempre menor que a. 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.
  3. 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.
  4. Escreva um segundo teste de integração que use @DynamicPropertySource em 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.

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.

relacionados

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?