Introdução a Stubs
Ao concluirmos esta aula, nós:
- Saberemos o que é um Stub.
- Compreenderemos como um Stub funciona por dentro.
- Compreenderemos quando devemos utilizar Stubs em nossos testes unitários.
[!NOTE] Esta aula assume que:
O Que É um Stub?
Um Stub é um objeto utilizado para substituir uma dependência externa do Sistema Sob Teste (SUT). Todo stub deve cumprir quatro requisitos fundamentais:
- Um stub deve fornecer a mesma API que a dependência externa substituída: Isso significa que, se a dependência externa for uma classe, nosso stub deve estendê-la e sobrescrever seus métodos. Por outro lado, se a dependência substituída implementar uma interface, nosso stub deve implementar essa mesma interface.
- Devemos ser capazes de configurar a resposta retornada quando um método do stub é invocado: Isso significa que podemos configurar o stub para retornar um objeto específico ou lançar uma exceção.
- Um stub retorna a resposta configurada todas as vezes em que seu método é invocado.
- Um stub não fornece nenhuma maneira de verificar as interações que acontecem entre o Sistema Sob Teste e o próprio stub.
Agora demonstraremos essas regras na prática criando um stub simples passo a passo. Vamos começar conhecendo a interface de dependência do nosso Sistema Sob Teste.
Introdução ao Sistema Sob Teste (SUT)
O nosso Sistema Sob Teste possui uma única dependência chamada TaskFinder. Essa interface declara um único método chamado findById(), que recebe o ID da tarefa solicitada como parâmetro e retorna o objeto Task encontrado.
O código-fonte da interface TaskFinder em Kotlin é o seguinte:
|
|
Vamos descobrir como podemos criar um stub simples para substituir a interface TaskFinder.
Criando um Stub Simples
Podemos criar um stub capaz de substituir um objeto TaskFinder real seguindo estes três passos:
Passo 1: Criar a classe TaskFinderStub
Primeiro, criamos uma classe pública e final (o padrão em Kotlin) que implementa a interface TaskFinder. É uma boa prática que nossa classe de stub seja final porque isso impede que outros desenvolvedores a estendam e criem hierarquias confusas de dublês de teste.
O código-fonte inicial da nossa classe de stub fica assim:
|
|
[!TIP] Nomenclatura e Visibilidade de Classes de Stub Ao nomear classes de stub manuais, uma convenção clara é acrescentar o sufixo
Stubao nome do tipo do objeto “real”. Como nossa classe de stub é utilizada para substituir objetosTaskFinder, seu nome deve serTaskFinderStub.Além disso, este exemplo assume que precisaremos usar essa classe de stub em múltiplas classes de teste que não estão necessariamente no mesmo pacote. Se ela for usada apenas localmente, podemos restringir sua visibilidade com
internalouprivate.
Passo 2: Configurar o objeto Task retornado
Segundo, precisamos permitir a configuração do objeto Task que será retornado pelo stub. Fazemos isso seguindo estes passos:
- Adicionamos uma propriedade
private val found: Task?à nossa classe de stub. - Definimos o valor dessa propriedade utilizando injeção via construtor.
Depois de adicionarmos o código que nos permite configurar o objeto Task retornado, o código-fonte da nossa classe de stub fica assim:
|
|
Passo 3: Implementar o método findById()
Terceiro, implementamos o método findById() da interface TaskFinder. Nossa implementação deve simplesmente retornar o objeto Task armazenado na propriedade found.
Após implementarmos o método findById(), o código-fonte completo da nossa classe TaskFinderStub fica assim:
|
|
Criamos nosso primeiro stub simples! A seguir, escreveremos um método de teste que demonstra como o nosso stub funciona.
Utilizando o Nosso Stub
Se quisermos utilizar um stub em nossos testes, devemos lembrar que stubs não são stateless (eles carregam o estado da resposta configurada para aquele cenário). Isso significa que devemos criar uma nova instância do stub antes da execução de cada método de teste. Podemos fazer isso adicionando um método de configuração (@BeforeEach) à nossa classe de teste e instanciando nosso stub dentro desse método.
Depois de adicionarmos esse método de setup à nossa classe de teste, o código-fonte fica assim:
|
|
Agora podemos escrever um método de teste que utiliza o nosso stub seguindo estes dois passos:
- Invocamos o método
findById()da interfaceTaskFinder. - Verificamos se o objeto
TaskFinderStubretorna o objetoTaskesperado.
O código-fonte completo do nosso teste fica assim:
|
|
Executando o Teste no Gradle
Ao executarmos o teste unitário com Gradle (./gradlew test --tests "TaskFinderStubTest" --info), capturamos a seguinte evidência real de execução:
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
The client will now receive all logging from the daemon (pid: 17201, address: [fe80::1%1:54335]).
Gradle Daemon started in 1 s 012 ms
Starting Build
Settings evaluated using settings file '/home/wc/test-with-spring/code/01-starter-junit6/settings.gradle.kts'.
Projects loaded. Root project using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Included projects: [root project 'starter-junit6']
> Configure project :
Evaluating root project 'starter-junit6' using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Using Kotlin Gradle Plugin 2.4.21
Using Java Toolchain: Temurin 21.0.6 (/home/wc/.sdkman/candidates/java/21.0.6-tem)
Spring Boot 4.1.2 plugin applied.
All projects evaluated.
Selected primary task 'test' from project :
Tasks to be executed: [task ':compileKotlin', task ':compileJava', task ':processResources', task ':classes', task ':compileTestKotlin', task ':compileTestJava', task ':processTestResources', task ':testClasses', task ':test']
Tasks that were skipped: [task ':compileJava', task ':compileTestJava']
> Task :compileKotlin UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin
Compiling with Kotlin compiler version 2.4.21 (JVM target 21)
Compiling Kotlin files from [/home/wc/test-with-spring/code/01-starter-junit6/src/test/kotlin]
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
Starting process 'Gradle Test Executor 1'. Working directory: /home/wc/test-with-spring/code/01-starter-junit6 Command: /home/wc/.sdkman/candidates/java/21.0.6-tem/bin/java -ea -cp ...
Successfully started process 'Gradle Test Executor 1'
com.testwithspring.starter.testdoubles.TaskFinderStubTest > findById deve retornar a tarefa configurada PASSED
TaskFinderStubTest > 1 test completed, 0 failed, 0 skipped
Finished generating test XML results (0.007 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/test-results/test
Generating HTML test report...
Finished generating test html results (0.012 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test
BUILD SUCCESSFUL in 1s
5 actionable tasks: 2 executed, 3 up-to-date
Este exemplo demonstrou na prática que um Stub é um dublê de teste (test double) que:
- Nos permite configurar a resposta que será retornada quando seu método for invocado.
- Retorna a mesma resposta configurada todas as vezes em que seu método é invocado.
- Não fornece nenhuma maneira de verificar as interações que aconteceram entre o Sistema Sob Teste e o stub em questão.
Esses princípios continuam 100% válidos mesmo quando criamos nossos stubs utilizando uma biblioteca de mocking como o MockK! Vamos avançar e identificar as situações em que o uso de stubs faz sentido.
Quando Devemos Usar Stubs?
Normalmente, um Sistema Sob Teste possui dois tipos de dependências externas:
- Dependências que consultam informações utilizadas pelo Sistema Sob Teste (Fontes de Dados / Queries): Essas dependências leem informações do banco de dados utilizado, buscam dados de uma API externa, consultam o relógio do sistema e assim por diante.
- Dependências que disparam uma ação que possui um “efeito colateral” (Side Effect / Commands): Essas dependências salvam ou excluem informações no banco de dados, enviam uma requisição HTTP de mutação para uma API externa, publicam um evento na fila de mensageria e assim por diante.
Como não podemos verificar se uma interação aconteceu entre o Sistema Sob Teste e um stub, devemos utilizar stubs nas situações em que não precisamos verificar se o Sistema Sob Teste realmente invocou um método do stub.
Se compararmos esse argumento com os dois tipos de dependências externas do Sistema Sob Teste, percebemos que:
- Se a dependência for apenas uma fonte de dados (datasource), não precisamos verificar as interações que acontecem entre o Sistema Sob Teste e a dependência externa, pois podemos usar asserções de estado/retorno sobre a saída do próprio Sistema Sob Teste para garantir que ele funcionou como esperado (se o SUT não tivesse consultado a fonte de dados, ele jamais conseguiria devolver o resultado correto).
- Se a dependência disparar uma ação que produz um “efeito colateral”, precisamos verificar se o Sistema Sob Teste invocou o método correto da dependência externa (ou se deixou de invocá-lo quando não deveria). Se não fizermos essa verificação, não teremos como comprovar que o Sistema Sob Teste realizou a ação esperada.
Em outras palavras: devemos substituir uma dependência externa do Sistema Sob Teste por um Stub sempre que essa dependência externa atuar apenas como uma fonte de dados (consulta).
Resumo
Nesta aula, aprendemos quatro conceitos fundamentais:
- Um Stub é um objeto que substitui uma dependência externa do Sistema Sob Teste.
- Um Stub retorna a resposta configurada todas as vezes em que seu método é invocado.
- Um Stub não fornece nenhuma maneira de verificar interações ocorridas entre o Sistema Sob Teste e o próprio stub.
- Devemos substituir uma dependência externa do Sistema Sob Teste por um Stub quando essa dependência externa for apenas uma fonte de consulta de dados (datasource).