Introdução a testes automatizados (TDD) com Ruby
Olá, se tem uma coisa que pessoas e equipes de auto desempenho possuem é confiança. Sendo que em desenvolvimento de software é muito importante ter certeza que o teu código funciona na medida que é modificado. A técnica mais comum e intuitiva para aumentar confiança de que as mudanças não estão gerando bugs é a realização de testes manuais. Ou seja, um ser humano, executará uma série de ações em sequência afim de avaliar se o sistema funciona conforme o esperado.
Testes realizados por seres humanos são válidos, mas é um recurso lento e caro se comparado a um processo automatizado. Por conta disso, nossa industria desenvolveu técnicas e ferramentas que permitem uma automação de boa parte desses processos.
Existem várias técnicas para se testar software. Mas neste artigo abordarei o uso do TDD (Test Driven Development), que pode ser traduzido como: desenvolvimento orientado a testes. O TDD é a prática de escrever os testes antes da implementação, isto é, você implementa testes (que naturalmente vão falhar) e só então adiciona o código necessário para que eles passem.
Esse post será dividido em duas partes, a primeira abordará o que é e por que utilizar TDD, e a última te ensinará como usar na prática! Como? Você aprenderá na medida que escreve a sua própria lib de testes 💪.
O que é TDD?
Como dito, TDD é um ciclo de desenvolvimento no qual você escreve primeiro o teste e depois a implementação. Veja a seguir cada uma de suas etapas:
Primeiro passo: escrever um teste que irá falhar.
Primeiro, você tem o red mode, no qual é escrito um teste que falha. Nele você descreverá o comportamento que o código deverá ter. Seja uma série de ações ou apenas uma parte/unidade do comportamento esperado.
Próximo passo: fazer o teste passar.
Nessa etapa, você escreverá o código necessário para fazer o teste passar, ou seja, entrar no green mode.
Último passo: refatorar o código produzido.
Nesse momento se torna possível refinar o código, já que temos garantias que o mesmo funciona. Ou seja, podemos melhorar a implementação desde que os testes continuem passando.
Significado de refatoração: É o processo de melhorar a estrutura de um código/sistema enquanto se preserva o comportamento existente.
Dica: Só refine o código se for realmente necessário! 🙂
Por fim: repita o ciclo.
Repita o ciclo para a próxima funcionalidade/comportamento a ser entregue.
- Escreva um teste que falha.
- Faça o teste passar.
- Refatore o código (melhore o que for possível).
A ideia é repetir esse ciclo para cada funcionalidade do sistema até que o mesmo seja considerado finalizado (pronto para produção).
Por que fazer TDD?
Ao fazer isso você e sua equipe obterão os seguintes benefícios:
- Erradicar o medo de modificar o sistema/código fonte.
Pelo fato dos testes estarem verdes, será possível alterar a lógica de negócio e comportamentos da aplicação uma vez que as especificações estão garantidas. - Refatorar se torna algo fácil e seguro de ser feito.
Caso algum teste falhe durante um refactoring será possível identificar o problema e então corrigí-lo.
A curto prazo tudo isso pode parecer improdutivo, já que você irá escrever mais código (implementação + testes) para chegar ao resultado desejado. Mas no médio/longo prazo, será um grande diferencial ter confiança para modificar o sistema conforme for preciso.
Como fazer TDD?
Para responder a essa pergunta te convido a escrever uma lib de testes para colocarmos toda a teoria em prática. Recomendo a leitura do post anterior já que o conteúdo do mesmo servirá de base para nos auxiliar na implementação.
Obs: Recomendo a leitura desse post caso não saiba como executar os exemplos de código a seguir.
Um dos conceitos chaves dessa prática é a que testes devem ser encarados como uma fonte de verdade. Ou seja, os testes não podem falhar.
E dado o conceito acima, sugiro fazermos uso de exceptions para interromper a execução de nossa suíte de testes caso ocorra alguma falha.
a = 1
b = 2
raise 'os valores devem ser iguais.' if a != b
O resultado do código acima será:
RuntimeError: os valores devem ser iguais.
Obs: Escreva nos comentários caso você tenha mais interesse em aprender sobre exceptions. 😉
Será que você teve a mesma ideia que eu? Que tal usarmos uma sequência de condicionais + exceptions para definir os nossos testes?
Afim de ter uma relação com o post anterior, usaremos o exemplo de uma calculadora para colocar em prática o uso de TDD.
Primeiro passo: escrever um teste que falha.
raise 'asserção falhou' if sum(1, 1) != 2
O que acontecerá se o código acima for executado? Dará erro! Por que? Porque o método não existe! 😅
NoMethodError: undefined method `sum' for main:Object
Então, bora definir o método.
def sum(a, b)
end
raise 'asserção falhou' if sum(1, 1) != 2
E ao executar o código acima, iremos ver esse resultado:
RuntimeError: asserção falhou
Opa! O teste falhou. 🙌 Ou seja, entramos no red mode.
Antes de continuarmos, precisamos abrir um rápido parênteses.
Pergunta: Por que fazer uso da palavra asserção?
Resposta: Por conta de seu significado (afirmação que se faz com muita certeza).
Logo, se um teste falhar, significa que falhou nossa afirmação / expectativa.
Dado que nosso teste está falhando, podemos implementar o mínimo necessário para ele passar.
def sum(a, b)
2
end
raise 'asserção falhou' if sum(1, 1) != 2
Ao executar o código anterior verá que o mesmo não teve erros. Ou seja, nosso teste está passando (green mode)!
Imagino que você já percebeu que o nosso método de soma, não soma! Mas acredite, essa é a proposta do TDD, você só escreve o código necessário para o teste passar.
E dado que não temos código o suficiente para refatorar, sugiro reiniciarmos o ciclo e escrevermos um novo teste que falha.
def sum(a, b)
2
end
raise 'asserção falhou' if sum(1, 1) != 2 # nil
raise 'asserção falhou' if sum(1, 3) != 4 # RuntimeError: asserção falhou
Dado a nova falha, podemos implementar o código necessário para ele passar.
def sum(a, b)
a + b
end
raise 'asserção falhou' if sum(1, 1) != 2
raise 'asserção falhou' if sum(1, 3) != 4
Uhuuuu! Os testes voltaram a passar.
Curtiu? Uma vez que os testes estão passando que tal começarmos a refatorar a nossa “lib de testes”?
Para isso, sugiro criarmos um método assert
que lançará uma exception caso o valor do argumento seja false
.
def assert(truthy)
raise 'asserção falhou' unless truthy
end
Obs: Podemos usar
unless
para negar uma condição, ou seja, é o mesmo que fazerif !false
.
A seguir, veja o uso do novo método de asserção para fazer os testes da implementação:
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
def sum(a, b)
a + b
end
# == tests ==
assert sum(1, 1) == 2
assert sum(1, 3) == 4
Próximo requisito: fazer com que o método de soma seja capaz de transformar strings em números.
Veja a seguir o teste relacionado ao novo requisito:
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
def sum(a, b)
a + b
end
# == tests ==
assert sum(1, 1) == 2 # nil
assert sum(1, 3) == 4 # nil
assert sum('3', 3) == 6 # TypeError: no implicit conversion of Integer into String
Se executarmos o código acima iremos obter o erro TypeError: no implicit conversion of Integer into String
ao tentar somar uma string com um número. Para evitar esse erro, sugiro ignorarmos qualquer argumento que não seja numérico.
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
def sum(a, b)
a + b if a.is_a?(Numeric) && b.is_a?(Numeric)
end
# == tests ==
assert sum(1, 1) == 2 # nil
assert sum(1, 3) == 4 # nil
assert sum('3', 3) == 6 # RuntimeError: asserção falhou
Pronto, agora o teste voltou a falhar!
Próximo passo: fazer o teste passar.
Veja abaixo como utilizei o método is_a?
para saber se o argumento é uma String
e o método to_i
para transformar uma string em um inteiro.
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
def sum(a, b)
a = a.to_i if a.is_a?(String)
b = b.to_i if b.is_a?(String)
a + b
end
# == tests ==
assert sum(1, 1) == 2
assert sum(1, 3) == 4
assert sum('3', 3) == 6
Ao testar o código acima, verá que os testes voltaram a passar!
Próximo passo: Refatorar.
Nessa etapa podemos analisar se existe alguma oportunidade para melhorar a implementação sem alterar o comportamento. Analise o código abaixo afim de encontrar algo para refinar:
def sum(a, b)
a = a.to_i if a.is_a?(String)
b = b.to_i if b.is_a?(String)
a + b
end
Ao meu ver podemos eliminar a repetição relacionada a verificação do argumento + transformação em número. Para isso, criaremos um novo método numeric_value
para cuidar dos argumentos.
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
def numeric_value(arg)
arg.is_a?(String) ? arg.to_i : arg
end
def sum(a, b)
numeric_value(a) + numeric_value(b)
end
# == tests ==
assert sum(1, 1) == 2
assert sum(1, 3) == 4
assert sum('3', 3) == 6
Viu? Nossos testes continuaram verdes. o/
Dica: Clique aqui para conhecer mais sobre o operador ternário
?:
que foi utilizado no métodonumeric_value
.
Com base no que foi dito no post anterior, que tal transformarmos esses métodos globais em métodos de uma classe?
Veja abaixo a transformação dos métodos sum
e numeric_value
em métodos da classe Calc
.
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
class Calc
def self.numeric_value(arg)
arg.is_a?(String) ? arg.to_i : arg
end
def self.sum(a, b)
numeric_value(a) + numeric_value(b)
end
end
# == tests ==
assert Calc.sum(1, 1) == 2
assert Calc.sum(1, 3) == 4
assert Calc.sum('3', 3) == 6
Por fim, sugiro adicionarmos um método para realizar multiplicações (Calc.multiply
).
Veja a seguir a implementação + testes do novo método:
# == testing lib ==
def assert(truthy)
raise 'asserção falhou' unless truthy
end
# == implementation ==
class Calc
def self.numeric_value(arg)
arg.is_a?(String) ? arg.to_i : arg
end
def self.sum(a, b)
numeric_value(a) + numeric_value(b)
end
def self.multiply(a, b)
numeric_value(a) * numeric_value(b)
end
end
# == tests ==
assert Calc.sum(1, 1) == 2
assert Calc.sum(1, 3) == 4
assert Calc.sum('3', 3) == 6
assert Calc.multiply(2, 2) == 4
assert Calc.multiply(3, 3) == 9
assert Calc.multiply('2', 4) == 8
Tranquilo né?
Perceba que nesse caso nós implementamos o método e os testes de uma única vez. Ou seja, nem sempre será necessário passar por todos os passos do TDD.
Concluindo
O poder do TDD está em desenvolver somente o código necessário para fazer o teste passar e na segurança de ter uma automação que verifica se o sistema funciona conforme o esperado.
Recomendo que você pratique e estude o máximo possível sobre esse tema (testes), porque uma vez que você tem a funcionalidade garantida, será possível explorar coisas como: refactoring, design de código, performance, segurança e etc… Ou seja, testes prepara o terreno para você explorar diferentes tipos de assunto.
No próximo post sobre testes, abordarei o uso das principais bibliotecas utilizadas em projetos Ruby: o minitest e o rspec.
Gostou do conteúdo? Deixe seu comentário aqui embaixo contando o que achou. Valeu! 😉
Agradecimentos
Quero agradecer a colaboração do @tomascco, @mploureno e @joaomarcos96 na revisão deste conteúdo. Muito obrigado! 👏
Desafios
1) Aprimore o método numeric_value
do código abaixo:
class Calc
def self.numeric_value(arg)
arg.is_a?(String) ? arg.to_i : arg
end
def self.sum(a, b)
numeric_value(a) + numeric_value(b)
end
end
Novos requisitos:
- Transformar
String
com números e.
emfloats
. - Lançar uma exceção caso algum dos argumentos não seja numérico, ou uma string com números (inteiros ou
floats
).
2) Análise o código abaixo e procure entender como ele funciona.
Dica: use a documentação do Ruby para entender os métodos utilizados na classe
UnitTest
.
# == testing lib ==
class UnitTest
def self.call
tests = public_instance_methods.select do |method|
method.to_s.start_with?('test_')
end
tests.each do |test|
self.new.send(test)
print '.'
end
puts "\n\nTodos os testes passaram!"
end
private
def assert(truthy)
raise 'asserção falhou' unless truthy
end
end
# == implementation ==
class Calc
def self.numeric_value(arg)
arg.is_a?(String) ? arg.to_i : arg
end
def self.sum(a, b)
numeric_value(a) + numeric_value(b)
end
def self.multiply(a, b)
numeric_value(a) * numeric_value(b)
end
end
# == tests ==
class CalcTest < UnitTest
def test_sum
assert Calc.sum(1, 1) == 2
assert Calc.sum(1, 3) == 4
assert Calc.sum('3', 3) == 6
end
def test_multiply
assert Calc.multiply(2, 2) == 4
assert Calc.multiply(3, 3) == 9
assert Calc.multiply('2', 4) == 8
end
end
# == running the tests ==
CalcTest.call
Resultado gerado pelo código acima:
..
Todos os testes passaram!
Obs:
CalcTest.call
é o método utilizado para executar todos testes deCalcTest
.
Já ouviu falar do ada.rb - Arquitetura e Design de Aplicações em Ruby? É um grupo focado em práticas de engenharia de software com Ruby. Acesse o canal no telegram e junte-se a nós em nosso meetup mensal (100% on-line).
Comments