TestContainers (Roberto Bolgheroni e Lucas Quaresma)


Testes automatizados são essenciais para o desenvolvimento de software. Eles permitem a detecção precoce de erros e previnem regressões após cada alteração no código. Com a automação, é possível realizar verificações repetitivas e abrangentes sem a necessidade de intervenção manual, o que aumenta a eficiência e a consistência do processo de desenvolvimento. Dessa forma, os desenvolvedores podem implementar mudanças com mais segurança, sabendo que o sistema continua operando de maneira confiável.

Testes de integração são particularmente importantes para verificar a comunicação do sistema com dependências externas, como bancos de dados, serviços de terceiros e APIs. Enquanto os testes unitários focam em componentes isolados, os testes de integração asseguram que diferentes partes do sistema interajam corretamente entre si e com elementos externos. Essa abordagem ajuda a identificar e corrigir problemas de integração que podem não ser evidentes quando os componentes são testados isoladamente, garantindo uma operação harmoniosa e eficiente do sistema como um todo.

Para lidar com dependências externas nos testes de integração, duas abordagens principais podem ser utilizadas: mocks e dependências concretas. Mocks são simulações de componentes externos que permitem testar o comportamento do sistema em um ambiente controlado e previsível. Eles são úteis para isolar o sistema de variáveis externas e focar no comportamento interno. Por outro lado, utilizar dependências concretas envolve testar com instâncias reais das dependências do sistema, proporcionando uma validação mais realista e abrangente. Veja mais sobre essa distinção a seguir.

Mocks e Testes de Integração

editar

Mocks substituem o código de produção, diminuindo a proteção que os testes automatizados proporcionam sobre o funcionamento real do sistema. Quanto mais próximos os testes são do ambiente de produção, melhor é a cobertura que proporcionam. O uso excessivo de mocks pode gerar falsa confiança e falsos positivos. Um exemplo clássico é quando uma mudança na modelagem do banco de dados torna uma operação inválida. Caso o componente de código que interage com o banco (geralmente uma classe "Repositório") seja mockado, essa falha pode passar despercebida durante os testes.

Além disso, mocks tornam a bateria de testes mais "frágil" a refatorações. Sempre que a comunicação entre a dependência mockada e o componente sob teste mudar, pode ser necessário rever os mocks, pois eles podem gerar falsos negativos. Claro, nem sempre é possível substituir um mock por uma instância concreta. No entanto, principalmente quando a dependência é interna do projeto, como um componente de acesso a banco de dados interno, é boa prática utilizar uma implementação concreta nos testes de integração.

Utilizando Dependências Concretas

editar

Manter uma instância concreta de uma dependência para uso nos testes automatizados pode ser desafiador. Um banco de dados de testes precisa ser disponibilizado, preenchido com dados e deve refletir as mudanças na modelagem aplicadas no banco de produção. Uma alternativa comum é a utilização de bancos em memória, como H2. No entanto, utilizar um modelo de banco de dados para testes diferente do banco de produção pode gerar falsos negativos – funcionalidades específicas do H2 podem não existir ou não funcionar da mesma forma no Postgres, por exemplo.

Para diminuir essa complexidade, podemos utilizar a biblioteca TestContainers.

O que é TestContainers?

editar

TestContainers é uma biblioteca Java/Kotlin que facilita a execução de testes de integração usando contêineres Docker. Ele fornece uma maneira conveniente de iniciar contêineres Docker temporários durante a execução dos testes, permitindo que você teste suas aplicações em um ambiente controlado e isolado. O projeto disponibiliza instâncias leves, descartáveis e eficazes de bancos de dados, Selenium, Message Brokers ou quaisquer outras dependências que possam ser executadas em um contêiner Docker. Ele cuida de todas as fases dos contêineres Docker e se conecta com o JUnit, tornando o processo ainda mais simples. Seu foco principal é garantir que seus testes de integração se assemelhem o máximo possível ao ambiente de produção.


A seguir, encontra-se um tutorial para a aplicação de TestContainers em um projeto Kotlin.

Pré-requisitos

editar

Antes de começar, certifique-se de ter o Kotlin e um sistema de construção como o Maven/Gradle configurados em seu ambiente de desenvolvimento. Para esse tutorial, seguiremos com o Maven e Spring.

Passo 1: Configurando o Projeto

editar

Estamos utilizando um projeto já existente, configurado com Maven e Spring, então apenas adicionamos as seguintes dependências no arquivo pom.xml,

<dependency>
	<groupId>org.testcontainers</groupId>
	<artifactId>testcontainers</artifactId>
	<version>1.18.0</version> 
	<scope>test</scope>
</dependency>
<dependency>
	<groupId>org.testcontainers</groupId>
	<artifactId>mysql</artifactId>
	<version>1.18.0</version>
	<scope>test</scope>
</dependency>

Passo 2: Escrevendo o Teste

editar

Agora, vamos criar um teste de integração Kotlin para verificar a interação com o banco de dados MySQL usando o Testcontainers.


Primeiro, construímos uma classe DatabaseContainer para poder ter instancias da classe de testes, e poder aproveitar essa.

class DatabaseContainer private constructor() : MySQLContainer<DatabaseContainer>("mysql:8.1.0") {
    override fun start() {
        super.start()

        System.setProperty("spring.datasource.url", mySQLContainer!!.getJdbcUrl())
        System.setProperty(
            "spring.datasource.username",
            mySQLContainer!!.getUsername()
        )
        System.setProperty(
            "spring.datasource.password",
            mySQLContainer!!.getPassword()
        )
    }

    override fun stop() {
        // DO NOTHING
    }

    companion object {
        private var mySQLContainer: DatabaseContainer? = null

        val instance: DatabaseContainer?
            get() {
                if (mySQLContainer == null) {
                    mySQLContainer = DatabaseContainer()
                        .withDatabaseName("test")
                        ?.withUsername("test")
                        ?.withPassword("test")
                }
                return mySQLContainer
            }
    }
}

Agora, para testar o comportamento dessa classe:

@Service
class TaskService (private val taskRepository: TaskRepository, private val taskListRepository: TaskListRepository) {


    fun updateData(taskId: Long, updateTaskDto: UpdateTaskDto): Task {
        var queryResult = taskRepository.findById(taskId)
        if(queryResult.isEmpty) {
            throw IllegalArgumentException("Task Not Found");
        }

        var taskFound = queryResult.get()
        taskFound.title = updateTaskDto.title
        taskFound.description = updateTaskDto.description

        return taskRepository.save(taskFound)
    }

    // ...
}

Iremos construir os testes de integração, para verificar a funcionalidade do método updateData() :

@SpringBootTest
class TaskServiceIntegrationTests @Autowired constructor(private val taskService: TaskService, private val taskRepository: TaskRepository){

    @BeforeEach
    @Transactional
    fun cleanUp() {
        taskRepository.deleteAll()
    }

    @Test
    fun `should throw exception when task doesn't exist`() {
        try {
            taskService.updateData(1, UpdateTaskDto("", ""))
            fail("Expected an IllegalArgumentException to be thrown")
        } catch (e: Exception) {
            assertTrue(e is IllegalArgumentException)
        }
    }

    @Test
    fun `should update data when task exists`() {
        // arrange
        var task: Task = taskRepository.save(Task("oldTitle", "oldDescription", 1))
          
        // act
        task = taskService.updateData(1, UpdateTaskDto("newTitle", "newDescription"))
        assertEquals(task.title, "newTitle")
        assertEquals(task.description, "newDescription")
    }

    companion object {
        @JvmField
        @ClassRule
        var mySQLContainer: DatabaseContainer? = DatabaseContainer.instance

        @JvmStatic
        @BeforeAll
        fun setUp(): Unit {
            mySQLContainer!!.start()
        }
    }
}

Conclusão

editar

O uso do TestContainers em testes de integração proporciona uma maneira eficiente e confiável de testar dependências externas em um ambiente controlado, semelhante ao de produção. A biblioteca facilita a criação e o gerenciamento de contêineres Docker temporários, garantindo testes mais realistas e precisos. Implementar TestContainers com Kotlin, Maven e Spring melhora a qualidade do software, reduzindo erros em produção e aumentando a confiança nas interações do sistema. Adotar essa prática é um passo essencial para uma infraestrutura de testes robusta e eficaz.

Referências

editar

https://java.testcontainers.org/

https://www.baeldung.com/docker-test-containers

https://kotest.io/docs/extensions/test_containers.html

https://medium.com/@dpeachesdev/using-testcontainers-with-kotlin-springboot-be248f33a3cc