Test-Driven Development no Rails: Unit Tests

11/05/07

No modelo Post, devemos escrever testes para validar os seguintes itens:

  • um autor pode ter inúmeros posts
  • os comentários podem ser permitidos ou não
  • o resumo é opcional, mas se for informado não deve ultrapassar 250 caracteres

Como ainda não temos o modelo Post, vamos criá-lo:

script/generate model Post

Abra o arquivo de migração 002_create_posts.rb e adicione o código abaixo.

class CreatePosts < ActiveRecord::Migration
  def self.up
    create_table :posts do |t|
      t.column :title, :string, :limit => 250, :nil => false
      t.column :excerpt, :string, :limit => 250, :nil => true
      t.column :body, :text, :nil => false
      t.column :created_at, :datetime
      t.column :updated_at, :datetime
      t.column :allow_comments, :boolean, :default => true, :nil => false
      t.column :user_id, :integer, :nil => false
    end
  end
 
  def self.down
    drop_table :posts
  end
end

O código acima dispensa maiores explicações. Execute o comando rake db:migrate para criarmos a tabela de posts.

~/blog$ script/generate model Post
      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/post.rb
      create  test/unit/post_test.rb
      create  test/fixtures/posts.yml
      exists  db/migrate
      create  db/migrate/002_create_posts.rb

Já podemos criar os testes necessários para validar o modelo de posts. Como nossos testes dependem do modelo User — o post pertece a um autor — temos que carregar alguns usuários no banco de dados. Isso pode ser feito com fixtures.

O que são fixtures?
Fixtures são conteúdos de um modelo — ou modelos — que serão carregados no banco de dados para a execução dos testes.

As fixtures podem ser carregadas através de SQL (INSERT INTO ...), arquivos CSV ou, preferencialmente, arquivos YAML. Cada arquivo YAML de conter dados de um único modelo. O nome do arquivo de fixtures deve ser igual ao nome da tabela do banco de dados com a extensão .yml. O Rails cria estes arquivos para você, automaticamente, toda vez que você cria uma migração ou modelo.

# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
one:
  id: 1
two:
  id: 2

O arquivo de fixtures é composto por diversos blocos que são equivalentes a registros do banco de dados. Lembre-se: use tabulação separadas por espaços. Vamos editar o arquivo "test/fixtures/users.yml" para adicionar alguns usuários válidos.

bart:
  id: 1
  name: Bart Simpson
  email: bart@simpsons.com
  password: test

krusty:
  id: 2
  name: Krusty The Clown
  email: krusty@simpsons.com
  password: test

Agora, abra o arquivo "test/unit/post_test.rb" e carregue as fixtures de usuários.

fixtures :posts, :users

O mais interessante de se utilizar fixtures é que você recebe automaticamente um método com o mesmo nome da tabela de banco de dados e cada registro pode ser acessado pelo nome — bart e krusty, no nosso caso — que você definiu no arquivo de fixtures. Utilize nomes significativos sempre que puder.

Vamos aproveitar e já criar algumas fixtures de posts. Abra o arquivo "test/unit/fixtures/posts.yml" e adicione o texto abaixo.

rails_rules:
  id: 1
  title: Rails rules
  body: Rails is a killer framework built with Ruby
  created_at: <%= Time.now %>
  updated_at: <%= Time.now %>
  user_id: 1
  allow_comments: false

ruby_rules:
  id: 2
  title: Ruby also rules
  body: Ruby is a charming language
  created_at: <%= Time.now %>
  updated_at: <%= Time.now %>
  user_id: 1
  allow_comments: true

Sim, você pode utilizar código Ruby dentro do arquivo de fixtures! Isso é extramente útil quando você precisa chamar algum método de um modelo (para criptografar a senha, por exemplo) ou trabalhar com datas, como é o nosso caso.

Vamos preparar a nossa classe, adicionando o método create, da mesma maneira que criamos nos testes do modelo User.

require File.dirname(__FILE__) + '/../test_helper'
 
class PostTest < Test::Unit::TestCase
  fixtures :posts, :users
 
  # Replace this with your real tests.
  def test_truth
    assert true
  end
  
  private
    def create(options={})
      Post.create({
        :title => 'Title',
        :excerpt => 'Excerpt',
        :body => 'Body',
        :allow_comments => true,
        :user_id => 1
      }.merge(options))
    end
end

Nossos primeiros teste irão validar os campos obrigatórios.

def test_should_be_invalid
  post = create(:title => nil, :excerpt => nil, 
      :body => nil, :allow_comments => nil, :user_id => nil)
  assert_invalid post, "Post shouldn't be created"
end
 
def test_should_require_title
  post = create(:title => nil)
  assert post.errors.invalid?(:title), ":title should be required"
  assert_invalid post, "Post shouldn't be created"
end
 
def test_should_require_body
  post = create(:body => nil)
  assert post.errors.invalid?(:body), ":body should be required"
  assert_invalid post, "Post shouldn't be created"
end
 
def test_should_require_author
  post = create(:user_id => nil)
  assert post.errors.invalid?(:user_id), ":user_id should be required"
  assert_invalid post, "Post shouldn't be created"
end

O resumo pode ter no máximo 250 caracteres mas é opcional. Então vamos aos testes.

def test_should_accept_excerpt
  post = create(:excerpt => 'Testing excerpt')
  deny post.errors.invalid?(:excerpt), ":excerpt should have been valid"
  assert_valid post
end
 
def test_should_deny_long_excerpt
  post = create(:excerpt => "a" * 251)
  assert post.errors.invalid?(:excerpt), ":excerpt should have had an error"
  assert_invalid post, "Post shouldn't be created"
end

Temos que verificar agora se o usuário existe e se o post foi corretamente associado a ele. Nossos testes:

def test_should_deny_non_integer_user
  post = create(:user_id => 'a')
  assert post.errors.invalid?(:user_id), ":user_id should have had an error"
  assert_invalid post, "Post shouldn't be created"
  
  post = create(:user_id => 1.397)
  assert post.errors.invalid?(:user_id), ":user_id should have had an error"
  assert_invalid post, "Post shouldn't be created"
end

def test_should_check_post_authorship
  # check all fixtures were loaded
  assert_equal 2, users(:bart).posts.size, "user should have had 2 posts"
  
  # assign a post without user_id
  post = create(:user_id => nil)
  
  # then, assign a post using the relationship method
  users(:bart).posts << post
  
  #now, check if user have one more post
  assert_equal 3, users(:bart).posts.size, "user should have had 3 posts"
  
  # assign a post to a user that doesn't exist
  post = create(:user_id => 100)
  assert post.errors.invalid?(:user), "User doesn't exist, so it should be required"
end

E aqui temos um novo método de asserção: assert_equal. Esse método verifica se dois valores são iguais. Veja alguns métodos de asserção que você pode usar.

assert(boolean, message)
Se o parâmetro boolean for nil ou false a asserção irá falhar.
assert_equal(expected, actual, message)
assert_not_equal(expected, actual, message)
A asserção irá falhar a menos que expected e actual sejam iguais/diferentes.
assert_nil(object, message)
assert_not_nil(object, message)
A asserção irá falhar a menos que object seja/não seja nil.
assert_raise(Exception, ..., message) { block… }
assert_not_raise(Exception, ..., message) { block… }
A asserção irá falhar a menos que block dispare/não dispare um erro da exceção especificada.
assert_match(pattern, string, message)
assert_no_match(pattern, string, message)
A asserção irá falhar a menos que string seja/não seja correspondente à expressão regular pattern.
assert_valid(record)
Falha a menos que record não tenha erros de validação.

Na parte dois deste artigo você verá outros métodos de asserção disponíveis para testes dos controllers.

E ao rodarmos os testes unitários, temos…

~/blog$ rake test:units
Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
Started
.FEFFFFF.......
Finished in 0.098366 seconds.

  1) Failure:
test_should_be_invalid(PostTest)
    [./test/unit/../test_helper.rb:30:in `deny'
     ./test/unit/../test_helper.rb:34:in `assert_invalid'
     ./test/unit/post_test.rb:9:in `test_should_be_invalid']:
Post shouldn't be created.
<false> is not true.

  2) Error:
test_should_check_post_authorship(PostTest):
NoMethodError: undefined method `posts' for #<User:0xb7334d2c>
    /usr/lib/ruby/gems/1.8/gems/activerecord-1.15.3/lib/active_record/base.rb:1860:in `method_missing'
    ./test/unit/post_test.rb:49:in `test_should_check_post_authorship'

  3) Failure:
test_should_deny_long_excerpt(PostTest) [./test/unit/post_test.rb:38]:
:excerpt should have had an error.
<false> is not true.

  4) Failure:
test_should_deny_non_integer_user(PostTest)
    [./test/unit/../test_helper.rb:30:in `deny'
     ./test/unit/../test_helper.rb:34:in `assert_invalid'
     ./test/unit/post_test.rb:44:in `test_should_deny_non_integer_user']:
Post shouldn't be created.
<false> is not true.

  5) Failure:
test_should_require_author(PostTest) [./test/unit/post_test.rb:26]:
:user_id should be required.
<false> is not true.

  6) Failure:
test_should_require_body(PostTest) [./test/unit/post_test.rb:20]:
:body should be required.
<false> is not true.

  7) Failure:
test_should_require_title(PostTest) [./test/unit/post_test.rb:14]:
:title should be required.
<false> is not true.

15 tests, 21 assertions, 6 failures, 1 errors

… uma verdadeira catástrofe! Um erro no teste test_should_check_post_authorship nos diz que o método posts não existe. Mas parando para pensar, faz todo sentido, já que nós ainda não definimos o relacionamento entre os modelos. Vamos tratar este erro apenas colocando o relacionamento no modelo User.

class User < ActiveRecord::Base
  has_many :posts, :dependent => :destroy
  
  #[...]
end

Note que apenas exibi o código relevante a esta alteração; as validações anteriores permanecem e são representadas aqui por #[...]. Após adicionar esta linha, você já tem o relacionamento entre posts e usuários e se você rodar os testes agora, apenas as falhas serão exibidas.

~/blog$ rake test:units
Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
Started
.FFFFFFF........
Finished in 0.142015 seconds.

  1) Failure:
test_should_be_invalid(PostTest)
    [./test/unit/../test_helper.rb:29:in `deny'
     ./test/unit/../test_helper.rb:33:in `assert_invalid'
     ./test/unit/post_test.rb:9:in `test_should_be_invalid']:
Post shouldn't be created.
<false> is not true.

  2) Failure:
test_should_check_post_authorship(PostTest) [./test/unit/post_test.rb:63]:
User doesn't exist, so it should be required.
<false> is not true.

  3) Failure:
test_should_deny_long_excerpt(PostTest) [./test/unit/post_test.rb:38]:
:excerpt should have had an error.
<false> is not true.

  4) Failure:
test_should_deny_non_number_user(PostTest) [./test/unit/post_test.rb:44]:
:user_id should have had an error.
<false> is not true.

  5) Failure:
test_should_require_body(PostTest) [./test/unit/post_test.rb:20]:
:body should be required.
<false> is not true.

  6) Failure:
test_should_require_title(PostTest) [./test/unit/post_test.rb:14]:
:title should be required.
<false> is not true.

  7) Failure:
test_should_require_user(PostTest) [./test/unit/post_test.rb:26]:
:user_id should be required.
<false> is not true.

16 tests, 25 assertions, 7 failures, 0 errors

Vamos às validações mais triviais utilizando o método validates_presence_of.

class Post < ActiveRecord::Base
  validates_presence_of :title
  validates_presence_of :body
  validates_presence_of :user_id
end
~/blog$ rake test:units
Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
Started
..FFF...........
Finished in 0.133536 seconds.

  1) Failure:
test_should_check_post_authorship(PostTest) [./test/unit/post_test.rb:63]:
User doesn't exist, so it should be required.
<false> is not true.

  2) Failure:
test_should_deny_long_excerpt(PostTest) [./test/unit/post_test.rb:38]:
:excerpt should have had an error.
<false> is not true.

  3) Failure:
test_should_deny_non_number_user(PostTest) [./test/unit/post_test.rb:44]:
:user_id should have had an error.
<false> is not true.

16 tests, 28 assertions, 3 failures, 0 errors

A coisa já melhorou bastante. As três falhas restantes são relativamente simples de resolver. Primeiro vamos verificar se o user_id é um número.

validates_numericality_of :user_id, :only_integer => true

A falha relativa ao tamanho do resumo pode ser resolvido com uma validação como esta:

validates_length_of :excerpt, :maximum => 250, :if => :check_excerpt?
 
private
  def check_excerpt?
    !self.excerpt.blank?
  end

E agora, só mais uma falha para corrigir. Estamos ficando bons nisso!

~/blog$ rake test:units
Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
Started
..F.............
Finished in 0.149596 seconds.

  1) Failure:
test_should_check_post_authorship(PostTest) [./test/unit/post_test.rb:67]:
User doesn't exist, so it should be required.
<false> is not true.

16 tests, 32 assertions, 1 failures, 0 errors

Para corrigir esta falha, você deve primeiro definir que um post está associado a um usuário. Nós fizemos apenas o outro caminho, dizendo que um usuário possui diversos posts. Altere o seu modelo Post, adicionando o relacionamento belongs_to :user. Agora, você poderá adicionar as validações relativas a esta falha.

class Post < ActiveRecord::Base
  belongs_to :user
  
  validates_associated :user
  validates_presence_of :user
  #[..]
end

Perceba que estamos validando a presença do atributo/método user e não user_id. A mesma coisa está sendo feita na segunda parte do teste test_should_check_post_authorship. Isso deve ser feito para se validar a associação entre um post e um usuário, de modo que o usuário deve realmente existir; caso contrário, teriamos uma associação incorreta no teste, já que o usuário com id 100 não existe.

Parabéns! Mais um modelo foi devidamente testado.

~/blog$ rake test:units
Loaded suite /usr/lib/ruby/1.8/rake/rake_test_loader
Started
................
Finished in 0.141916 seconds.

16 tests, 32 assertions, 0 failures, 0 errors

Pages: 1 2 3 4

Comentários #


#1 Davis Zanetti Cabral disse:
11 Maio 07, 09:28AM

Muito bom o artigo. Uma dica para visualizar melhor os testes é o uso do RadRails ou então a instalação do gem RedGreen. Dá uma força bacana na hora de visualizar.

Abraço!

#2 TDD on Rails « Coding Dojo Floripa disse:
11 Maio 07, 09:36AM

[...] Vale a pena conferir. [...]

#3 Klaus Paiva disse:
11 Maio 07, 09:44AM

Muito bom (Ferdi)Nando!

Matou a pau no texto! Ficou muito claro e didático. O trio migrate, tests e fixtures (que eu realmente só conhecia por pouco mais do que o nome) agora me parecem muito mais interessantes!

Valeu! :D

#4 Silfar disse:
11 Maio 07, 10:07AM

Muito show, mas me tira uma dúvida:

def test_should_require_longer_password
user = create(:password => 't')
assert user.errors.invalid?(:password), ":password should be 4 characters or longer"
assert_invalid user, "User shouldn't be created"
end

Pq usar as duas linhas:

assert user.errors….
assert_invalid user……

não entendi pq precisa das duas. Pode me explicar ?

#5 Nando Vieira disse:
11 Maio 07, 10:10AM

Silfar, não precisa. Mas é como eu falei: você pode deixar o seu teste tão completo quanto você queira. Paranoicamente, eu poderia ainda ter adicionado uma asserção para verificar se uma mensagem de erro foi definida. Precisa? Teoricamente, não. Mas é tão simples de fazer que eu realmente não me importo. Ah, mencionei que eu uso snippets para meus testes e que eu só preciso colocar "ai + tab"? :)

#6 Eduardo Fiorezi disse:
11 Maio 07, 11:29AM

Nando, parabéns pela excelente abordagem.

Igual vc falou no comentário, com TDD não é necessário cobrir "100%" das situações, mas é bom cobrir todas necessárias naquele momento… Ficar imaginando tudo que pode acontecer é prejudicial e essas milhões de situações imaginadas talvez nunca apareçam.

Espero os próximos…

#7 Shairon disse:
11 Maio 07, 11:59AM

Muito bom. Estou ansioso para ver o de funcionalidade

#8 Luciano Pacheco disse:
11 Maio 07, 02:30PM

def test_should_require_name
comment = create(:name = nil)
assert comment.errors.invalid?(:name), ":name should have had an error"
assert_invalid comment, "Comment shouldn't be created"
end

":name should have had an error"

Nas mensagens não devemos utilizar mensagens mais explicativa, tipo "name can't be blank" ou algo parecido ?

Estou perguntando, pois também estou iniciando nesse mundo de XP e TDD. :)

Valeu pelo artigo. ;)

#9 Nando Vieira disse:
11 Maio 07, 03:06PM

@Luciano: Melhor do que isso, você pode fazer algo assim:

assert comment.errors.invalid?(:name), ":name should have had an error\n#{comment.errors.full_messages.to_sentence}"

Você decide como quer fazer! ;)

#10 Renato Elias disse:
11 Maio 07, 03:29PM

Mandou bem nando ! Vlw, como você me falou no "praianha" ruby é babaca de tão fácil =)

#11 Diogo Lopes disse:
19 Jun 07, 11:23AM

Parabens cara… nao sei como vim parar aqui, mas estou assinando.
Tem muita gente falando que ABC , XCD , ZZZ é bacana, fundamental, etc… Mas tu mostrou na pratica que vale a pena.
E tudo em Ruby, que é melhor ainda. (até hj, só tinha visto TDD em JAVA em pt)

abcs!

#12 rapha disse:
23 Jun 07, 06:28AM

Fantástico velho. Acabei de escrever os testes de um projeto aqui, e realmente facilita a vida. Agora eu fico muito mais seguro de implementar qualquer funcionalidade, porque eu sei que posso testar tudo de novo em um instante. :D

Rails sem tdd? Nem a pau!

#13 Nome do Jogo » Blog Archive » Vamos falar sobre teste... disse:
05 Out 07, 04:36PM

[...] aprender mais sobre como funcionam os teste no Rails você pode clicar aqui e aqui. E lembrem-se: "Quem não testar é mulher do [...]

#14 davi disse:
15 Nov 07, 04:05PM

Excelente artigo, Nando… :-)

Estou usando isso e o Agile Web Development With Rails e estou gostando.

Mas eu vi uma coisa no livro que você não citou (para simplificar?), o Hash default_error_messages do ActiveRecord::Errors:

@@default_error_messages = {
:inclusion => "is not included in the list",
:exclusion => "is reserved",
:invalid => "is invalid",
:confirmation => "doesn't match confirmation",
:accepted => "must be accepted",
:empty => "can't be empty",
:blank => "can't be blank",
:too_long => "is too long (maximum is %d characters)",
:too_short => "is too short (minimum is %d characters)",
:wrong_length => "is the wrong length (should be %d characters)",
:taken => "has already been taken",
:not_a_number => "is not a number"
}

Eu coloquei o seguinte no meu RAILS_ROOT/test/test_helper.rb:

def assert_activerecord_errors(expected, got, message=")
assert_equal ActiveRecord::Errors.default_error_messages[expected], got, message
end

E agora posso testar as mensagens de erro usando um "simples" assert_activerecord_errors :taken, OBJECT.errors.on(:name).

Abraço

#15 Nando Vieira disse:
15 Nov 07, 04:27PM

@davi, como eu altero as mensagens-padrão do Rails através do atributo :messages, eu utilizo outra forma para validar tais mensagens. Dá uma olhada nos meus helpers[1], para ver como ficou!

[1] http://pastie.caboo.se/114671

#16 Simples Idéias. Por Nando Vieira. » Arquivo » Usando... disse:
01 Jun 08, 11:13PM

[...] muito tempo atrás, escrevi um artigo mostrando como testar uma aplicação Rails usando Test::Unit. Muita coisa aconteceu desde então e [...]

#17 Frolim disse:
24 Jul 08, 12:08PM

Ficou muito bom!
Finalmente iniciei testes unitários com a sua explicação.
Vlw!

#18 Test Helpers - ArthurGeek.net disse:
04 Set 08, 01:15AM

[...] e se você ainda não entrou nessa de TDD, dê uma olhada neste artigo escrito pelo Nando Vieira sobre Unit Tests. Muito boa referência em português! Posted in [...]

#19 Test Helpers - ArthurGeek.net disse:
04 Set 08, 12:25PM

[...] e se você ainda não entrou nessa de TDD, dê uma olhada neste artigo escrito pelo Nando Vieira sobre Unit Tests. Muito boa referência em português! Tags: helpers, [...]

Deixe um comentário




Este blog usa o Gravatar.


Não é aceito código HTML:
adicione-o no pastie.caboo.se 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.