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:
- Se eu vou apenas fazer uma consulta no ActiveRecord, por exemplo, eu faço diretamente no controller (obviamente, consultas complexas devem ser abstraídas em métodos no próprio modelo e/ou classes auxiliares).
- Se eu precisar fazer qualquer tarefa adicional como enviar um e-mail ou executar alguma coisa no background, crio uma classe que fará isso.
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/active_record_helper.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,além de fazer a conexão com o banco de dados.
require "bundler/setup"
Bundler.setup(:default, :test)
require "yaml"
require "active_record"
require "rspec/matchers"
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)
ActiveSupport::Dependencies.autoload_paths += %w[./app/models]
require_relative "support/factories"
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 "active_record_helper"
describe User do
context "validations" do
it "requires name" do
user = User.create(name: '')
expect(user.errors[:name]).not_to be_empty
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
real 0m11.537s
user 0m6.652s
sys 0m1.740s
# Depois da alteração
real 0m3.688s
user 0m2.672s
sys 0m0.452s
E por falar em ActiveRecord, aqui vão mais algumas dicas.
- Evite criar registros quando você não precisa. Muitas vezes instanciar o modelo já é suficiente. Se for realmente necessário criar estes registros, não crie mais do que precisa.
- Sempre que puder, utilize fixtures ou algo equivalente. Eu sempre adiciono a minha gem factory_girl-preload, e defino alguns registros que uso em todos os lugares (como uma instância da classe
User
). - Esqueça a ideia de Fat Model literal (um único modelo, inchado de métodos não relacionados). Você pode delegar regras de negócio para classes separadas e apenas criar métodos de conveniência, quando necessário (e quando fizer sentido).
- Fuja dos callbacks do ActiveRecord, principalmente para realizar tarefas que não são relativas ao modelo (enviar e-mail, por exemplo, com o
after_create
).
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.