Usando o RSpec para testar sua aplicação Rails - Modelos
Leia em 6 minutos
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
ebe_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á!