Saltar para o conteúdo

TestContainers (Roberto Bolgheroni e Lucas Quaresma)

Fonte: Wikiversidade


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 | editar código-fonte]

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 | editar código-fonte]

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 | editar código-fonte]

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 | editar código-fonte]

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 | editar código-fonte]

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 | editar código-fonte]

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()
        }
    }
}

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.

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