Testes de sistema com TestContainers

iundarigun
Dev Cave
Published in
6 min readApr 19, 2020

Antes de começar, se você só tiver interesse em ler sobre TestContainers, pula os próximos parágrafos, pois é só uma dissertação de um velho em confinamento.

Vamos falar de testes

Em abril de 2017, escrevi um post sobre testes de integração usando Cucumber que, como tudo com o passar do tempo, me da mais vergonha que orgulho. Ultimamente venho pensando muito sobre qual deveria ser a abrangência da nossa base de testes. Se você jogar no google o texto “piramide de testes” vai achar centenas de artigos e centenas de imagens diferentes.

Esta introdução não é mais que para justificar que minha opinião foi mudando com os anos. Continuo pensando que alguns testes unitários são inúteis. Sei que é uma opinião polêmica, mas estou me referindo principalmente à aqueles testes que fazemos para atingir certa cobertura ou que fazemos só com intuito de cumprir o expediente e não procurando testar de fato aquele fluxo cabuloso. Acredito que os testes unitários são sim muito importantes para testar lógicas de negócio complexas mas, pensando num cenário de microservice ou aplicações pequenas com escopo definido, é interessante também testar seu código através das integrações entre as camadas ou componentes e não só testar cada parte de forma isolada. Esta opinião é baseada no fato que o código normalmente não funciona isolado e é importante entender o comportamento da aplicação desde o ponto de vista funcional e não unicamente técnico, o que acontece muitas vezes com testes unitários. O custo/tempo de rodar esse tipo de teste é relativamente rápido/barato dado o tamanho controlado da aplicação.

Mas, para continuar com minha explicação, preciso definir o que eu chamo de testes de integração: Considero o teste que abrange desde o ponto de entrada na nossa aplicação até o ponto de saída. Isso é, pensando numa API como exemplo, desde a chamada até a saída para postar numa fila, ou mandar para outra API, mockando todos os componentes externos, ou até mesmo a resposta de outra API no meio do caminho. A única exceção que considero neste cenário é o banco de dados, onde usar um banco em memória traz vantagens facilitando validações de resultado, mas de certa forma é um mock de banco pois não é o banco real.

Na minha cabeça, depois disso só enxergava testes end-to-end ou testes de UI, onde praticamente precisa subir uma infra inteira, sem considerar o valor ou possibilidade de ter algum tipo de teste entre este e o teste de integração definido antes.

Após algumas experiências recentes, percebi que as vezes precisamos testar nosso serviço rodeado de algumas partes reais do eco-sistema para ser mais assertivo, pois a resposta de uma fila de broker, ou o comportamento de um banco de dados real nem sempre é facilmente replicável. Podemos chamar eles de testes de sistema:

A vantagem de ter 10 milhões de pirâmides é que achar uma que encaixe no que estou explicando é fácil :D

O docker nos ajuda muito nesses cenários, pois podemos subir os contêineres de todos os componentes envolvidos antes de iniciar os testes e trabalhar com eles. Mas como gerenciamos eles? É aqui que entra o TestContainers.

TestContainers

É uma biblioteca Java que suporta testes JUnit, fornecendo instâncias ​​de bancos de dados comuns, navegadores Selenium, ou qualquer outra coisa que possa ser executada em um contêiner Docker.

Finais de janeiro escrevi um artigo sobre testes, focando especificamente na separação da execução usando JUnit5 (reparem que foi antes da quarentena, saudades dessa época!). Usei o mesmo repositório para adicionar esses testes:

>git clone https://github.com/iundarigun/sample-testing

Vamos adicionar a dependência do TestContainers no gradle:

// Some configurations
val testContainersVersion = "1.13.0"
dependencies {
// other dependencies
testImplementation("org.testcontainers:testcontainers:$testContainersVersion")
testImplementation("org.testcontainers:junit-jupiter:$testContainersVersion")
}
// Some configurations

Iniciando com o básico

Para começar, um caso idiota para entender como funciona. Vamos fazer um teste que levanta um contêiner que contém uma API:

Basicamente, estamos :

  • Declarando a classe para rodar o contêiner com a anotação TestContainers
  • Criando uma variável anotada com Container do tipo GenericContainer, indicando imagem iundarigun/mock-ws, que é um WebService Mock para facilitar os testes (eu também vejo a ironia disso se você leu a introdução…)
  • Indicamos que portas devem ser expostas
  • Solicitamos para esperar que a contêiner responda 200 às requisições HTTP antes de continuar
  • Assim que o teste rodar, o contêiner iniciará

O contêiner sobe e faz o mapeamento da porta exposta numa porta disponível aleatória. No test, consultamos ela para poder usa-la quando chamar a API.

Nota: Declaramos o contêiner como companion object porque queremos que inicie só uma vez para todos os testes da classe. Se fizer sentido iniciar o contêiner limpo para cada teste, é só declarar como um atributo da classe.

Contêiner no teste de integração

Usei o exemplo só para simplificar o uso inicial, mas pensando em teste unitário faz mais sentido usar o MockK ou Mockito do que usar um contêiner, porque nesse caso deixa de ser um teste unitário.

Antes de passar a teste de sistema, vamos só adicionar um contêiner no nosso teste de integração. Nossa aplicação consulta uma API externa antes de cadastrar um Employee, para validar se o documento do Employee tem alguma suspeita de fraude. Vamos levantar um contêiner para poder rodar os testes. O ideal é que esse contêiner contenha a aplicação real, mas como não criei ela, estou usando o mock-ws (oh a ironia por ai de novo).

Temos duas coisas diferentes na declaração do contêiner com o exemplo anterior:

  • A primeira é que fizemos o binding da porta de forma manual. Isso é porque o Feign precisa da url na hora do contexto do Spring iniciar, o que acontece antes de criar o contêiner, assim que não podemos deixar ela aleatória.
  • A segunda é o mapeamento de volume. Para poder controlar as respostas do server, deixei os arquivos de configuração no classpath, então mapeamos o volume para poder ser acessível desde dentro do contêiner.

Claro que ainda não é exatamente o que chamei de teste de sistema. Precisamos de um banco postgres real e não um banco em memória como estava até agora.

Testes de sistema

Para realizar o teste de sistema, precisamos um contêiner com o banco. Reparem que a solução anterior não vai atender. O banco de dados é mandatório para a aplicação subir, mas o TestContainers do jeito que vimos até agora só sobe após o contexto iniciar.

Vamos criar uma classe de configuração (dentro do módulo de testes) para o banco iniciar junto com a aplicação:

Que novidades temos na declaração do contêiner:

  • Adicionamos variáveis de ambiente para especificar o user e password do postgres
  • Adicionamos os arquivos de schema e data iniciais no contêiner para criação do banco e população dos dados iniciais. Poderíamos ter feito isso com volumes como no caso anterior. O comportamento em pipelines de volumes não é muito garantido mas, fora isso, o resultado deve ser o mesmo.
  • Configuramos o wait para esperar a porta estar disponível
  • Iniciamos o contêiner manualmente
  • A configuração é só aplicada para o profile de docker, então existe um application-docker.yml específico para os testes de sistema.

Alteramos a classe de teste para usar esse profile:

Agora sim, nosso teste virou um teste de sistema, até troquei a tag e criei mais uma task no gradle:

Conclusão

Falei um pouco sobre TestContainer e muito sobre minha ideia de testes, mas se alguém tiver alguma dúvida, podem me procurar e descobrimos juntos, pois a minha experiência com a ferramenta ainda não é tão grande.

Só algumas perguntas e respostas para finalizar:

  • Existe alguma outra forma de Wait? Sim, podemos usar log, https ou healthcheck do contêiner.
  • Suporta docker-compose? Sim, embora com algumas (pequenas) limitações. Estamos usando nos testes de nossas aplicações e funciona razoavelmente bem
  • Onde ficam os contêiners gerados? Eles são deletados depois de cada execução, evitando ficar com sujeira no disco e no contêiner (mesmo comportamento de rodar um contêiner com docker run - - rm …)
  • Há outras alternativas para contêiner? Conheço o Palantir, mas achei mais complicado de usar. Vocês conhecem outra? Conta para nós!

Referências

--

--

iundarigun
Dev Cave

Java and Kotlin software engineer at Clearpay