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:

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.

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á!