RubyOnBr logo

Testes em Rails

Introdução

Qualquer um que tenha começado um projeto usando Rails e pulado diretamente para a implementação, sem se preocupar com testes, levante a mão direita. Muito bem, vejo que todo mundo levantou a mão.

Testes são, ao mesmo tempo, um dos maiores fatores de produtividade em uma aplicação Rails e uma das coisas mais difíceis de se fazer. A tendência, especialmente quando o projeto começo a avançar é sempre não testar tanto quanto se deveria. Isso é um fato tão comum que mesmo notórios projetos livres escritos em Rails tem poucos testes ou testes insuficientes.

Esse artigo tem como objetivo mostrar um pouco sobre como implementar testes unitários simples usando uma classe que não pertence a um modelo da aplicação. A idéia é exibir uma seqüência de passos simples para testar de maneira razoavelmente completa uma classe e desmistificar o processo.

Assim, para começar, podemos supor que estamos criando um jogo e precisamos implementar a classe chamada Card. Essa é uma classe que representa algo bem específico em nosso problema de domínio mas que não possui uma tradução imediata em banco de dados. Antes, é uma classe cujas instâncias sempre estarão associadas a outras.

A classe Card

Essa classe, obviamente, representará uma carta de baralho. Sendo uma classe que representa valores imutáveis, sua implementação depende de alguns cuidados. Não sendo uma classe de modelo de dados no Rails, a maneira mais fácil de criá-la é criar um arquivo chamado card.rb no diretório app/models. Esse arquivo terá um teste correspondente, card_test.rb em test/unit, que é o arquivo que editaremos agora.

   require File.dirname(__FILE__) + '/../test_helper'

   class CardTest < Test::Unit::TestCase
   end

Esse primeiro teste ainda não faz absolutamente nada. Como temos apenas 52 cartas, um primeito teste poderia ser verificar se conseguimos criar todas essas cartas. Para isso, podemos codificar algo assim:

   require File.dirname(__FILE__) + '/../test_helper'

   class CardTest < Test::Unit::TestCase

     def test_all_possible
       SUITS.each do |suit|
         FACES.each do |face|
           card = Card.new(face, suit)
           assert_equal face, card.face
           assert_equal suit, card.suit
         end
       end
     end

   end

Esse primeiro teste cria a carta correspondente a cada naipe e cada face possível, verificando se o naipe e face da carta criada correspondem aos valores informados externamente. Rodando esse teste, temos o seguinte resultado.

   Started
   E
   Finished in 0.043318 seconds.

    1) Error:
   test_all_possible(CardTest):
   NameError: uninitialized constant CardTest::SUITS
       /usr/lib/ruby/gems/1.8/gems/activesupport-1.3.1.5848/lib/active_support/dependencies.rb:478:in 'const_missing'
       ./test/unit/card_test.rb:6:in `test_all_possible'

   1 tests, 0 assertions, 0 failures, 1 errors

Obviamente, o teste falha inicialmente porque não temos definidas as constantes SUITS e FACES. Tampouco temos a nossa classe Card. Editando o arquivo card.rb para resolver esses problemas, podemos fazer o seguinte.

   SUITS = [:clubs, :diamonds, :spades, :hearts].freeze

   FACES = [:ace, :two, :there, :four, :five, :six, :seven, :eight,
            :nine, :ten, :jack, :queen, :king].freeze

   class Card
   end

Rodando o teste, continuamos com o mesmo problema. Isso acontece porque o arquivo não está sendo encontrado. A maneira mais simples é incluí-lo via require no arquivo environment.rb.

Rodando o teste novamente, veremos que o erro muda por não termos definido uma inicialização para a classe. É o que faremos agora.

Como também estamos nos aproximando de uma implementação maior, queremos testar também que não haja possibilidade de criar cartas inválidas. Adicionamos então o seguinte teste:

   require File.dirname(__FILE__) + '/../test_helper'

   class CardTest < Test::Unit::TestCase

     def test_all_possible
       SUITS.each do |suit|
         FACES.each do |face|
           card = Card.new(face, suit)
           assert_equal face, card.face
           assert_equal suit, card.suit
         end
       end
     end

     def test_invalid_cards
       assert_raise(InvalidCardError) { Card.new(0, 0) }
       assert_raise(InvalidCardError) { Card.new("a", "a") }
       assert_raise(InvalidCardError) { Card.new(:ace, 0) }
       assert_raise(InvalidCardError) { Card.new(0, :clubs) }
     end

   end

Esse outro teste garante várias situações possíveis de criação de um carta que se contrapõem as criações válidas do teste anterior.

Podemos agora implementar mais detalhes em nossa classe.

   SUITS = [:clubs, :diamonds, :spades, :hearts].freeze

   FACES = [:ace, :two, :there, :four, :five, :six, :seven, :eight,
            :nine, :ten, :jack, :queen, :king].freeze

   class InvalidCardError < StandardError; end

   class Card

     def initialize(face, suit)
       @value = FACES.index(face) + 13 * SUITS.index(suit)
     rescue
       raise InvalidCardError
     end

   end

Nessa implementação, vamos utilizar apenas uma variável inteira para armazenar nossa carta. Considerando que só temos 52 variações possíveis, podemos guardar o naipe e a face em uma codificação simples, representando o primeiro naipe com valores entre 0 e 12, o segundo com valores entre 13 e 25, e assim por diante. Caso o valor não seja encontrado em um dos arrays que criamos, um exceção será gerada pelo retorno de nil e capturamos essa exceção para gerar a nossa mais específica.

Rodando os testes, verificamos que agora só temos um erro nos atributos externos do objeto. Implementamos os mesmos da seguinte forma:

   SUITS = [:clubs, :diamonds, :spades, :hearts].freeze

   FACES = [:ace, :two, :there, :four, :five, :six, :seven, :eight,
            :nine, :ten, :jack, :queen, :king].freeze

   class InvalidCardError < StandardError; end

   class Card

     def initialize(face, suit)
       @value = FACES.index(face) + 13 * SUITS.index(suit)
     rescue
       raise InvalidCardError
     end

     def face
       FACES[@value % 13]
     end

     def suit
       SUITS[@value / 13]
     end

   end

O resultado é que nosso teste agora passa com o seguinte resultado:

   Started
   ..
   Finished in 0.047398 seconds.

   2 tests, 108 assertions, 0 failures, 0 errors

Temos 108 asserções, sendo que 104 são do primeiro teste, que faz duas verificações para cada uma das 52 cartas, e quatro do segundo teste.

Prosseguindo em nossa implementação, queremos que essa classe não seja mutável, ou seja, que duas instâncias com o mesmo naipe e face sejam qualitativamente idênticas. Podemos conseguir isso, e de quebra comparações entre cartas, usando o mixin Comparable. Mas antes, precisamos elaborar testes para verificar a acurácia de nossa implementação.

Nossos testes ficam assim agora:

   require File.dirname(__FILE__) + '/../test_helper'

   class CardTest < Test::Unit::TestCase

     def test_all_possible
       SUITS.each do |suit|
         FACES.each do |face|
           card = Card.new(face, suit)
           assert_equal face, card.face
           assert_equal suit, card.suit
         end
       end
     end

     def test_invalid_cards
       assert_raise(InvalidCardError) { Card.new(0, 0) }
       assert_raise(InvalidCardError) { Card.new("a", "a") }
       assert_raise(InvalidCardError) { Card.new(:ace, 0) }
       assert_raise(InvalidCardError) { Card.new(0, :clubs) }
     end

     def test_comparable
       c1 = Card.new(:four, :clubs)
       c2 = Card.new(:seven, :hearts)
       c3 = Card.new(:ace, :spades)
       c4 = Card.new(:seven, :diamonds)
       c5 = Card.new(:four, :clubs)
       assert c1 == c5
       assert c1 < c2
       assert c1 < c3
       assert c1 < c4
       assert c2 > c3
       assert c2 > c4
       assert c3 > c4
     end

   end

Testamos várias combinações de operadores que ainda não estão implementados. Para fazer isso, usaremos, como mencionado acima, o mixin Comparable.

   SUITS = [:clubs, :diamonds, :spades, :hearts].freeze

   FACES = [:ace, :two, :there, :four, :five, :six, :seven, :eight,
            :nine, :ten, :jack, :queen, :king].freeze

   class InvalidCardError < StandardError; end

   class Card

     include Comparable

     def initialize(face, suit)
       @value = FACES.index(face) + 13 * SUITS.index(suit)
     rescue
       raise InvalidCardError
     end

     def face
       FACES[@value % 13]
     end

     def suit
       SUITS[@value / 13]
     end

     def eql?(card)
       @value == card.instance_variable_get(:@value)
     end

     def <=>(card)
       @value <=> card.instance_variable_get(:@value)
     end

   end

Definimos um método eql?, sobrepondo o original para não comparar a instância, mas um valor que queremos. Implementamos então o operador <=>, que é usado por Comparable. Com base nesse operador, o mixin gera métodos para =, !=, &gt;, &gt;=, &lt; e &lt;=.

Um último método que podemos implementar é um método de classe que retorne todas as classes possíveis em um baralho. Podemos criar um teste para esse método também:

   require File.dirname(__FILE__) + '/../test_helper'

   class CardTest < Test::Unit::TestCase

     def test_all_possible
       SUITS.each do |suit|
         FACES.each do |face|
           card = Card.new(face, suit)
           assert_equal face, card.face
           assert_equal suit, card.suit
         end
       end
     end

     def test_invalid_cards
       assert_raise(InvalidCardError) { Card.new(0, 0) }
       assert_raise(InvalidCardError) { Card.new("a", "a") }
       assert_raise(InvalidCardError) { Card.new(:ace, 0) }
       assert_raise(InvalidCardError) { Card.new(0, :clubs) }
     end

     def test_comparable
       c1 = Card.new(:four, :clubs)
       c2 = Card.new(:seven, :hearts)
       c3 = Card.new(:ace, :spades)
       c4 = Card.new(:seven, :diamonds)
       c5 = Card.new(:four, :clubs)
       assert c1 == c5
       assert c1 < c2
       assert c1 < c3
       assert c1 < c4
       assert c2 > c3
       assert c2 > c4
       assert c3 > c4
     end

     def test_all_cards
       cards = []
       SUITS.each do |suit|
         FACES.each do |face|
           cards << Card.new(face, suit)
         end
       end
       assert_equal cards, Card.all_cards
     end

   end

A implementação seria algo assim:

   SUITS = [:clubs, :diamonds, :spades, :hearts].freeze

   FACES = [:ace, :two, :there, :four, :five, :six, :seven, :eight,
            :nine, :ten, :jack, :queen, :king].freeze

   class InvalidCardError < StandardError; end

   class Card

     include Comparable

     def initialize(face, suit)
       @value = FACES.index(face) + 13 * SUITS.index(suit)
     rescue
       raise InvalidCardError
     end

     def face
       FACES[@value % 13]
     end

     def suit
       SUITS[@value / 13]
     end

     def eql?(card)
       @value == card.instance_variable_get(:@value)
     end

     def <=>(card)
       @value <=> card.instance_variable_get(:@value)
     end

     def self.all_cards
       @@all_cards ||= SUITS.inject([]) { |m, s| FACES.each { |f| m << Card.new(f, s) }; m }
     end

   end

Note que a implementação é mais complexa que o teste. Poderíamos ter um teste cuja geração de cartas espelhasse exatamente o código de geração. Fazer um teste mais simples e em mais passos é uma garantia a mais de que nossas classes estão funcionando corretamente. Obviamente, há sempre a possibilidade de um erro no teste–embora duas implementações diferentes errando ao mesmo tempo seja algo bem improvável.

Todos os testes passam agora perfeitamente. Assim termina a implementação inicial da classe Card usando testes para fazer o trabalho de verificação prévia de implementação.

Conclusão

Como nossa rápida implementação mostra, efetuar os testes antes da implementação é sempre uma maneira muito eficiente de verificar a validade do que estamos desenvolvendo.

Obviamente, se estivéssemos testando uma classe de banco de dados, teríamos testes para verificar se os dados estão indo e voltando para o banco da maneira que desejamos e se sua representação está adequada. Um erro comum nesses casos é testar a própria implementação do ActiveRecord. Um exemplo é testar se os métodos save, update e delete functionam. Isso é um desperdício completo de tempo, porque esses métodos são fundamentais e testados pelo próprio Rails. Os seus testes devem se focar no mostrado acima: detalhes de implementação, ou seja, lógica interna que não tem a ver com o banco de dados. Representação só é testada quando há modificações.

Em futuros artigos, exploraremos um pouco mais esse tipo de testes e também como testar as demais partes da aplicação.

Ronaldo Ferraz

Comente aqui

Todos os diretos reservados a RubyOnBr. Copyright RubyOnBr .
This site is powered by Radiant CMS.