Acabando com os lambdas do RSpec

04/07/08

Quando você começa a usar o RSpec, cedo ou tarde acabará usando o método lambda. Veja o exemplo abaixo.

it "should become 'from uk' style" do
  lambda { @emo.become_from_uk! }.should change(@emo, :from_uk)
end

O bloco lambda, após sua execução, deverá alterar o valor do atributo from_uk. O problema é que ele não combina com o RSpec. Ele é, como posso dizer, deselegante. Uma maneira bem legal de fazer isso é criando um alias.

alias :doing :lambda
it "should become 'from uk' style" do
  doing { @emo.become_from_uk! }.should change(@emo, :from_uk)
end

Muito melhor, não acha? :)

Mudança no RSpec 1.1.4 remove inclusão automática de módulos

25/06/08

No RSpec 1.1.4, a inclusão de módulos deixou de ser automática. Você só irá perceber esta mudança se está testando algum módulo, como é o caso dos helpers.

Antes, você só precisava fazer algo como isto:

describe ApplicationHelper do
  it "should render flash[:notice]" do
    flash[:notice] = "Some notice"
    flash_messages.should have_tag('p.notice', 'Some notice')
  end
end

Nesta nova versão, se você tentar testar o método flash_messages, irá receber uma mensagem de aviso: Modules will no longer be automatically included in RSpec version 1.1.4. Called from ./spec/helpers/application_helper_spec.rb:6

A solução é fazer a inclusão manual dos módulos, como este exemplo

describe ApplicationHelper do
  include ApplicationHelper
 
  it "should render flash[:notice]" do
    flash[:notice] = "Some notice"
    flash_messages.should have_tag('p.notice', 'Some notice')
  end
end

ou utilizar o objeto helper, que possui todos os métodos do módulo especificado em describe

describe ApplicationHelper do
  it "should render flash[:notice]" do
    flash[:notice] = "Some notice"
    helper.flash_messages.should have_tag('p.notice', 'Some notice')
  end
end

Em um primeiro momento, eu não tinha gostado nem um pouco desta alteração, mas depois de ler os motivos, acho que ficou muito melhor. Você teria dúvidas de que está testando um helper se utilizar helper.flash_messages? E se você tivesse um módulo com um método describe? As respostas já justificam a alteração por si sós.

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