Há muito tempo atrás, escrevi um artigo mostrando como testar uma aplicação Rails usando Test::Unit. Muita coisa aconteceu desde então e eu, influenciado pelo Arthur, comecei a usar o RSpec.

O RSpec é um framework BDD (Behaviour-Driven Development ou Desenvolvimento Guiado por Comportamento) escrito em Ruby, que permite que você escreva testes em uma linguagem mais natural, em inglês. O grande problema é que não fará muito sentido se você escrever tudo em português, mas se sua aplicação já é assim, então você deve estar acostumado com o código bilíngue.

Além do RSpec, existe um outro framework muito utilizado chamado Shoulda. Se você se sente confortável com o Test::Unit, vale a pena dar uma olhada.

Criando sua aplicação Rails

Crie uma aplicação chamada "todo" com o comando rails todo. Iremos criar uma lista de tarefas que podem ser marcadas como completadas. Ela será bastante simples, mas nos dará uma idéia de como testar modelos, controllers, helpers e views.

Instalando o RSpec

Para testar códigos Ruby que não fazem parte de uma aplicação Rails, você pode instalar a gem com o comando abaixo.

sudo gem install rspec

Agora, se você quer testar sua aplicação Rails — que é o nosso caso —, você deve seguir um outro caminho. À partir da versão 1.1.4, o RSpec utiliza o Github e para instalá-lo basta clonar seu repositório. Os comandos abaixo foram retirados do wiki do projeto.

cd vendor/plugins
git clone git://github.com/dchelimsky/rspec.git
cd rspec
git checkout 1.1.4
cd ..
git clone git://github.com/dchelimsky/rspec-rails.git
cd rspec-rails
git checkout 1.1.4
cd ../../../

Quando você terminar, execute o comando script/generate rspec para criar os arquivos necessários para o Rails utilizar o RSpec. Esse comando irá criar um diretório "spec", onde ficam localizados todos os arquivos de teste.

Entendendo o problema

Após pensar bastante sobre como nossa aplicação iria funcionar, chegamos aos seguintes requisitos:

  • Uma lista pode ter diversas tarefas
  • Uma lista deve ter um título de até 100 caracteres
  • Uma lista pode ter uma descrição opcional

Com isso em mente, podemos criar nosso modelo List, e escrever os testes antes de adicionar qualquer funcionalidade ao modelo. No RSpec, você possui alguns generators específicos para criar modelos e controllers, por exemplo. Para criar um modelo, você deve usar o generator "rspec_model".

script/generate rspec_model List title:string description:string
rake db:migrate

Ao gerarmos um modelo, o RSpec cria um arquivo de teste em diretório "spec/models/list_spec.rb". Vale lembrar que é possível se testar, separadamente, modelos, controllers, helpers e views, deixando tudo muito mais organizado.

Abra o arquivo arquivo "list_spec.rb", que já possui um teste — melhor ainda, uma especificação — criado automaticamente. No RSpec, uma especificação pode ser escrita usando o método it. Esse método deve estar associado a um outro método chamado describe, que terá diversas especificações para validar um determinado contexto.

require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
 
describe List do
  before(:each) do
    @list = List.new
  end
 
  it "should be valid" do
    @list.should be_valid
  end
end

Vamos substituir este código por especificações reais que validem nosso modelo, já pensando em como evitar qualquer duplicação de código.

require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
 
describe List do
  fixtures :lists
 
  it "should be valid" do
    lambda {
      list = create_list
      list.should be_valid
      violated "#{list.errors.full_messages.to_sentence}" if list.new_record?
    }.should change(List, :count).by(1)
  end
 
  it "should be invalid without a title" do
    lambda {
      list = create_list(:title => nil)
      list.errors.should be_invalid(:title)
    }.should_not change(List, :count)
  end
 
  it "should be invalid with long titles" do
    lambda {
      list = create_list(:title => "a" * (List::TITLE_MAX_LENGTH + 1))
      list.errors.should be_invalid(:title)
    }.should_not change(List, :count)
  end
 
  it "should accept description when provided available" do
    lambda {
      list = create_list(:description => "Things I need to buy at the supermarket")
      list.description.should_not be_nil
      list.should be_valid
    }.should change(List, :count)
  end
 
  it "should have many tasks" do
    lambda { lists(:work).tasks }.should_not raise_error
  end
 
  private
    def create_list(options={})
      List.create({
        :title => 'To buy'
      }.merge(options))
    end
end
 
 
Dica
Todos os métodos terminados em "?" (invalid?, completed?, valid?), podem ser chamados com o matcher be_<nome do método>, como be_invalid, be_completed e be_valid. Se o método espera qualquer argumentos, basta especificá-los, como em be_invalid(:title).

Nossas especificações utilizam fixtures. Por isso, vamos adicionar alguns itens ao arquivo "spec/fixtures/lists.yml".

supermarket:
  title: Supermarket
  description: Things I need to buy
 
work:
  title: Work
  description: Things to be done until Friday

Para rodar os testes, execute o comando "rake spec". Ele irá executar cada um dos arquivos de especificações presentes no diretório "spec".

exception:todo fnando$ rake spec
(in /Users/fnando/Sites/todo)
......F.FF.

1)
'List should have many tasks' FAILED
expected no Exception, got #<NoMethodError: undefined method `tasks' for #<List:0x230c6ac>>
./spec/models/list_spec.rb:37:

2)
NameError in 'List should be invalid with long titles'
uninitialized constant List::TITLE_MAX_LENGTH
/Library/Ruby/Gems/1.8/gems/activesupport-2.0.2/lib/active_support/dependencies.rb:478:in `const_missing'
./spec/models/list_spec.rb:23:
./spec/models/list_spec.rb:22:

3)
'List should be invalid without a title' FAILED
expected invalid?(:title) to return true, got false
./spec/models/list_spec.rb:17:
./spec/models/list_spec.rb:15:

Finished in 0.244484 seconds

5 examples, 3 failures

Os usuários do Textmate devem instalar o bundle RSpec; além de possuir os snippets, possui um comando que roda as especificações de maneira muito mais simples. Para instalá-lo, execute os comandos abaixo.

mkdir -p ~/Library/Application\ Support/TextMate/Bundles/
cd ~/Library/Application\ Support/TextMate/Bundles/
svn co svn://rubyforge.org/var/svn/rspec/trunk/RSpec.tmbundle

Depois de instalado, você pode executar todas as especificações com o atalho ⌘+R. Se preferir, pode executar uma única especificação com o atalho ⌘+⇧+R. Ambos os atalhos irão abrir uma janela como esta.

Janela do Textmate rodando os testes com RSpec

Para corrigir as falhas que nossas especificações geraram, vamos adicionar algum código em nosso modelo. A falha #3 é a mais simples de ser corrigida; basta adicionarmos uma chamada ao método validates_presence_of.

class List < ActiveRecord::Base
  validates_presence_of :title
end
 

A falha #2 espera utiliza uma constante que não existe; vamos adicioná-la com o número máximo de caracteres que o título de uma lista pode ter.

class List < ActiveRecord::Base
  TITLE_MAX_LENGTH = 100
 
  validates_presence_of :title
end
 

A falha #1 valida um relacionamento com o modelo Task, que ainda não foi criado, então vamos deixá-lo ali por enquanto. Execute novamente as especificações para ver o que precisa ser corrigido.

1)
'List should have many tasks' FAILED
expected no Exception, got #<NoMethodError: undefined method `tasks' for #<List:0x230bd38>>
./spec/models/list_spec.rb:37:
 
2)
'List should be invalid with long titles' FAILED
expected invalid?(:title) to return true, got false
./spec/models/list_spec.rb:24:
./spec/models/list_spec.rb:22:
 
Finished in 0.154639 seconds
 
5 examples, 2 failures

Temos agora, que adicionar algum código que corrija a falha #2; isso pode ser feito se adicionarmos uma chamada ao método validates_length_of.

class List < ActiveRecord::Base
  TITLE_MAX_LENGTH = 100
 
  validates_presence_of :title
  validates_length_of :title, :maximum => TITLE_MAX_LENGTH
end
 

O próximo passo é criar um modelo chamado Task. Crie tal modelo com o comando abaixo.

script/generate rspec_model Task title:string completed_at:datetime list:references

Agora, temos de definir quais são os requisitos deste nosso novo modelo.

  • Uma tarefa deve ter um título
  • Uma tarefa pode ser marcada como completada e deve marcar a hora que isso aconteceu
  • Uma tarefa deve estar associada a uma lista

Baseado nestes requisitos, podemos ter as seguintes especificações:

require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
 
describe Task do
  fixtures :tasks, :lists
 
  it "should be valid" do
    lambda {
      list = create_task
      list.should be_valid
      violated "#{list.errors.full_messages.to_sentence}" if list.new_record?
    }.should change(Task, :count)
  end
 
  it "should be invalid without a title" do
    lambda {
      task = create_task(:title => nil)
      task.errors.should be_invalid(:title)
    }.should_not change(Task, :count)
  end
 
  it "should be invalid without a list" do
    lambda {
      task = create_task(:list => nil)
      task.errors.should be_invalid(:list)
    }.should_not change(Task, :count)
  end
 
  it "should update completed_at when is done" do
    @now = Time.now
    Time.should_receive(:now).at_least(:once).and_return(@now)
 
    task = create_task
    task.complete!.should be_true
    task.completed_at.to_s.should == @now.to_s
  end
 
  it "should be completed" do
    task = create_task
    task.complete!
    task.should be_completed
  end
 
  it "should belong to a list" do
    lambda { tasks(:milk).list }.should_not raise_error
  end
 
  private
    def create_task(options={})
      Task.create({
        :title => "Milk",
        :list => lists(:supermarket)
      }.merge(options))
    end
end
 
 

E o nosso arquivo de fixtures:

milk:
  title: Milk
  list: supermarket
 
export_database:
  title: Export database
  list: work

Ele possui muita semelhança com as especificações criadas para o modelo List, mas a linha Time.should_receive(:now).at_least(:once).and_return(@now) pode confundir os mais desatentos. O método should_receive espera que o método especificado seja chamado (no nosso caso, pelo menos uma vez) e deve retornar o objeto @now. Isso é muito importante de ser feito, principalmente quando você precisa comparar datas e quer ter certeza que tudo está saindo como você espera. É muito importante que você defina o valor retornado antes de criar o stub, por motivos óbvios.

Para que nossas especificações sejam validas, precisamos modificar nosso modelo.

class Task < ActiveRecord::Base
  belongs_to :list
 
  validates_presence_of :title, :list, :list_id
  validates_associated :list
 
  def complete!
    update_attribute(:completed_at, Time.now)
  end
 
  def completed?
    !completed_at.blank?
  end
end
 

Agora que nosso modelo Task foi criado, podemos alterar o modelo List e adicionar o relacionamento has_many.

class List < ActiveRecord::Base
  TITLE_MAX_LENGTH = 100
 
  validates_presence_of :title
  validates_length_of :title, :maximum => TITLE_MAX_LENGTH
 
  has_many :tasks
end
 

Se você rodar os testes mais uma vez, verá que todas as especificações passaram! Agora, execute o comando rake spec:rcov para ver qual a cobertura de seu código, utilizando o RCov. Abra o arquivo "coverage/index.html" visualizar o arquivo gerado.

Cobertura de código com o RSpec

Acostume-se a utilizar o RCov: ele não é perfeito, mas funciona muito bem na maioria das vezes.

Uma outra coisa importante de se verificar é qual a proporção de testes escritos em relação ao código. Isso pode ser verificado com o comando rake stats. No nosso aplicativo, esta relação é de 3.7 linhas de teste escritos para cada linha de código, o que é excelente.

Rake stats

No Brasigo, temos trabalhado com 1:3 ± 0.3; isso significa que a proporção aceitável está entre 2.7 e 3.3 linhas de teste para cada linha de código. A cobertura atual é de 100%.

E agora?

Neste artigo você aprendeu a validar um modelo. Na segunda parte, vamos ver como testar os controllers. Já comecei a escrever e, se tudo der certo, publico até o fim da semana. Até lá!

Comentários #

#1 Tapajós disse:
01 Jun 08, 09:09PM

Nando, parabéns pelo artigo. Venho utilizando RSpec já tem algum tempo e realmente é muito bom!
Acho que esse é o único artigo em português sobre RSpec, não?

Um abraço

#2 Nando Vieira disse:
01 Jun 08, 10:44PM

Tapajós, sabe que eu não sei? :)

#3 Davis Zanetti Cabral disse:
01 Jun 08, 11:18PM

Muito bom! Parabéns novamente!

#4 Charleno Pires disse:
02 Jun 08, 12:50AM

Nando, parabéns pelo post, realmente muito bom, e a abordagem do assunto foi bem didática :D

mas, tenho uma observação, acho que em
script/generate rspec_model title:string completed_at:datetime list:references ,você quis dizer ::
script/generate rspec_model Task title:string completed_at:datetime list:references ???
Acho que faltou o Task ali. ;)

#5 Charleno Pires disse:
02 Jun 08, 01:00AM

Nando, testando aqui com o Rails 2.1 no Ubuntu 8.04, tive o seguinte resultado:

1)
NoMethodError in 'List should be invalid with long titles'
undefined method `TITLE_MAX_LENGTH' for #
./spec/models/list_spec.rb:34:
./spec/models/list_spec.rb:33:

2)
'Task should update completed_at when is done' FAILED
expected: "Mon Jun 02 03:58:29 UTC 2008",
got: "2008-06-02 03:58:29 UTC" (using ==)
./spec/models/task_spec.rb:34:

Finished in 0.623409 seconds

11 examples, 2 failures

O que acha que pode ser???

#6 Nando Vieira disse:
02 Jun 08, 06:43AM

# 4 Charleno, faltou sim! Já atualizei o texto! ;)

#5 Charleno, o primeiro erro acontece porque a constante TITLE_MAX_LENGTH não está sendo encontrada; você está fazendo a chamada com List::TITLE_MAX_LENGTH? A constante foi definida no modelo List?

Para evitar o segundo erro, experimente comparar duas strings formatadas; algo como task.completed_at.to_s(:db).should == @now.to_s(:db)

#7 Thiago Freire disse:
02 Jun 08, 07:36AM

Muito bom o post! Tava procurando por algo desse tipo, mas mesmo em inglês tem poucos. Tomara que fique tão grande quanto o antigo de TDD :)

#8 Charleno Pires disse:
02 Jun 08, 08:53AM

Já sei o que foi, em (List::TITLE_MAX_LENGTH 1)) faltou o + => (List::TITLE_MAX_LENGTH + 1))

Realmente, trocando o task.completed_at.to_s.should == @now.to_s por
task.completed_at.to_s(:db).should == @now.to_s(:db)

Resultado :: 11 examples, 0 failures

#9 Carlos Brando disse:
03 Jun 08, 09:04AM

Excelente. Tive de aprender rspec faz pouco tempo... queria ter tido um artigo assim na época. Mas ainda assim vai ser legar acompanhar esta série para ver se aprendi certo!

#10 Mauricio disse:
03 Jun 08, 06:34PM

Muito bom. Esperando anciosamente pelos testes em controlers.

Minha aplicação está inteiramente sem testes (shame on me).

#11 Thiago Freire disse:
09 Jun 08, 02:30AM

Oi, descobri que é só colocar --format html que dá justamente aquela saída do TextMate. Considerando sua experiência, você não poderia fazer um plugin parecido pro gedit?
Senão, faço eu mesmo, daqui a alguns anos :)

#12 andre fonseca disse:
21 Out 08, 12:33AM

Po nando, publica ae o artigo de testes nos controllers :-)
falta pouco pra acabar heheh

Valeu parcero. Galera aqui toda vai comecar a estudar rspec por esse post!

#13 Thiago disse:
06 Nov 08, 03:23PM

Poderia esplicar melhorar esses
should change(List, :count).by(1)
should_not change(List, :count) ?

É realmente necessário o lambda para testar se uma lista está sem title? As duas linhas internas ao lambdas, sem o mesmo, já não resolveria?

#14 Nando Vieira disse:
06 Nov 08, 08:57PM

Thiago, não é necessário não! Depois de tanto tempo, mudei completamente a maneira como escrevo minhas specs. Preciso escrever um novo post falando sobre isso! :P

Sobre o "should change" e "should_not change": ele irá armazenar o valor passado para "change" e após a execução do bloco esse valor deve mudar ou não, baseado no incremento passado por "by".

#15 Iniciando com RSpec. | Ustra Júnior disse:
17 Ago 09, 03:02PM

[...] seguindo um tutorial do Nando Viera do blog Simples Ideias e achei muito interessante. Clique aqui para ir para o tutorial. Eu quero primeiro dar uma olhada no RSpec porque pelo que eu vi ele faz um [...]

#16 edipo disse:
23 Set 09, 10:54AM

Ola Nando otimo post, muito bom, so uma duvida, vc teria alguma dica de onde econtrar algum material mais detalhado sobre alguns aspectos usados nesse posts como os should change" e "should_not change.. Obrigaodo e parabêns.

#17 Minhas impressões -- Sexto encontro GURU-SP -- 26-09-0... disse:
28 Set 09, 02:22PM

[...] foi a rapidez na construção das aplicações, pois muita coisa já está pronta (Scaffolds). RSpec e Shoulda foram citados de maneira breve nesse horário (test units). Vimos como funciona a escrita [...]

#18 Binho disse:
25 Abr 10, 10:30AM

Olá,

Não entendi a lógica usada na validação do tamanho do título, Nando.

Tanto que quero fazer uma validação para tamanho mínimo e não consigo.

Poderia explicar?

#19 Nando Vieira disse:
25 Abr 10, 11:05AM

Binho, para testar o tamanho mínimo, você precisa apenas pegar a quantidade de caracteres aceita -1. Por exemplo, se você espera que o campo aceite no mínimo 10 caracteres, pode fazer assim:

subject.title = "a" * 9
subject.should_not be_valid
subject.errors.should be_invalid(:title)

O que deve ter ficado confuso é que estou usando a constante e o método *: http://ruby-doc.org/core/classes/String.html#M000769

#20 Binho disse:
27 Abr 10, 12:11PM

Certinho agora!

Valeu pela explicação, Nando!

#21 Relacionamentos e múltiplos objetos com Factory Girl « Rin... disse:
30 Maio 10, 10:33PM

[...] usar o instalar e usar o Rspec em seu projeto, veja este post do Nando Vieira Após a instalação e configuração, não esqueça de executar o comando [...]

#22 Como convencer de que é bom testar ? « Milk on Rails disse:
01 Dez 10, 07:05PM

[...] Simples Ideias -- Usando o RSpec para testar sua aplicação Rails [...]

Deixe um comentário





Não é aceito código HTML: adicione-o no pastie.org ou paste.milk-it.net e poste apenas o link.

Se este é seu primeiro comentário, ele terá que ser aprovado antes de ser exibido.

jQuery: Dominando o framework

Você quer aprender a usar jQuery de verdade? Então chegou a hora! Neste workshop você verá como funciona este framework de JavaScript, entendendo todos os aspectos que fazem do jQuery uma das melhores ferramentas para desenvolvimento de interfaces.

Saiba mais Fechar

Conheça também o HOWTO