Usando o RSpec para testar sua aplicação Rails - Modelos
01/06/08
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_completedebe_valid. Se o método espera qualquer argumentos, basta especificá-los, como embe_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.
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.
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.

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 #
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
Tapajós, sabe que eu não sei? :)
Muito bom! Parabéns novamente!
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. ;)
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???
# 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)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 :)
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
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!
Muito bom. Esperando anciosamente pelos testes em controlers.
Minha aplicação está inteiramente sem testes (shame on me).
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 :)
Deixe um comentário