Carregando
Todos os artigos
Programação 27 min de leitura

Introdução ao RSpec e ao Desenvolvimento Guiado por Testes: Um Guia Visual e Prático

Aprenda RSpec e desenvolvimento guiado por testes construindo um método de fatorial, um teste que falha de cada vez: vermelho, verde, refatorar, como ler falhas, matchers, casos extremos, resultados reais rodada por rodada, medições de velocidade e exercícios com respostas.

nbkhope
Nesta página

Como você sabe que o seu código funciona? Você roda, testa algumas entradas e confere as respostas no olho. Isso funciona uma vez. Mas cada vez que você mudar o código teria que testar tudo de novo, e não vai. Testes automatizados fazem essas tentativas por você, em uma fração de segundo, todas as vezes.

O RSpec é a ferramenta de testes mais popular do Ruby, e o desenvolvimento guiado por testes (TDD, do inglês test-driven development) é um jeito de trabalhar em que você escreve o teste antes do código. Neste guia você vai construir um método fatorial do jeito TDD, um teste que falha de cada vez, e ver cada mensagem real que o RSpec imprime pelo caminho. Você vai aprender a ler falhas, usar matchers, cuidar de casos extremos e manter o código seguro enquanto o arruma. Exercícios com respostas fecham o artigo.

Todas as saídas abaixo vêm de execuções reais com o RSpec 3.13 no Ruby 3.3. Os seus tempos ("Finished in…") vão variar um pouco de uma execução para outra. O RSpec imprime as mensagens em inglês; vamos traduzindo as partes importantes.

O ciclo do desenvolvimento guiado por testes

O ciclo do desenvolvimento guiado por testes. 1, Vermelho: escreva um teste pequeno do que o código deve fazer; rode: ele falha. 2, Verde: escreva o código mais simples que faz o teste passar; rode todos os testes. 3, Refatorar: arrume o código (e os testes); os testes precisam continuar verdes o tempo todo. Depois repita até a funcionalidade ficar pronta. Cada volta no ciclo leva minutos, não horas. O teste que falha vem primeiro para você saber que ele confere algo de verdade: um teste que nunca falhou pode não estar testando nada

O TDD tem três passos, com nomes das cores que as ferramentas de teste imprimem:

  1. Vermelho: escreva um teste pequeno para algo que o código deve fazer. Rode e veja ele falhar (afinal, ainda não existe código).
  2. Verde: escreva o código mais simples que faz esse teste passar. Rode todos os testes.
  3. Refatorar: arrume o código mantendo todos os testes verdes.

Depois repita com o próximo pedacinho de comportamento. Os testes vão se acumulando numa rede de segurança que confere tudo o que você construiu, toda vez que você os roda.

O nosso objetivo: fatorial

Fatorial: multiplique até chegar no 1. 0! é igual a 1 por definição. 1! = 1. 2! = 2 × 1 = 2. 3! = 3 × 2 × 1 = 6. 4! = 4 × 3 × 2 × 1 = 24. 5! = 5 × 4 × 3 × 2 × 1 = 120. A regra: n! = n × (n − 1)!, todo fatorial é o número vezes o fatorial logo abaixo dele, então 5! = 5 × 4! = 5 × 24 = 120. Os casos base 0! = 1 e 1! = 1 viram os nossos primeiros testes

O fatorial de um número, escrito n!, multiplica o número por todos os números inteiros abaixo dele até chegar no 1: 4! = 4 × 3 × 2 × 1 = 24. Os casos base são 1! = 1 e, por definição, 0! = 1.

Repare que a tabela já é uma lista de testes: cada linha é uma entrada com a resposta que esperamos.

Preparando tudo

Instale o RSpec (uma vez por computador) e confira a versão:

1$ gem install rspec
2$ rspec --version
3RSpec 3.13
4  - rspec-core 3.13.6
5  - rspec-expectations 3.13.5
6  - rspec-mocks 3.13.8
7  - rspec-support 3.13.7

Crie uma pasta para o projeto, entre nela e deixe o RSpec se configurar:

1$ mkdir fatorial
2$ cd fatorial
3$ rspec --init
4  create   .rspec
5  create   spec/spec_helper.rb
Onde ficam os arquivos. A pasta do projeto fatorial/ é de onde você roda o rspec. Dentro dela: .rspec, as opções que o rspec sempre usa; fatorial.rb, o código sendo testado; e a pasta spec/, onde o rspec procura os testes. Dentro de spec/: spec_helper.rb, configurações comuns criadas pelo rspec --init, e fatorial_spec.rb, os testes, cujo nome tem que terminar em _spec.rb

Depois crie um fatorial.rb vazio para o código e um spec/fatorial_spec.rb para os testes. O RSpec roda todo arquivo dentro de spec/ cujo nome termina em _spec.rb, então essas duas regras de nome importam.

Vermelho: o primeiro teste

Coloque isto no spec/fatorial_spec.rb:

1require_relative "../fatorial"
2
3RSpec.describe "fatorial" do
4  it "devolve 1 para 0" do
5    expect(fatorial(0)).to eq(1)
6  end
7end
A anatomia de um spec. require_relative "../fatorial" carrega o código do arquivo uma pasta acima. RSpec.describe "fatorial" do começa um grupo de exemplos, dizendo o que está sendo descrito. it "devolve 1 para 0" do é um exemplo: um comportamento, um teste. expect(fatorial(0)).to eq(1) é uma expectativa: fatorial(0) é o valor real e eq(1) é o matcher com o valor esperado. Sem mágica: describe, it, expect e eq são métodos comuns do Ruby, e do … end são blocos comuns
  • describe ("descreva") dá nome à coisa sendo testada e agrupa os exemplos dela.
  • it ("ele") é um exemplo: um único comportamento, descrito em palavras.
  • expect(real).to matcher ("espere que real seja…") é a verificação. Aqui o matcher eq(1) quer dizer "igual a 1".

Em inglês, isso se lê quase como uma frase: expect fatorial of 0 to equal 1, "espere que o fatorial de 0 seja igual a 1". Esse jeito legível é o estilo do RSpec, mas não há mágica nenhuma: describe, it, expect e eq são métodos comuns do Ruby que recebem strings e blocos.

Rode os testes a partir da pasta do projeto (a que tem a spec/):

1$ rspec
2F
3
4Failures:
5
6  1) fatorial devolve 1 para 0
7     Failure/Error: expect(fatorial(0)).to eq(1)
8
9     NoMethodError:
10       undefined method `fatorial' for #<RSpec::ExampleGroups::Fatorial:0x0000028ef1564f00>
11     # ./spec/fatorial_spec.rb:5:in `block (2 levels) in <top (required)>'
12
13Finished in 0.03429 seconds (files took 0.46926 seconds to load)
141 example, 1 failure
15
16Failed examples:
17
18rspec ./spec/fatorial_spec.rb:4 # fatorial devolve 1 para 0

Vermelho, como esperado: ainda não existe o método fatorial ("undefined method", método não definido). (O número 0x0000… é um endereço de memória e muda a cada execução.) Cada F na primeira linha é um exemplo que falhou; um que passa imprime um ponto.

Verde, um passinho de cada vez

No TDD você deixa a falha dizer o que fazer em seguida, e faz só isso. Defina o método:

def fatorial
end
1$ rspec
2F
3
4Failures:
5
6  1) fatorial devolve 1 para 0
7     Failure/Error: expect(fatorial(0)).to eq(1)
8
9     ArgumentError:
10       wrong number of arguments (given 1, expected 0)
11     # ./fatorial.rb:1:in `fatorial'
12     # ./spec/fatorial_spec.rb:5:in `block (2 levels) in <top (required)>'
13
14Finished in 0.02019 seconds (files took 0.52359 seconds to load)
151 example, 1 failure
16
17Failed examples:
18
19rspec ./spec/fatorial_spec.rb:4 # fatorial devolve 1 para 0

Uma falha nova é progresso. "Wrong number of arguments (given 1, expected 0)": o teste passa um argumento, mas o método não recebe nenhum. Acrescente um parâmetro:

def fatorial(numero)
end
1$ rspec
2F
3
4Failures:
5
6  1) fatorial devolve 1 para 0
7     Failure/Error: expect(fatorial(0)).to eq(1)
8
9       expected: 1
10            got: nil
11
12       (compared using ==)
13     # ./spec/fatorial_spec.rb:5:in `block (2 levels) in <top (required)>'
14
15Finished in 0.0271 seconds (files took 0.41189 seconds to load)
161 example, 1 failure
17
18Failed examples:
19
20rspec ./spec/fatorial_spec.rb:4 # fatorial devolve 1 para 0

Agora o método roda, mas um método vazio devolve nil: esperado (expected) 1, obtido (got) nil. Qual é o código mais simples que devolve 1? Devolver 1:

def fatorial(numero)
  1
end
1$ rspec
2.
3
4Finished in 0.02445 seconds (files took 0.41461 seconds to load)
51 example, 0 failures

Verde! Devolver uma constante parece trapaça, e é, de propósito: o código só fica tão esperto quanto os testes exigem. Os próximos testes vão obrigá-lo a ficar mais esperto.

O caminho até aqui:

RodadaO RSpec disseO que quer dizerO conserto
1NoMethodError: undefined method 'fatorial'O método não existeDefina o método
2ArgumentError: wrong number of arguments (given 1, expected 0)Ele recebe o número errado de argumentosAcrescente um parâmetro
3expected: 1, got: nilEle roda, mas devolve o valor erradoDevolva 1
41 example, 0 failuresVerdeEscreva o próximo teste

Como ler uma falha

Como ler uma falha. A primeira linha, 1) fatorial devolve 2 para 2, diz qual exemplo falhou: o grupo e o nome do exemplo. Failure/Error: expect(fatorial(2)).to eq(2) é a linha que falhou. expected: 2 é o que o teste queria, e got: 1 é o que o código deu. A linha # ./spec/fatorial_spec.rb:13 dá o arquivo e o número da linha. A última linha, rspec ./spec/fatorial_spec.rb:12 # fatorial devolve 2 para 2, pode ser colada para rodar só aquele exemplo. Leia de cima para baixo: qual comportamento quebrou, qual expectativa, a diferença e onde

Uma saída mais legível: o formato de documentação

O rspec --init criou um arquivo .rspec com opções que o RSpec usa em toda execução. Acrescente uma segunda linha para ele imprimir cada exemplo pelo nome:

--require spec_helper
--format documentation

Mais testes, código mais esperto

Acrescente mais dois exemplos ao spec:

1require_relative "../fatorial"
2
3RSpec.describe "fatorial" do
4  it "devolve 1 para 0" do
5    expect(fatorial(0)).to eq(1)
6  end
7
8  it "devolve 1 para 1" do
9    expect(fatorial(1)).to eq(1)
10  end
11
12  it "devolve 2 para 2" do
13    expect(fatorial(2)).to eq(2)
14  end
15end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2 (FAILED - 1)
7
8Failures:
9
10  1) fatorial devolve 2 para 2
11     Failure/Error: expect(fatorial(2)).to eq(2)
12
13       expected: 2
14            got: 1
15
16       (compared using ==)
17     # ./spec/fatorial_spec.rb:13:in `block (2 levels) in <top (required)>'
18
19Finished in 0.01296 seconds (files took 0.3756 seconds to load)
203 examples, 1 failure
21
22Failed examples:
23
24rspec ./spec/fatorial_spec.rb:12 # fatorial devolve 2 para 2

O teste novo para 1 passou de cara (a nossa constante por acaso está certa), e o para 2 falha. O conserto mais simples? Ainda um pouco atrevido:

1def fatorial(numero)
2  return 1 if numero <= 1
3
4  numero
5end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7
8Finished in 0.00364 seconds (files took 0.43131 seconds to load)
93 examples, 0 failures

Esse truque se chama fingir até conseguir (fake it till you make it). Para obrigar uma solução de verdade, acrescente exemplos que nenhum atalho consegue satisfazer. Isso se chama triangulação:

1  it "devolve 6 para 3" do
2    expect(fatorial(3)).to eq(6)
3  end
4
5  it "devolve 24 para 4" do
6    expect(fatorial(4)).to eq(24)
7  end
8
9  it "devolve 5040 para 7" do
10    expect(fatorial(7)).to eq(5040)
11  end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7  devolve 6 para 3 (FAILED - 1)
8  devolve 24 para 4 (FAILED - 2)
9  devolve 5040 para 7 (FAILED - 3)
10
11Failures:
12
13  1) fatorial devolve 6 para 3
14     Failure/Error: expect(fatorial(3)).to eq(6)
15
16       expected: 6
17            got: 3
18
19       (compared using ==)
20     # ./spec/fatorial_spec.rb:17:in `block (2 levels) in <top (required)>'
21
22  2) fatorial devolve 24 para 4
23     Failure/Error: expect(fatorial(4)).to eq(24)
24
25       expected: 24
26            got: 4
27
28       (compared using ==)
29     # ./spec/fatorial_spec.rb:21:in `block (2 levels) in <top (required)>'
30
31  3) fatorial devolve 5040 para 7
32     Failure/Error: expect(fatorial(7)).to eq(5040)
33
34       expected: 5040
35            got: 7
36
37       (compared using ==)
38     # ./spec/fatorial_spec.rb:25:in `block (2 levels) in <top (required)>'
39
40Finished in 0.02272 seconds (files took 0.42674 seconds to load)
416 examples, 3 failures
42
43Failed examples:
44
45rspec ./spec/fatorial_spec.rb:16 # fatorial devolve 6 para 3
46rspec ./spec/fatorial_spec.rb:20 # fatorial devolve 24 para 4
47rspec ./spec/fatorial_spec.rb:24 # fatorial devolve 5040 para 7

Olhe os valores "got": 3, 4, 7. O método só devolve a própria entrada. Hora de escrevê-lo de verdade, multiplicando do número até o 2:

1def fatorial(numero)
2  produto = 1
3  multiplicador = numero
4
5  while multiplicador > 1
6    produto *= multiplicador
7    multiplicador -= 1
8  end
9
10  produto
11end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7  devolve 6 para 3
8  devolve 24 para 4
9  devolve 5040 para 7
10
11Finished in 0.00402 seconds (files took 0.52151 seconds to load)
126 examples, 0 failures

Tudo verde. Os casos base também estão cobertos: para 0 e 1 o laço nunca roda e produto continua 1.

Refatorar: arrumar com uma rede de segurança

O laço while funciona, mas o Ruby consegue dizer "multiplique de 1 até n" numa linha só, com o reduce. Como os testes existem, dá para tentar sem medo:

def fatorial(numero)
  (1..numero).reduce(1, :*)
end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7  devolve 6 para 3
8  devolve 24 para 4
9  devolve 5040 para 7
10
11Finished in 0.0038 seconds (files took 0.40621 seconds to load)
126 examples, 0 failures

Continua verde, então a versão mais curta se comporta exatamente igual em tudo o que especificamos. O reduce(1, :*) começa em 1 e vai multiplicando cada número do intervalo; para 0, o intervalo 1..0 é vazio, então a resposta é o 1 inicial.

Casos extremos: números enormes e entradas negativas

O que mais o fatorial deveria fazer? Duas boas perguntas para virar testes:

  • Números grandes. Fatoriais crescem absurdamente rápido.
  • Entradas inválidas. O fatorial não é definido para números negativos.
Fatoriais crescem rápido: dígitos de n!, calculados com o Ruby 3.3. 5! tem 3 dígitos, 10! tem 7, 15! tem 13, 20! tem 19, 21! tem 20, 25! tem 26, 50! tem 65 e 100! tem 158. Uma linha tracejada marca o limite de um inteiro de 64 bits, 19 dígitos: 20! cabe, 21! não. Muitas linguagens estouram no 21!. Os inteiros do Ruby crescem conforme a necessidade, então fatorial(100), com todos os seus 158 dígitos, simplesmente funciona, e um teste pode conferir uma resposta de 26 dígitos como 25!
1  it "aguenta números grandes demais para 64 bits" do
2    expect(fatorial(25)).to eq(15_511_210_043_330_985_984_000_000)
3  end
4
5  it "rejeita números negativos" do
6    expect { fatorial(-1) }.to raise_error(ArgumentError)
7  end

Repare nas chaves no segundo teste: expect { … } entrega ao RSpec um bloco de código para rodar, para que ele possa ficar de olho num erro. Com parênteses, o erro aconteceria antes que o RSpec pudesse pegá-lo.

1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7  devolve 6 para 3
8  devolve 24 para 4
9  devolve 5040 para 7
10  aguenta números grandes demais para 64 bits
11  rejeita números negativos (FAILED - 1)
12
13Failures:
14
15  1) fatorial rejeita números negativos
16     Failure/Error: expect { fatorial(-1) }.to raise_error(ArgumentError)
17       expected ArgumentError but nothing was raised
18     # ./spec/fatorial_spec.rb:33:in `block (2 levels) in <top (required)>'
19
20Finished in 0.02116 seconds (files took 0.42656 seconds to load)
218 examples, 1 failure
22
23Failed examples:
24
25rspec ./spec/fatorial_spec.rb:32 # fatorial rejeita números negativos

O teste de número grande passou de cara: os inteiros do Ruby crescem o quanto for preciso. O teste negativo está vermelho ("expected ArgumentError but nothing was raised", esperava um ArgumentError mas nenhum erro aconteceu), porque fatorial(-1) devolve 1 em silêncio, uma resposta errada com cara de certa. Gere um erro em vez disso:

1def fatorial(numero)
2  raise ArgumentError, "o fatorial não está definido para números negativos" if numero.negative?
3
4  (1..numero).reduce(1, :*)
5end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7  devolve 6 para 3
8  devolve 24 para 4
9  devolve 5040 para 7
10  aguenta números grandes demais para 64 bits
11  rejeita números negativos
12
13Finished in 0.00875 seconds (files took 0.41366 seconds to load)
148 examples, 0 failures

Refatorando os testes: uma tabela de casos

Testes também são código e merecem arrumação. Aqueles seis exemplos quase idênticos podem sair de um hash de entradas e respostas, e o context ("contexto") agrupa os exemplos de uma situação:

1require_relative "../fatorial"
2
3RSpec.describe "fatorial" do
4  {
5    0 => 1,
6    1 => 1,
7    2 => 2,
8    3 => 6,
9    4 => 24,
10    7 => 5040,
11    25 => 15_511_210_043_330_985_984_000_000,
12  }.each do |numero, esperado|
13    it "devolve #{esperado} para #{numero}" do
14      expect(fatorial(numero)).to eq(esperado)
15    end
16  end
17
18  context "com um número negativo" do
19    it "gera um ArgumentError" do
20      expect { fatorial(-1) }.to raise_error(ArgumentError, /negativos/)
21    end
22  end
23end
1$ rspec
2
3fatorial
4  devolve 1 para 0
5  devolve 1 para 1
6  devolve 2 para 2
7  devolve 6 para 3
8  devolve 24 para 4
9  devolve 5040 para 7
10  devolve 15511210043330985984000000 para 25
11  com um número negativo
12    gera um ArgumentError
13
14Finished in 0.00821 seconds (files took 0.41454 seconds to load)
158 examples, 0 failures

As mesmas oito verificações, menos repetição, e acrescentar um caso agora é uma linha só. O /negativos/ também confere a mensagem do erro. (O guia de hashes explica o each com dois parâmetros no bloco.)

A sessão inteira de uma vez

A nossa sessão de TDD, rodada por rodada: exemplos passando e falhando depois de cada uma das 12 rodadas do rspec neste artigo. Rodadas 1 a 3 (sem método, sem parâmetro, devolve nil): 0 de 1 passando. Rodada 4 (devolve 1): 1 de 1. Rodada 5 (teste para 2): 2 de 3. Rodada 6 (finge): 3 de 3. Rodada 7 (testes para 3, 4 e 7): 3 de 6. Rodada 8 (laço real): 6 de 6. Rodada 9 (reduce, uma refatoração): 6 de 6. Rodada 10 (teste para −1): 7 de 8. Rodada 11 (gera erro): 8 de 8. Rodada 12 (tabela de casos, uma refatoração): 8 de 8. Todo teste novo falhou primeiro e depois passou; as duas rodadas de refatoração ficaram totalmente verdes

Cada barra vermelha é um teste que falhou antes de existir o código para fazê-lo passar. É a prova de que cada teste consegue mesmo pegar um problema.

Os testes pegam uma refatoração quebrada

Eis por que a rede de segurança importa. Imagine que alguém "simplifica" o método, achando que multiplicar por 1 não serve para nada:

1def fatorial(numero)
2  raise ArgumentError, "o fatorial não está definido para números negativos" if numero.negative?
3
4  (2..numero).reduce(:*)
5end

Parece certo e funciona para 2, 3, 4… Mas:

1$ rspec
2
3fatorial
4  devolve 1 para 0 (FAILED - 1)
5  devolve 1 para 1 (FAILED - 2)
6  devolve 2 para 2
7  devolve 6 para 3
8  devolve 24 para 4
9  devolve 5040 para 7
10  devolve 15511210043330985984000000 para 25
11  com um número negativo
12    gera um ArgumentError
13
14Failures:
15
16  1) fatorial devolve 1 para 0
17     Failure/Error: expect(fatorial(numero)).to eq(esperado)
18
19       expected: 1
20            got: nil
21
22       (compared using ==)
23     # ./spec/fatorial_spec.rb:14:in `block (3 levels) in <top (required)>'
24
25  2) fatorial devolve 1 para 1
26     Failure/Error: expect(fatorial(numero)).to eq(esperado)
27
28       expected: 1
29            got: nil
30
31       (compared using ==)
32     # ./spec/fatorial_spec.rb:14:in `block (3 levels) in <top (required)>'
33
34Finished in 0.02395 seconds (files took 0.5391 seconds to load)
358 examples, 2 failures
36
37Failed examples:
38
39rspec './spec/fatorial_spec.rb[1:1]' # fatorial devolve 1 para 0
40rspec './spec/fatorial_spec.rb[1:2]' # fatorial devolve 1 para 1

(2..0) e (2..1) são intervalos vazios, e o reduce sem valor inicial devolve nil para um intervalo vazio. Os testes dos casos base pegaram o erro em milissegundos, muito antes que um usuário pudesse. (Como os exemplos saem de um laço, o RSpec os chama pela posição, como [1:1], em vez de pela linha.)

Rodando só alguns testes

Conforme a suíte cresce, muitas vezes você vai querer rodar um exemplo ou alguns:

1$ rspec spec/fatorial_spec.rb:19
2Run options: include {:locations=>{"./spec/fatorial_spec.rb"=>[19]}}
3
4fatorial
5  com um número negativo
6    gera um ArgumentError
7
8Finished in 0.00582 seconds (files took 0.4713 seconds to load)
91 example, 0 failures
1$ rspec -e "negativo"
2Run options: include {:full_description=>/negativo/}
3
4fatorial
5  com um número negativo
6    gera um ArgumentError
7
8Finished in 0.00522 seconds (files took 0.48556 seconds to load)
91 example, 0 failures
  • rspec caminho:linha roda o exemplo (ou grupo) daquela linha.
  • rspec -e "palavras" roda os exemplos cuja descrição completa contém essas palavras.
  • rspec --format progress passa por cima do .rspec e imprime só pontinhos: ........

Matchers: mais jeitos de conferir

O eq é só o começo. O RSpec vem com matchers para números, coleções, strings, tipos, erros e mudanças:

1RSpec.describe "matchers do dia a dia" do
2  it "compara valores" do
3    expect(2 + 2).to eq(4)
4    expect(10).to be > 3
5    expect(7).to be_between(1, 10)
6    expect(0.1 + 0.2).to be_within(0.001).of(0.3)
7  end
8
9  it "confere true, false e nil" do
10    expect([].empty?).to be true
11    expect([]).to be_empty
12    expect(nil).to be_nil
13    expect(5).not_to be_nil
14  end
15
16  it "olha dentro de strings e coleções" do
17    expect("Olá, Ruby").to include("Ruby")
18    expect("Olá, Ruby").to start_with("Olá")
19    expect("2024-07-07").to match(/\A\d{4}-\d{2}-\d{2}\z/)
20    expect([3, 1, 2]).to contain_exactly(1, 2, 3)
21    expect([2, 4, 6]).to all(be_even)
22    expect({ nome: "Ada", nascimento: 1815 }).to include(nome: "Ada")
23  end
24
25  it "confere tipos" do
26    expect(42).to be_an(Integer)
27    expect("42").to be_a(String)
28  end
29
30  it "confere erros e mudanças" do
31    expect { Integer("quarenta e dois") }.to raise_error(ArgumentError)
32    carrinho = []
33    expect { carrinho << "maçã" }.to change { carrinho.size }.from(0).to(1)
34  end
35end
1$ rspec
2
3matchers do dia a dia
4  compara valores
5  confere true, false e nil
6  olha dentro de strings e coleções
7  confere tipos
8  confere erros e mudanças
9
10Finished in 0.03668 seconds (files took 0.49869 seconds to load)
115 examples, 0 failures

Alguns que valem destacar:

  • be_within(…).of(…) compara decimais com uma tolerância, porque 0.1 + 0.2 é 0.30000000000000004, e não exatamente 0.3.
  • be_empty, be_even são matchers de predicado: o be_xyz chama o método xyz? e espera true.
  • not_to inverte qualquer matcher.

Bons matchers também escrevem boas mensagens de falha:

1RSpec.describe "mensagens de falha" do
2  it "explica um item que falta" do
3    expect(["maçã", "banana"]).to include("cereja")
4  end
5
6  it "mostra um diff para hashes" do
7    expect({ nome: "Ada", nascimento: 1815 }).to eq({ nome: "Ada", nascimento: 1816 })
8  end
9end
1$ rspec spec/falhas_spec.rb
2
3mensagens de falha
4  explica um item que falta (FAILED - 1)
5  mostra um diff para hashes (FAILED - 2)
6
7Failures:
8
9  1) mensagens de falha explica um item que falta
10     Failure/Error: expect(["maçã", "banana"]).to include("cereja")
11       expected ["maçã", "banana"] to include "cereja"
12     # ./spec/falhas_spec.rb:3:in `block (2 levels) in <top (required)>'
13
14  2) mensagens de falha mostra um diff para hashes
15     Failure/Error: expect({ nome: "Ada", nascimento: 1815 }).to eq({ nome: "Ada", nascimento: 1816 })
16
17       expected: {:nascimento=>1816, :nome=>"Ada"}
18            got: {:nascimento=>1815, :nome=>"Ada"}
19
20       (compared using ==)
21
22       Diff:
23       @@ -1,2 +1,2 @@
24       -:nascimento => 1816,
25       +:nascimento => 1815,
26        :nome => "Ada",
27     # ./spec/falhas_spec.rb:7:in `block (2 levels) in <top (required)>'
28
29Finished in 0.03448 seconds (files took 0.38084 seconds to load)
302 examples, 2 failures
31
32Failed examples:
33
34rspec ./spec/falhas_spec.rb:2 # mensagens de falha explica um item que falta
35rspec ./spec/falhas_spec.rb:6 # mensagens de falha mostra um diff para hashes

O diff aponta direto para o único valor diferente, o que salva vidas com hashes grandes.

Um mapa dos matchers do RSpec do dia a dia. Igualdade: eq(4), eql(4), equal(objeto), be(true). Comparação: be > 3, be <= 10, be_between(1, 10), be_within(0.1).of(x). Verdadeiro ou falso: be true ou be false, be_nil, be_truthy, be_falsey. Predicados: be_empty, be_even, be_positive, have_key(:nome). Coleções: include(3), contain_exactly, all(be_even), start_with(1). Strings: include("Ruby"), start_with("Ol"), end_with("!"), match com uma expressão regular. Tipos: be_a(String), be_an(Integer), be_instance_of, respond_to(:size). Blocos: raise_error(KeyError), change { x }, output("oi").to_stdout, not_to raise_error

Quão rápidos são os testes unitários?

Testes rápidos são rodados; testes lentos são pulados. Geramos specs com 10 a 10.000 exemplos de fatorial e medimos o tempo:

10.000 exemplos em cerca de meio segundo. Tempo que o rspec informou para rodar specs de cada tamanho, medido para este artigo com o RSpec 3.13 no Ruby 3.3.2, Windows, mediana de 5 execuções: 10 exemplos, 5,0 milissegundos; 100 exemplos, 11,7 ms; 1.000 exemplos, 59,9 ms; 10.000 exemplos, 561,5 ms. Cerca de 56 microssegundos por exemplo. Iniciar o Ruby e carregar os arquivos somou cerca de um terço de segundo a cada execução

Cerca de 56 microssegundos por exemplo. Iniciar o Ruby e carregar os arquivos custou mais (cerca de um terço de segundo por execução) do que rodar mil exemplos. Testes unitários pequenos e focados como os nossos são baratos o bastante para rodar toda vez que você salva um arquivo.

Sua vez: pratique

Tente fazer estes antes de espiar as respostas. Monte cada um como o projeto do fatorial: o código na pasta do projeto, o spec na spec/ e --format documentation no .rspec.

Exercício 1: Adivinhe a falha (aquecimento). Qual exemplo falha, e o que o RSpec vai dizer em "got"? (Um ano é bissexto se for divisível por 4, menos os anos divisíveis por 100, a não ser que também sejam divisíveis por 400.)

def ano_bissexto?(ano)
  ano % 4 == 0
end
1require_relative "../ano_bissexto"
2
3RSpec.describe "ano_bissexto?" do
4  it "é true para 2024" do
5    expect(ano_bissexto?(2024)).to be true
6  end
7
8  it "é false para 2023" do
9    expect(ano_bissexto?(2023)).to be false
10  end
11
12  it "é false para 1900" do
13    expect(ano_bissexto?(1900)).to be false
14  end
15
16  it "é true para 2000" do
17    expect(ano_bissexto?(2000)).to be true
18  end
19end

Exercício 2: Leia a falha. Este teste falha com expected: 120, got: 0. Qual é o erro?

1def fatorial(numero)
2  produto = 0
3  (1..numero).each { |multiplicador| produto *= multiplicador }
4  produto
5end

Exercício 3: FizzBuzz com TDD. Escreva os testes primeiro e depois o código: fizzbuzz(numero) devolve "Fizz" para múltiplos de 3, "Buzz" para múltiplos de 5, "FizzBuzz" para múltiplos dos dois e, nos outros casos, o número como string.

Exercício 4: Palíndromos e casos extremos. Faça com TDD um método palindromo?(texto) que ignore letras maiúsculas, espaços, pontuação e acentos, para que "Socorram-me, subi no ônibus em Marrocos!" conte. Pense na string vazia também.

Exercício 5: Fibonacci (desafio). Faça com TDD o fibonacci(posicao), em que a sequência é 0, 1, 1, 2, 3, 5, 8… começando na posição 0. Use uma tabela de casos incluindo a posição 100, e rejeite posições negativas.

Quiz rápido

  1. Quais são os três passos do ciclo do TDD?
  2. Por que um teste novo deve falhar antes de você escrever o código para ele?
  3. Quando você escreve expect { … } com chaves em vez de expect(…)?
  4. O que o be_empty chama de verdade?
  5. Como rodar só os exemplos cujo nome menciona "negativo"?

Respostas

Exercício 1: 1900 é divisível por 4, então o método errado diz true:

1$ rspec
2
3ano_bissexto?
4  é true para 2024
5  é false para 2023
6  é false para 1900 (FAILED - 1)
7  é true para 2000
8
9Failures:
10
11  1) ano_bissexto? é false para 1900
12     Failure/Error: expect(ano_bissexto?(1900)).to be false
13
14       expected false
15            got true
16     # ./spec/ano_bissexto_spec.rb:13:in `block (2 levels) in <top (required)>'
17
18Finished in 0.02043 seconds (files took 0.49782 seconds to load)
194 examples, 1 failure
20
21Failed examples:
22
23rspec ./spec/ano_bissexto_spec.rb:12 # ano_bissexto? é false para 1900

A regra completa deixa tudo verde:

def ano_bissexto?(ano)
  (ano % 4 == 0 && ano % 100 != 0) || ano % 400 == 0
end
1$ rspec
2
3ano_bissexto?
4  é true para 2024
5  é false para 2023
6  é false para 1900
7  é true para 2000
8
9Finished in 0.00563 seconds (files took 0.64104 seconds to load)
104 examples, 0 failures

Repare que o 2000 passou mesmo com o erro: sem o teste do 1900, o erro teria passado batido. Bons testes vão atrás dos casos traiçoeiros.

Exercício 2: O produto começa em 0, e 0 vezes qualquer coisa é 0. Um produto tem que começar em 1 (assim como uma soma começa em 0). Trocar produto = 0 por produto = 1 faz o teste passar.

1$ rspec
2
3fatorial
4  devolve 120 para 5 (FAILED - 1)
5
6Failures:
7
8  1) fatorial devolve 120 para 5
9     Failure/Error: expect(fatorial(5)).to eq(120)
10
11       expected: 120
12            got: 0
13
14       (compared using ==)
15     # ./spec/fatorial_spec.rb:5:in `block (2 levels) in <top (required)>'
16
17Finished in 0.01436 seconds (files took 0.46315 seconds to load)
181 example, 1 failure
19
20Failed examples:
21
22rspec ./spec/fatorial_spec.rb:4 # fatorial devolve 120 para 5

Exercício 3: Um teste por regra. No código, confira "os dois" primeiro, senão o 15 pararia no "Fizz":

1require_relative "../fizzbuzz"
2
3RSpec.describe "fizzbuzz" do
4  it "devolve o número como texto" do
5    expect(fizzbuzz(1)).to eq("1")
6  end
7
8  it "devolve Fizz para múltiplos de 3" do
9    expect(fizzbuzz(9)).to eq("Fizz")
10  end
11
12  it "devolve Buzz para múltiplos de 5" do
13    expect(fizzbuzz(10)).to eq("Buzz")
14  end
15
16  it "devolve FizzBuzz para múltiplos dos dois" do
17    expect(fizzbuzz(30)).to eq("FizzBuzz")
18  end
19end
1def fizzbuzz(numero)
2  return "FizzBuzz" if numero % 15 == 0
3  return "Fizz" if numero % 3 == 0
4  return "Buzz" if numero % 5 == 0
5
6  numero.to_s
7end
1$ rspec
2
3fizzbuzz
4  devolve o número como texto
5  devolve Fizz para múltiplos de 3
6  devolve Buzz para múltiplos de 5
7  devolve FizzBuzz para múltiplos dos dois
8
9Finished in 0.00392 seconds (files took 0.43658 seconds to load)
104 examples, 0 failures

Exercício 4: Cada exigência virou um exemplo, mais o caso extremo da string vazia:

1require_relative "../palindromo"
2
3RSpec.describe "palindromo?" do
4  it "é true para um palíndromo simples" do
5    expect(palindromo?("osso")).to be true
6  end
7
8  it "é false para uma palavra que não é" do
9    expect(palindromo?("ruby")).to be false
10  end
11
12  it "ignora letras maiúsculas" do
13    expect(palindromo?("Arara")).to be true
14  end
15
16  it "ignora espaços, pontuação e acentos" do
17    expect(palindromo?("Socorram-me, subi no ônibus em Marrocos!")).to be true
18  end
19
20  it "é true para uma string vazia" do
21    expect(palindromo?("")).to be true
22  end
23end
1def palindromo?(texto)
2  letras = texto.unicode_normalize(:nfd).downcase.gsub(/[^a-z0-9]/, "")
3  letras == letras.reverse
4end
1$ rspec
2
3palindromo?
4  é true para um palíndromo simples
5  é false para uma palavra que não é
6  ignora letras maiúsculas
7  ignora espaços, pontuação e acentos
8  é true para uma string vazia
9
10Finished in 0.0489 seconds (files took 0.41899 seconds to load)
115 examples, 0 failures

O unicode_normalize(:nfd) separa cada letra acentuada em letra mais acento (o "ô" vira "o" seguido de "^"), e aí o gsub tira só o acento: "ônibus" vira "onibus". Sem isso, o gsub apagaria o "ô" inteiro e sobraria "nibus". (O guia de métodos de string explica o downcase, o gsub e o reverse.)

Exercício 5: Uma tabela de casos mais o caso do erro. A resposta de 21 dígitos da posição 100 não é problema para o Ruby:

1require_relative "../fibonacci"
2
3RSpec.describe "fibonacci" do
4  { 0 => 0, 1 => 1, 2 => 1, 3 => 2, 10 => 55, 100 => 354_224_848_179_261_915_075 }.each do |posicao, esperado|
5    it "devolve #{esperado} na posição #{posicao}" do
6      expect(fibonacci(posicao)).to eq(esperado)
7    end
8  end
9
10  it "rejeita posições negativas" do
11    expect { fibonacci(-1) }.to raise_error(ArgumentError, /negativa/)
12  end
13end
1def fibonacci(posicao)
2  raise ArgumentError, "a posição não pode ser negativa" if posicao.negative?
3
4  anterior, atual = 0, 1
5  posicao.times { anterior, atual = atual, anterior + atual }
6  anterior
7end
1$ rspec
2
3fibonacci
4  devolve 0 na posição 0
5  devolve 1 na posição 1
6  devolve 1 na posição 2
7  devolve 2 na posição 3
8  devolve 55 na posição 10
9  devolve 354224848179261915075 na posição 100
10  rejeita posições negativas
11
12Finished in 0.00781 seconds (files took 0.39706 seconds to load)
137 examples, 0 failures

Quiz: (1) Vermelho (escreva um teste que falha), verde (faça passar do jeito mais simples) e refatorar (arrume mantendo tudo verde). (2) Um teste que nunca falhou pode não estar conferindo nada; vê-lo falhar prova que ele consegue pegar o problema. (3) Quando você confere algo que acontece enquanto o código roda, como raise_error ou change: o RSpec precisa do código como bloco para rodá-lo ele mesmo. (4) O método empty?: o be_xyz chama xyz?. (5) rspec -e "negativo".

Cola rápida

Eu quero…Escreva
Instalar o RSpecgem install rspec
Preparar um projetorspec --init (cria o .rspec e o spec/spec_helper.rb)
Agrupar os testes de algoRSpec.describe "fatorial" do … end
Agrupar testes de uma situaçãocontext "com um número negativo" do … end
Escrever um testeit "devolve 1 para 0" do … end
Conferir um valorexpect(fatorial(0)).to eq(1)
Conferir o contrárioexpect(valor).not_to be_nil
Conferir um erroexpect { codigo }.to raise_error(ArgumentError)
Rodar todos os testesrspec (da pasta que tem a spec/)
Rodar um arquivo / uma linharspec spec/fatorial_spec.rb / rspec spec/fatorial_spec.rb:19
Rodar testes pelo nomerspec -e "negativo"
Mostrar o nome dos testes--format documentation no .rspec

Resumindo

Testar transforma "acho que funciona" em "sei que funciona, e vou saber na hora em que parar de funcionar":

  • O TDD é um ciclo: vermelho (um teste que falha), verde (o código mais simples que passa), refatorar (arrumar mantendo tudo verde).
  • Deixe as falhas guiarem você. Cada mensagem (sem método, argumentos errados, valor errado) diz qual é o próximo passinho.
  • Finja e depois triangule. Acrescente exemplos até que só a solução de verdade consiga passar em todos.
  • Specs se leem como frases: describe o quê, it faz algo, expect(real).to matcher.
  • Os testes são uma rede de segurança: deixam você refatorar com coragem e pegam erros como o nil do intervalo vazio em milissegundos.
  • Pense nos casos extremos: zero, um, valores enormes, entradas negativas ou vazias.
  • Testes unitários são rápidos: cerca de 56 microssegundos por exemplo na nossa medição.

Para mais Ruby, leia O Que É a Linguagem de Programação Ruby?, Arrays em Ruby, Hashes em Ruby e Métodos de String em Ruby.

Obrigado pela leitura!

Conhece alguém que vai gostar? Mande para essa pessoa.

Mais artigos

Leia a seguir

Comentários

Seja gentil e específico. Comentários com links de contas novas aparecem depois da análise de um moderador.