Criando Stubs com every { ... } returns
Ao concluirmos esta aula, nós:
- Compreenderemos como destacar explicitamente no código que o dublê de teste criado é um Stub (
TestDoubles.stub()). - Seremos capazes de configurar um stub para retornar um objeto ou lançar uma exceção quando seu método for invocado (
every { ... } returns ...ethrows). - Saberemos como configurar os parâmetros esperados de um método utilizando Argument Matchers (
eq(),any(),ofType()). - Saberemos como acessar os argumentos reais passados para o método stubado usando blocos
answers { ... }.
[!NOTE] Esta aula assume que:
Destacando o Tipo de um Stub (TestDoubles.stub())
Como vimos na aula anterior, nem o MockK nem o Mockito diferenciam dummies, stubs e mocks na hora da instanciação: a função mockk() (ou Mockito.mock()) serve para criar todos os três, e qualquer dublê criado com ela pode atuar como um mock.
Na aula anterior, criamos o objeto utilitário TestDoubles com um método fábrica chamado dummy(). Aprendemos que explicitar o tipo do dublê de teste no método fábrica esclarece nossas intenções e reduz mal-entendidos na equipe.
Por esse motivo, adicionaremos agora um método fábrica chamado stub() ao nosso objeto TestDoubles. Esse método delega a criação para o MockK e retorna o dublê de teste criado. Após adicionarmos esse método a TestDoubles, seu código-fonte fica assim:
|
|
[!NOTE] Por que
stub()usarelaxed = trueaqui? No Mockito clássico, um stub criado comMockito.mock(stubClass)retorna valores vazios (null,0,false) caso algum método não configurado seja chamado. Na próxima aula, compararemos em detalhes os Stubs Relaxados com os Stubs Estritos (strictStub(), que equivale aomockk<T>()padrão semrelaxed = true)!
Agora podemos criar stubs sem invocar mockk() diretamente em nossas classes de teste. Antes de colocarmos a mão na massa, vamos dar uma rápida olhada na interface que será substituída pelo nosso stub.
Introdução ao Sistema Sob Teste (SUT)
O nosso Sistema Sob Teste possui uma única dependência chamada TaskRepository. Essa interface declara quatro métodos descritos a seguir:
- O método
count()retorna o número total de tarefas encontradas no banco de dados (Long). - O método
delete()exclui a tarefa passada como parâmetro. Esse método não retorna nada (Unit) e lança umaNotFoundExceptioncaso a tarefa excluída não seja encontrada no banco de dados. - O método
findById()busca a tarefa cujo ID é passado como parâmetro e retorna umOptional<Task>contendo a tarefa encontrada (ou umOptionalvazio caso nenhuma tarefa seja encontrada). - O método
save()salva a tarefa passada como parâmetro no banco de dados e retorna a tarefa salva.
O código-fonte da interface TaskRepository é o seguinte:
|
|
Vamos avançar e descobrir como podemos configurar o comportamento dos métodos do nosso stub com o MockK (e seu paralelo com o BDDMockito).
Configurando o Comportamento de Métodos com o MockK
A primeira coisa que precisamos fazer é criar nosso stub. Como os dublês de teste criados com o MockK não são stateless, devemos criar um novo dublê de teste antes que cada método de teste seja invocado, utilizando um método @BeforeEach.
Depois de adicionarmos esse método de setup à nossa classe de teste e criarmos um stub de TaskRepository invocando stub(), o código-fonte inicial da nossa classe de teste fica assim:
|
|
Vamos agora configurar diferentes comportamentos em nossos stubs!
1. Retornando Valores e Objetos sem Parâmetros de Método
Se quisermos configurar o valor ou objeto retornado usando o MockK quando o método stubado não possui parâmetros, seguimos estes dois passos:
- Especificamos a chamada do método stubado dentro de um bloco
every { ... }(do pacoteio.mockk.every). - Configuramos o valor ou objeto retornado encadeando a função infixa
returnse passando o valor desejado.
Após configurarmos o método count() da interface TaskRepository para retornar 1L quando for invocado, o código-fonte do nosso método de teste fica assim:
|
|
[!NOTE] Paralelo com BDDMockito e Mockito clássico No
BDDMockito, configurávamos essa mesma chamada comgiven(repository.count()).willReturn(1L). Na API original do Mockito, usávamoswhen(repository.count()).thenReturn(1L). No MockK,every { repository.count() } returns 1Lé 100% seguro em tempo de compilação e lê-se como uma frase natural.
2. Retornando Valores e Objetos com Parâmetros de Método
Se quisermos configurar o valor ou objeto retornado quando o método stubado recebe parâmetros, seguimos estes passos:
- Especificamos a chamada do método stubado dentro do bloco
every { ... }, passando o argumento esperado para o método. - Configuramos o valor ou objeto retornado com
returns.
Após configurarmos o método findById() da interface TaskRepository para retornar um Optional não vazio quando for invocado com o parâmetro 1L, o código-fonte do nosso método de teste fica assim:
|
|
3. Utilizando Argument Matchers (eq, any, ofType)
Quando passamos um valor diretamente para a chamada do método dentro de every { repository.findById(1L) }, o MockK (assim como o Mockito) verifica se o argumento real é igual ao esperado utilizando o método equals() (== em Kotlin).
Embora isso seja suficiente para a maioria das situações, às vezes precisamos de flexibilidade extra. Quando isso acontece, podemos especificar o parâmetro esperado utilizando um Argument Matcher passado como argumento para o método stubado.
O pacote io.mockk fornece diversas funções de argument matchers (equivalentes à classe org.mockito.ArgumentMatchers do Mockito). Vamos analisar dois exemplos que demonstram como eles funcionam:
Primeiro Exemplo: Matcher de igualdade explícita (eq())
Se quisermos configurar o método findById() da interface TaskRepository para retornar um Optional não vazio quando for invocado com um parâmetro igual a 1L usando um matcher explícito, usamos a função eq(1L) (equivalente a Matchers.eq(1L) no Mockito):
|
|
Segundo Exemplo: Matcher para qualquer valor do tipo (any())
Se quisermos configurar o método findById() da interface TaskRepository para retornar um Optional não vazio quando for invocado com qualquer valor Long como parâmetro, utilizamos o matcher any() (ou any<Long>(), equivalente ao Matchers.anyLong() do Mockito):
|
|
[!WARNING] Atenção ao misturar valores literais e Matchers No Mockito clássico, se um método possui múltiplos parâmetros e você usa um argument matcher em um deles, é obrigatório usar argument matchers (como
eq(...)) em todos os demais parâmetros. O MockK é mais inteligente e frequentemente permite misturar literais com matchers, mas usareq(...)explicitamente ao combinar múltiplos matchers no mesmo método continua sendo uma excelente prática de clareza!
4. Lançando Exceções (throws)
Se quisermos configurar uma exceção para ser lançada quando um método stubado for invocado, veja como o MockK simplifica nossa vida em comparação ao Mockito:
Primeiro Caso: Método que retorna valor (findById)
Para configurar o método findById() para lançar uma NotFoundException quando for invocado com o parâmetro 1L:
- Especificamos a chamada do método dentro de
every { repository.findById(1L) }. - Configuramos a exceção lançada encadeando
throwse passando a instância da exceção (NotFoundException()).
(No BDDMockito, fazíamos given(repository.findById(1L)).willThrow(new NotFoundException()); no Mockito original, when(repository.findById(1L)).thenThrow(new NotFoundException())).
O código-fonte do nosso método de teste fica assim:
|
|
[!CAUTION] Jamais quebre o contrato da sua API ao configurar um Stub! Observe que, pelo contrato real da interface
TaskRepository, o métodofindById()não lançaNotFoundException— quando a tarefa não é encontrada, ele retornaOptional.empty().Usamos
findById()acima apenas para demonstrar a sintaxe dethrowsem métodos com retorno. Em seus testes reais, nunca configure um stub para lançar uma exceção que o método real jamais lançaria, pois isso criaria um teste mentiroso que viola o contrato da sua API!
Segundo Caso: Método sem retorno (Unit / void — delete)
No Mockito clássico (Java), métodos void não podiam ser passados dentro de given(...) ou when(...) porque o compilador Java não aceita void como argumento de método! Isso obrigava o desenvolvedor a usar uma sintaxe invertida no Mockito:
BDDMockito:willThrow(new NotFoundException()).given(repository).delete(deleted);Mockito:doThrow(new NotFoundException()).when(repository).delete(deleted);
No MockK em Kotlin, como every { ... } recebe um bloco lambda (e em Kotlin toda função sem retorno explícito retorna o objeto singleton Unit), a sintaxe é 100% idêntica para métodos com ou sem retorno!
Veja como configuramos o método delete() para lançar NotFoundException:
|
|
5. Configurando uma Resposta Dinâmica (answers { ... })
Embora configurar o retorno fixo com returns seja suficiente para a maioria dos casos de uso, às vezes precisamos de mais poder — por exemplo, quando queremos acessar o objeto real que foi passado como parâmetro para o método stubado a fim de construir o objeto de retorno dinamicamente.
No Mockito clássico, resolvíamos isso instanciando uma classe anônima Answer<T> dentro de willAnswer(...) ou thenAnswer(...) e fazendo cast de invocation.getArguments()[0].
No MockK, fazemos isso de forma muito mais limpa encadeando um bloco answers { ... } logo após every { ... }! Dentro do bloco answers, temos acesso direto aos argumentos passados através de firstArg<T>(), secondArg<T>() ou arg<T>(index):
- Configuramos o método stubado com
every { repository.findById(1L) }. - Encadeamos o bloco
answers { ... }. - Acessamos o primeiro argumento como
LongusandofirstArg<Long>()(ouinvocation.args[0] as Long) e retornamosOptional.of(expected)se o parâmetro for igual a1L, ouOptional.empty()caso contrário.
Após configurarmos o comportamento do método findById() usando answers, o código-fonte do método de teste fica assim:
|
|
Colocando a Teoria em Prática
Agora vamos usar nossas novas habilidades para escrever um teste unitário real para o método createCopyOf() da classe TaskService. Esse método foi implementado seguindo estes seis passos:
- Verifica se ambos os parâmetros do método não são
null. - Busca a tarefa de origem no banco de dados (
repository.findById(sourceTaskId)). - Se a tarefa de origem não for encontrada, lança uma
NotFoundException. - Cria um novo objeto
Task, copiando o título e a descrição da tarefa de origem e obtendo o ID do criador a partir do objetoLoggedInUserrecebido como parâmetro. - Salva o novo objeto
Taskcriado no banco de dados (repository.save(created)). - Retorna o objeto
Tasksalvo.
O código-fonte completo da classe TaskService é o seguinte:
|
|
Precisamos escrever um teste unitário que garanta que o método createCopyOf() retorna as informações da tarefa criada quando a tarefa de origem é encontrada no banco de dados. Para isso, configuramos o nosso stub TaskRepository em dois passos:
- Configuramos o stub
TaskRepositorypara retornar a tarefa de origem (Optional.of(sourceTask)) quando seu métodofindById()for invocado com o IDSOURCE_TASK_ID. - Configuramos o stub
TaskRepositorypara retornar a tarefa salva com um novo ID (NEW_TASK_ID) quando seu métodosave()for invocado recebendo qualquer objeto do tipoTask(ofType(Task::class)ouany<Task>(), equivalente aoisA(Task.class)do Mockito), copiando os dados do argumento recebido viaanswers { ... }.
Após configurarmos o stub TaskRepository, o código-fonte completo da nossa classe de teste fica assim:
|
|
Este exemplo prático demonstra perfeitamente por que o bloco answers { ... } (equivalente ao Answer<T> do Mockito) é tão útil: como o objeto Task passado para repository.save(created) é instanciado dentro do método createCopyOf(), a única maneira de o nosso stub simular o banco de dados atribuindo NEW_TASK_ID e preservando exatamente os campos (creator, description, title) que o TaskService preencheu na nova tarefa é inspecionando o parâmetro real firstArg<Task>() recebido pelo método save()!
Resumo
Nesta aula, aprendemos cinco pontos fundamentais:
- Devemos criar nosso stub antes que cada método de teste seja invocado (
@BeforeEach). - Configuramos o comportamento do nosso stub utilizando
every { ... } returns ...ouevery { ... } throws ...no MockK (equivalentes agiven(...).willReturn(...)/when(...).thenReturn(...)no Mockito). - Por padrão, o MockK (assim como o Mockito) compara os parâmetros esperados com os parâmetros reais utilizando o método
equals(). - Podemos flexibilizar a verificação dos parâmetros do método utilizando argument matchers como
eq(),any()eofType(). - Se precisarmos acessar o argumento real passado para o método stubado no momento de construir sua resposta, utilizamos o bloco
answers { firstArg<T>() ... }.