Testes de integreção X Testes de unidade: um confronto desigual

João Paulo Oliveira - Oct 24 - - Dev Community

Testes de integração é uma etapa essencial dos testes de software em que determina se módulos desenvolvidos apartados funcionarão corretamente quando executados em conjunto. Sucede aos testes de unidades que tem por objetivo validar módulos individualmente.

Seu objetivo é constatar que a integração com demais módulos de uma aplicação atende os requisitos esperados a fim de encontrar falhas nessa comunicação e mitigar possíveis erros que ocorreriam no ambiente produtivo.

Imagine uma aplicação que armazena dados em um banco, envia mensagens a um broker de mensageria e faz solicitações na rede para algum serviço.

Nos testes de integração é preciso validar se a comunicação com esses componentes foram bem sucedidas, se ao solicitar a persistência de uma informação a um banco de dados ocorreu com sucesso, se ao enviar mensagens para um broker de mensageria, por exemplo Kafka, foi entregue ou até mesmo saber como a aplicação irá se comportar fazendo requisições na rede. E claro, não somente isso, mas também assegurar que a lógica empregada no serviço foi realizada conforme o esperado!

Note, nos testes de integração é validado o comportamento da aplicação juntamente aos módulos integrados, seja ele um comportamento esperado ou não, mas somente assim para sabermos o quão nossas aplicações estão aptas para rodar em ambiente produtivo com mais segurança.

Vamos a um caso de uso:

@RestController
@RequestMapping("/api")
class ObterPedidoController(
    private val pedidoRepostiory: PedidoRepostiory,
    private val notificacao: Notificacao
) {

    @GetMapping("/pedidos/{pedidoId}/usuarios/{usuarioId}")
    fun obterPedido(
        @PathVariable usuarioId: Long,
        @PathVariable pedidoId: Long
    ): ResponseEntity<Any> {

        val pedido =  pedidoRepostiory
            .findByIdAndUsuarioId(pedidoId, usuarioId)
            .getOrElse {
                return ResponseEntity.status(404).body("Pedido nao encontrado")
            }

        try {
            notificacao.enviar(pedido)
        } catch (ex: Exception) {
            println(ex.stackTrace)
            return ResponseEntity.internalServerError().build()
        }

        val response = PedidoResponse.criar(pedido)

        return ResponseEntity.ok(response)
    }
}
Enter fullscreen mode Exit fullscreen mode

Conseguimos extrair algumas informações importantes do código acima:

1 - É uma API REST que tem somente a função de obter pedido.

2 - A classe PedidoRepostiory é um ponto de interação com um banco de dados.

3 - A classe Notificacao é um de um ponto de interação para envios de mensagens.

4 - É realizado uma busca à base de dados e caso não encontrado é lançada uma exceção.

5 - É realizado o envio de uma mensagem para um serviço de Notificação.

6 - Retorna o pedido encontrado ao solicitante.

Perceba que não há regras de negócio extensas, não há validações em excesso, não há diversas lógica e caminhos, somente busca um pedido na base, envia notificação e retorna pedido, simples. Mas, se mal testado pode vir a ser problemático.

A pergunta que devemos fazer é: como testamos esse código?

Vamos começar pelo o teste de unidade:

@ExtendWith(MockitoExtension::class)
class ObterPedidoControllerUnitTest {

    @Mock
    private lateinit var pedidoRepostiory: PedidoRepostiory

    @Mock
    private lateinit var notificacao: Notificacao

    @InjectMocks
    private lateinit var controllerTest: ObterPedidoController

    @Test
    @DisplayName("Deve retonar um pedido e deve enviar uma notifição")
    fun t1() {
        // cenario
        val usuario = criaUsuario().apply { id = 1L }
        val pedido = criaPedido(usuario).apply { id = 1L }
        val pedidoResponse = PedidoResponse.criar(pedido)

        `when`(pedidoRepostiory.findByIdAndUsuarioId(pedido.id!!, usuario.id!!)).thenReturn(Optional.of(pedido))

        // acao
        val response = controllerTest.obterPedido(pedido.id!!, usuario.id!!)

        // validar
        assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
        assertThat(response.body).isEqualTo(pedidoResponse)
        verify(notificacao, times(1)).enviar(pedido)
    }
}
Enter fullscreen mode Exit fullscreen mode

Ao analisar esse testes temos alguns problemas:

O teste realizado acima utiliza a técnica de “mockar” o comportamento de alguns objetos ficando a cargo do desenvolvedor decidir ao mesmo como ele deve se comportar e também o que retornar.

Testes de unidade não executam o contexto de uma aplicação Spring, e neste caso existe uma interação com o banco de dados que se porventura a consulta estiver errada não saberemos e teremos um falso positivo do teste.

Como o teste é executado de maneira isolada e sem considerar o ambiente ao seu redor nunca saberemos se a comunicação com um broker de mensagem ocorreu com sucesso, se ele está aceitando as mensagens. Ou melhor, não saberemos como a aplicação irá se comportar caso as dependências externas estejam indisponíveis.

Ou seja, neste caso específico temos um teste que não testa NADA!

Vamos mudar a abordagem e olharmos para um teste de integração:

@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
@Import(ConfiguracaoKafkaTest::class)
@DirtiesContext(methodMode = BEFORE_METHOD)
class ObterPedidoControllerTest : TestBased() {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var pedidoRepostiory: PedidoRepostiory

    @Autowired
    private lateinit var usuarioRepostiory: UsuarioRepostiory

    @Autowired
    lateinit var consumer: Consumer<String, String>

    @BeforeEach
    fun beforeEach() {
        val usuario = criaUsuario()
        usuarioRepostiory.saveAndFlush(usuario)

        val pedido = criaPedido(usuario)
        pedidoRepostiory.saveAndFlush(pedido)
    }

    @AfterEach
    fun afterEach() {
        pedidoRepostiory.deleteAll()
        usuarioRepostiory.deleteAll()
    }

    @Test
    @DisplayName("Deve retonar um pedido e deve enviar uma mensagem ao topic do kafka")
    fun t1() {
        // cenario
        val request = get("/api/pedidos/1/usuarios/1")

        // acao
        val response = mockMvc.perform(request)

        // consome as mensagens
        val records = KafkaTestUtils.getRecords(consumer, Duration.ofSeconds(2))

        // validar
        response.andExpectAll(
            status().isOk,
            jsonPath("$.nomeItem").value("Feijao tropeiro"),
            jsonPath("$.quantidade").value(5)
        )
        assertThat(records).hasSize(1)
    }

    private fun criaUsuario() = Usuario("Maria", "das Dores", "maria@dores.com")
    private fun criaPedido(usuario: Usuario) = Pedido("Feijao tropeiro", 5, usuario)
}
Enter fullscreen mode Exit fullscreen mode

A primeira coisa que notamos é que o teste de integração é mais complexo para escrever e isso é um fato.

Diferente do teste anterior, este teste inicia o contexto da aplicação através da anotação @SpringBootTest e permite com que a mesma se conecte às dependências externas como banco de dados e o kafka. Ponto positivo.

No topo do teste iremos notar duas rotinas que cria e deleta pedidos e usuários da base de dados e executa antes e depois de qualquer caso de teste. Temos uma integração real com um banco.

Ao olhar o caso de teste vemos que a validação ocorre em cima do comportamento da API que está sendo testada. Espera-se um código de status OK, um retorno com valores específicos e que haja uma mensagem no tópico. De fato, o que nossa API realiza.

Mas o que tem de diferente para o teste anterior?

1 - Se porventura alguma configuração estiver errada, seja do banco de dados, do serviço de mensagem ou da aplicação, o erro é reportado.

2 - Se a consulta do banco estiver errada saberemos pois esperamos que seja retornado dados com valores específicos. Caso não exista, será retornado um código de status NOT FOUND.

3 - Caso a comunicação com as dependências externas venham falhar o próprio teste irá reportar, pois esperasse um código de status OK.

4 - O teste valida se a mensagem foi enviada ao broker, caso contrário o teste falhará e saberemos que a configuração está errada.

5 - Não há comportamento falso ou “mockado”.

Enfim, trazem mais segurança e assertividade!

Para finalizar, os testes de integração são essenciais para garantir o bom funcionamento da aplicação em ambientes reais, uma vez que validam a interação entre diferentes componentes, como banco de dados, brokers de mensageria e outros serviços externos. Diferentemente dos testes unitários, que isolam o comportamento dos objetos e podem gerar falsos positivos, os testes de integração verificam a aplicação em seu contexto completo, apontando erros de configuração ou falhas de comunicação com dependências externas. Embora sejam mais complexos de escrever, esses testes oferecem maior segurança ao validar o comportamento real da aplicação, prevenindo problemas que poderiam ocorrer em produção.

Ficou interessado pelo o conteúdo, deseja realizar testes de integraçãom com Kafka e não sabe como? Visite o este atigo abaixo:
Escrevendo Testes de Integração para Producers Kafka Com Spring Boot e EmbeddedBroker

. . . .