Quem já escreve testes há algum tempo deve ter percebido que, para cada funcionalidade da aplicação que queremos testar, temos que escrever ao menos um cenário que cobre o “caminho feliz” e um ou mais cenários que cobrem os “caminhos infelizes”. Escrevemos muito mais código na suíte de testes em comparação ao código que será executado de fato em produção.
Tendo isso em vista, a ferramenta que utilizamos para dar suporte a criação de testes têm impacto significativo na velocidade de desenvolvimento, manutenção e evolução da aplicação.
Caminho feliz: cenário onde o cliente utiliza a aplicação de acordo com as especificações do projeto.
Caminho infeliz: são as possibilidades de utilização incorretas da aplicação pelo cliente (exceções).
Neste artigo vou apresentar o Spock, um framework de testes para aplicações Java e Groovy, e como sua linguagem de especificação elegante e expressiva pode trazer maior produtividade no dia-a-dia do programador.
Analisaremos o Spock através das seguintes características:
- Legibilidade e organização do teste
- Versatilidade de parametrização do teste
- Reportando falhas
- Testando a interação entre objetos: “Mocking” e “Stubbing”
- Trabalhando com exceções
Legibilidade e organização do teste
Praticamente todo teste que escrevemos (salvo algumas exceções) seguem as seguintes fases:
- Configuração: onde inicializamos os dados que utilizaremos no teste.
- Execução: onde o objeto que está sendo testado será executado.
- Verificação: onde avaliamos os resultados retornados na fase de execução.
Vamos exemplificar essas fases escrevendo um cenário que testará uma funcionalidade fictícia de cadastro de usuário com JUnit 5:
@Test | |
void validateUserRegistration() { | |
// setup | |
String name = "Fulano" | |
int age = 20 | |
String email = "fulano@mail.test" | |
// execute | |
User user = UserService().register(name, age, email) | |
// verify | |
assertEquals(name, user.getName()); | |
assertEquals(age, user.getAge()); | |
assertEquals(email, user.getEmail()); | |
} |
Embora essas fases estejam presentes na maioria dos testes, nem todas as bibliotecas dispoẽm de mecanismos para deixar isso de forma explícita; no caso do JUnit costumo usar comentários para demarcar cada fase.
Reescrevendo esse mesmo cenário com o Spock o teste fica bem mais legível e organizado, graças a linguagem Groovy (base do Spock) que nos permite escrever os métodos de forma declarativa e manter o corpo do teste mais simples e conciso. O Spock impõe a separação das fases ao estilo BDD, utilizando os blocos: “given”, “when” e “then”.
def 'Validate user registration'() { | |
given: 'user data' | |
def name = "Fulano" | |
def age = 20 | |
def email = "fulano@mail.test" | |
when: 'register the user' | |
def user = UserService().register(name, age, email) | |
then: | |
with(user) { | |
getName() == name | |
getAge() == age | |
getEmail() == email | |
} | |
} |
A fase de verificação também fica mais simples, pois utiliza os mesmos operadores de comparação do Java (removendo a necessidade de conhecer os métodos de “assertion”):
then: | |
name == 'Fulano' | |
name != 'Beltrano' | |
age >= 20 | |
age < 100 |
Dica: existe também o bloco “and” que pode ser usado para deixar a especificação do teste mais compreensível:
given: 'open a database connection' | |
// code goes here | |
and: 'seed the customer table' | |
// code goes here | |
and: 'seed the product table' | |
// code goes here |
Versatilidade de parametrização do teste
Com frequência precisamos executar o teste com dados diferentes para garantir que todos os cenários possíveis estão sendo validados.
O Spock resolve esse problema através do bloco “where” onde podemos prover uma lista de dados a serem utilizados em nossos testes.
No exemplo abaixo utilizamos o “Data Tables” para fornecer os dados de entrada ao método do teste. A primeira linha da tabela (header) declara o nome das variáveis que utilizaremos no teste, e as demais linhas correspondem ao valor dessas variáveis. Para cada linha o método do teste será executado uma vez.
def 'Allow user access only for 18 over'(name, age, expected) { | |
given: 'user entity' | |
def user = new User(name, age) | |
when: 'validate user access' | |
def userCanAccess = AccessService().grantUser(user) | |
then: | |
userCanAccess == expected | |
where: | |
name | age | expected | |
'Fulano' | 18 | true | |
'Beltrano' | 19 | true | |
'Sicrano' | 17 | false | |
} |
Organizando os dados no formato de tabela facilita tanto para quem escreve, como também para quem lê o teste; fazendo com que o teste sirva como uma documentação do funcionamento do objeto sob teste.
O Spock utiliza o conceito de “Data Driven Testing”, que fornece diversas formas de gerarmos dados de entrada, dando mais flexibilidade ao programador durante a escrita dos testes:
- Data Tables
- Data Pipes
- Multi-Variable Data Pipes
- Data Variable Assignment
- Combinação dos itens acima
Reportando falhas
No teste abaixo inserimos uma falha propositalmente na tabela do bloco “where” (linha 9):
def 'Maximum of two numbers'() { | |
expect: | |
Math.max(a, b) == c | |
where: | |
a | b | c | |
1 | 3 | 3 | |
7 | 4 | 4 | |
0 | 0 | 0 | |
} |
Dica: prefira utilizar o bloco “expect” quando a execução e a validação podem ser descritas numa única fase.
Ao executar o teste teremos a seguinte saída no terminal:
Condition not satisfied: | |
Math.max(a, b) == c | |
| | | | | | | |
| 7 7 4 | 4 | |
| false | |
class java.lang.Math |
Com Spock é fácil de identificar que a falha ocorreu na segunda interação, mas podemos deixar essa informação ainda mais explícita utilizando a anotação de “@Unroll”:
@Unroll | |
def 'Maximum of #a and #b is #c'() { | |
expect: | |
Math.max(a, b) == c | |
where: | |
a | b | c | |
1 | 3 | 3 | |
7 | 4 | 4 | |
0 | 0 | 0 | |
} |
Perceba que inserimos marcações no nome do método usando o sinal “#” mais o nome das variáveis utilizadas no teste. Executando o teste novamente teremos a seguinte saída:
maximum of 1 and 3 is 3 PASSED | |
maximum of 7 and 4 is 4 FAILED | |
Math.max(a, b) == c | |
| | | | | | | |
| 7 7 4 | 4 | |
| false | |
class java.lang.Math | |
maximum of 0 and 0 is 0 PASSED |
Identificar rapidamente onde ocorreu a falha aumenta a nossa produtividade, principalmente numa suíte de teste muito grande.
Testando a interação entre objetos
Mocking
Nem só de verificação de estado vivem nossos testes; há cenários onde é necessário explorar o comportamento do objeto sob teste do ponto de vista das interações que ele faz com outros objetos.
Nesse tipo de teste (também conhecido como “Interaction Tests” ou “Collaboration Test”) utilizamos a técnica de “Mock” (imitar) para conseguirmos analisar as interações entre os objetos sob teste.
A criação de “Mocks” é muito simples e pode ser feita de duas formas:
def subscriber1 = Mock(Subscriber) | |
def subscriber2 = Mock(Subscriber) |
Ou utilizando a sintaxe Java:
Subscriber subscriber1 = Mock() | |
Subscriber subscriber2 = Mock() |
Vejamos o uso do “Mock” mais detalhadamente no exemplo abaixo (linhas 14, 15):
class Publisher { | |
List<Subscriber> subscribers = [] | |
void send(String message){ | |
subscribers*.receive(message) | |
} | |
} | |
interface Subscriber { | |
void receive(String message) | |
} | |
class PublisherSpec extends Specification { | |
Publisher publisher = new Publisher() | |
Subscriber subscriber1 = Mock() | |
Subscriber subscriber2 = Mock() | |
def setup() { | |
publisher.subscribers << subscriber1 // << operador do Groovy para List.add() | |
publisher.subscribers << subscriber2 | |
} | |
def 'should send messages to all subscribers'() { | |
when: | |
publisher.send("hello") | |
then: | |
1 * subscriber1.receive("hello") | |
1 * subscriber2.receive("hello") | |
} | |
} |
Diferente dos exemplos anteriores, nos quais verificamos o estado do objeto sob teste (ex: user.getName() == name
); nosso interesse agora é saber se após a fase execução (“when”) o objeto “Publisher” realizou as interações necessárias com os seus colaboradores “Subscriber”.
A análise das interações é feita através de restrições, ou seja, no exemplo acima esperamos que objeto alvo “Subscriber”, seja chamado através do método “receive”, recebendo exatamente como argumento a palavra “hello” e que isso ocorra apenas um vez.
Essas restrições estão organizadas no Spock da seguinte forma:
- restrição por cardinalidade
- restrição de alvo
- restrição de método
- restrição de argumento
1 * subscriber1.receive("hello") | |
| | | | | |
| | | restrição de argumento | |
| | restrição de método | |
| restrição de alvo | |
restrição por cardinalidade |
Obs: todas as variações de restrição podem ser vistas na documentação do Spock.
Stubbing
Como podemos testar um objeto, de forma independente, quando ele depende do resultado retornado por outros objetos que colaboram com ele?
É preciso controlar os objetos de colaboração e definir como eles devem se comportar durante as interações com o objeto sob teste.
Chamamos essa técnica de “Stub”, onde substituímos o objeto real por um objeto que será alimentado com as entradas que desejamos utilizar no nosso testes.
Alteramos o nosso exemplo de teste que agora utiliza o “Stubbing” para definir um resultado ao método “receive” da interface “Subscriber” (linha 22). Dessa forma podemos validar não apenas a interação mas também o retorno esperado:
class Publisher { | |
private Subscriber subscriber | |
Publisher(Subscriber subscriber) { | |
this.subscriber = subscriber | |
} | |
String sendAndGetStatus(String message) { | |
return subscriber.receive(message) | |
} | |
} | |
interface Subscriber { | |
String receive(String message) | |
} | |
class PublisherSpec extends Specification { | |
Publisher publisher | |
Subscriber subscriber = Mock() | |
def setup() { | |
subscriber.receive(_) >> "ok" // Stubbing | |
publisher = new Publisher(subscriber) | |
} | |
def 'should send messages and get the subscriber status'() { | |
when: | |
def result = publisher.sendAndGetStatus("hello") | |
then: | |
result == "ok" | |
} | |
} |
As interações com “Stubs” diferem um pouco em comparação as interações como “Mocks”, conforme demonstrado abaixo:
subscriber.receive(_) >> "ok" | |
| | | | | |
| | | gerador de resposta | |
| | argumento de restrição | |
| método de restrição | |
objeto de restrição |
Mais uma vez, todas as variações de interações pode ser vistas na documentação do Spock, especialmente a parte que trata da combinação de “Mocking” e “Stubbing”.
Trabalhando com exceções
Por fim, porém não menos importante, precisamos ser capazes de validar quando o objeto sob teste deve lançar uma exceção.
Geralmente, o uso de exceções nos testes ocorrem de duas formas:
1 - Validar a ocorrência de uma exceção (“Exception Conditions”).
given: | |
def stack = new Stack() | |
when: | |
stack.pop() | |
then: | |
thrown(EmptyStackException) | |
stack.empty |
2 - Simular uma exceção como efeito colateral (utilizando “Stubs”).
subscriber.receive(_) >> { throw new InternalError("ouch") } |
Conclusão
Ainda como pontos positivos do Spock vale listar:
- Spock disponibiliza um “Web Console” onde podemos experimentar o seu funcionamento.
- Projeto de exemplo mostrando como configurar com: Ant, Gradle e Maven.
- Ótima documentação, embasada em conceitos do Agile e BDD.
- O framework de “Mock” do Spock é integrado, ou seja, não é preciso importar outras bibliotecas (embora também seja possível utilizá-las em conjunto com o Spock).
- Integração com o Spring.
Nem tudo são flores. Importante ressaltar que, em comparação, o JUnit 5 ganha nos seguintes pontos:
- 100% implementado em Java.
- Melhor compatibilidade com as últimas versões da JDK.
- Melhor integração com as IDEs.
Enfim, desde que utilizei o Spock pela primeira vez não larguei mais; não apenas pelo ganho de produtividade, mas também pela forma como os testes passam a funcionar (de fato) como a documentação da aplicação.
Referências:
Top comments (0)