Fazendo os seus testes executarem mais rápido


Leia em 5 minutos

Vira e mexe mando uns tweets com o tempo de execução dos meus testes. Não que sejam absurdamente rápidos, mas muita gente andou me perguntando qual é a mágica.

Para você ter uma ideia, a API do Codeplane (que é o meu projeto atual), tem os seguintes tempos de execução:

$ time rspec
Finished in 7.37 seconds
357 examples, 0 failures

real  0m21.159s
user  0m11.233s
sys 0m3.444s

Veja o tempo de execução apenas dos modelos (persistência com ActiveRecord e outras classes):

$ time rspec spec/models/
Finished in 2.11 seconds
165 examples, 0 failures

real  0m4.023s
user  0m2.816s
sys 0m0.536s

E, por fim, todos os testes que não precisam do Rails:

$ time rspec spec/{extensions,middlewares,models,rack,services,workers}
Finished in 2.46 seconds
237 examples, 0 failures

real  0m4.531s
user  0m3.080s
sys 0m0.664s

Nada mal. Neste artigo você poderá ver um pouco das coisas que faço para ter tempos como estes.

Evite adicionar dependências

Dependência tem esse nome e não é por acaso. Pense duas vezes se você realmente precisa de todas aquelas gems que você está adicionando. Às vezes, algumas poucas linhas de código já podem resolver a coisa toda.

O boot do Rails não é lento. Um aplicativo novo, apenas com as dependências padrão, é executado em menos de 1.5s.

$ time rails runner ''

real  0m1.316s
user  0m1.164s
sys 0m0.148s

O stack padrão tem 40 gems de dependência (uma das linhas é apenas uma descrição), como você pode ver abaixo:

$ bundle show | wc -l
41

A API do Codeplane tem um tempo de boot três vezes maior, com quase o dobro de dependências.

$ time rails runner ''

real  0m3.850s
user  0m2.732s
sys 0m0.468s

$ bundle show | wc -l
76

Todas as bibliotecas que você adicionar ao seu projeto precisarão ser carregadas de alguma forma. No caso do Bundler, a menos que você seja específico quanto a isso, elas serão carregadas no momento do boot, e cada dependência que você adicionar, irá aumentar o seu tempo. Por isso, conforme nossa aplicação vai crescendo, temos a sensação de que o Rails é lento.

Nem sempre conseguimos evitar uma nova dependência. Uma coisa que você pode fazer, sempre que possível, é postergar o carregamento da biblioteca no lugar onde ela será usada. Imagine que eu queira usar a gem permalink. Esta gem, que é específica para o ActiveRecord, pode ser carregada no arquivo que irá usá-la. Primeiro, certifique-se de adicionar a opção :require => false no arquivo Gemfile. Depois, carregue-a no arquivo do modelo.

require "permalink"

class Post < ActiveRecord::Base
  permalink
end

Isole seus testes

Você nem sempre precisa do Rails. Para ser mais específico, os únicos testes que devem depender do Rails são os de integração (no caso do RSpec, são aqueles testes full-stack, com requisições HTTP). Todos os outros podem (e devem) ser isolados. Só assim você conseguirá executá-los mais rapidamente.

Mas para que isso seja possível, você precisará fazer algumas coisas. Primeiro, evite fazer muitas coisas no controller, porque aí você não precisará carregar o Rails para começo de conversa. A minha linha de raciocínio é bastante simples:

A primeira regra é aplicada no exemplo abaixo, onde apenas pego uma lista de repositórios de um dado usuário:

class ReposController < ApplicationController
  def index
    @repos = current_user.repos
    respond_with @repos
  end
end

No caso de um cadastro de usuário, onde um e-mail precisará ser enviado (assincronamente), eu teria que aplicar a segunda regra, como no exemplo abaixo:

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def create
    @user = User.create(params[:user])
    Signup.process(@user)

    respond_with @user, :location => login_path
  end
end

A classe Signup é responsável por adicionar o job que será executado assincronamente. Isso é importante, pois agora você consegue escrever testes específicos para a classe Signup, sem precisar bater, necessariamente, no controller ou mesmo no Rails.

Uma implementação possível dessa classe pode ser vista abaixo:

class Signup
  def self.process(user)
    new(user).process
  end

  def initialize(user)
    @user = user
  end

  def mailer_worker
    MailerWorker
  end

  def process
    return if @user.errors.any?

    mailer_worker.enqueue(
      :name => @user.name,
      :email => @user.email,
      :mail => :welcome
    )
  end
end

Isso introduz o conceito de PORO (Plain-Old Ruby Object), ou seja, uma classe pura de Ruby que serve como um serviço. Perceba que a minha dependência está definida no método Signup#mailer_worker, em vez de passar como um parâmetro para o método Signup#process. Prefiro fazer desta forma porque acho mais clara.

E quanto aos testes?

require "./app/models/signup"

describe Signup do
  describe "#mailer_worker" do
    let(:worker) { mock("MailerWorker") }

    it "returns MailerWorker" do
      stub_const "MailerWorker", worker
      expect(Signup.new(nil).mailer_worker).to eql(worker)
    end
  end

  describe "#process" do
    let(:worker) { mock("MailerWorker") }
    let(:user) { mock("user") }

    before do
      Signup.any_instance.stub :mailer_worker => worker
    end

    context "when user is valid" do
      before do
        user.stub({
          :errors => [],
          :name => "NAME",
          :email => "EMAIL"
        })
      end

      it "enqueues e-mail" do
        worker
          .should_receive(:enqueue)
          .with({
            :name => "NAME",
            :email => "EMAIL",
            :mail => :welcome
          })

        Signup.new(user).process
      end
    end

    context "when user is invalid" do
      before do
        user.stub :errors => ["ERROR"]
      end

      it "does not enqueue e-mail" do
        worker.should_not_receive(:enqueue)
        Signup.new(user).process
      end
    end
  end
end

Muita gente não gosta dessa abordagem com mocks e stubs. Particularmente, não vejo problemas, já que sempre tenho testes de integração que irão garantir o funcionamento desta classe no seu uso real e integrado ao sistema.

Perceba que não estou carregando o arquivo “spec_helper.rb”, como fazemos normalmente. Isso porque, neste exemplo, não precisamos de nada do Rails. Então, não faz o menor sentido você carregar o Rails inteiro apenas para rodar estes testes. Veja como eles rodam muito rápido:

$ time rspec signup_spec.rb
...

Finished in 0.00727 seconds
3 examples, 0 failures

real  0m0.259s
user  0m0.196s
sys 0m0.024s

Claro que nem sempre é possível fugir do Rails. Será? Executar testes do ActiveRecord normalmente exigiriam coisas do Rails, mas podemos isolar o carregamento somente das dependências do ActiveRecord em um arquivo diferente do “spec_helper.rb”.

Primeiro crie um arquivo “spec/spec_helper_activerecord.rb”. Este arquivo irá carregar o Bundler e definir o escopo de dependências, mas não irá carregar as bibliotecas automaticamente. No exemplo abaixo irei carregar o ActiveRecord, FactoryGirl, FactoryGirl Preload, o matcher `have(n).errorson`, presente no rspec-rails, além de fazer a conexão com o banco de dados.

require "bundler"
Bundler.setup(:default, :test)

require "active_record"

require "rspec/matchers"
require "rspec/rails/extensions/active_record/base"
require "rspec/rails/matchers/have_extension"

require "factory_girl"
require "factory_girl/preload"
require "factory_girl/preload/rspec2"

connection_info = YAML.load_file("config/database.yml")["test"]
ActiveRecord::Base.establish_connection(connection_info)

Todas as gems específicas do ActiveRecord que você usa devem ser carregadas neste arquivo também.

Agora, no arquivo de testes do seu modelo, você irá carregar este arquivo e o modelo que você quer testar. Veja, por exemplo, como seria para testar as validações de um modelo User.

require "spec_helper_active_record"
require "./app/models/user"

describe User do
  context "validations" do
    it "requires name" do
      user = User.create(:name => nil)
      expect(user).to have(1).error_on(:name)
    end
  end
end

Com esta alteração, eu consegui reduzir o tempo de execução do diretório “spec/models” consideravelmente:

$ time rspec spec/models/
147 examples, 0 failures

# Antes da alteração
real0m11.537s
user0m6.652s
sys0m1.740s

# Depois da alteração
real0m3.688s
user0m2.672s
sys0m0.452s

E por falar em ActiveRecord, aqui vão mais algumas dicas.

Finalizando

Testes rápidos não deve ser o seu objetivo final. Isso é apenas uma consequência de ter um código bem escrito, seguindo as boas práticas de desenvolvimento de software (piada que alguns amigos irão entender).

Ouça sempre o que o seus testes estão dizendo. Se está muito difícil de testar, provavelmente sua classe/método está fazendo mais coisas do que deveria.