Dicas para otimizar o tempo de build de testes com RSpec

Publicado por Jônatas Davi Paganini no dia dev

Cars

Atualmente mais de 12.000 testes automatizados fazem a cobertura do RDStation. Além disso, outros microserviços possuem suas baterias de testes e builds automatizados independentes.

Com essa quantidade de testes nosso processo de build automatizado começou a perder performance e o tempo para se rodar todos os specs se tornou um problema. Sendo assim foi necessário um olhar mais refinado e uma análise detalhada de onde estavam os gargalos e aplicar melhorias para manter a rotina de testes automatizados consistente e performática.

Neste post, iremos compartilhar o que aprendemos fazendo a análise desses specs e como algumas mudanças simples podem impactar na performance dos builds.

Isso nos permite manter um ambiente saudável e produtivo não só para os clientes mas também para nós mesmos: desenvolvedores do produto.

No desenvolvimento ágil, a suíte de testes unitários e o processo de build automatizado são práticas eficientes para manutenabilidade do sistema.

Factory girl

A biblioteca Factory Girl é extensamente utilizada para definição e setup dos modelos que serão testados.

No RDStation usamos as factories para testar a maior parte do código, mas muitas vezes não nos focamos na performance de cada uso nos testes então aqui vão algumas considerações.

Evitar callbacks lentos

No ActiveRecord, podemos adicionar callbacks, que são ganchos para os principais eventos no modelo. Cada modelo pode ter N callbacks. Exemplo:

1
2
3
4
5
# app/models/user.rb
class User < ActiveRecord::Base
  after_create :send_welcome_email
  # ...
end

Então a cada novo usuário, um email de bem vindo é preparado, compilado e enviado. No teste não será diferente. O email será compilado e simulado um envio.

No RDStation a maioria dos testes usa um usuário para interagir então o usuário é criado frenéticamente na bateria de testes.

Para ignorar esse comportamento, podemos ignorar o callback na factory, evitando enviar email a cada criação de usuário:

1
2
3
4
5
6
# spec/factories/user.rb
FactoryGirl.define do
  factory :user do
    after(:build) { |_user| User.skip_callback(:create, :after, :send_welcome_mail)  }
  end
end

Esta mudança ganha de 20ms a 400ms por spec que cria o usuário. Mas perdemos o comportamento padrão do modelo.

Dessa forma, é possível criar uma outra factory para testar apenas o comportamento em questão:

1
2
3
4
# spec/factories/user.rb
factory :user_with_welcome_email do
  after(:create) { |user| user.send_welcome_mail   }
end

Prefira associações lazy

Quando você declara as associações diretamente na factory. Ele cria toda a estrutura e isso reflete no banco.

1
2
3
4
5
6
7
8
# spec/factories/account.rb
FactoryGirl.define do
  factory :account_with_basic_plan do
    name        'Example'
    site        'http://meudominiopro.com.br'
    association :subscription, factory: :billing_subscription
  end
end

Porém, com factorygirl é possível passar um bloco de código que será executado apenas se a associação for chamada, senão não nem faz o insert da subscription no banco:

1
2
3
4
5
6
7
8
# spec/factories/account.rb
FactoryGirl.define do
  factory :account_with_lazy_subscription do
    name        'Example'
    site        'http://meudominiopro.com.br'
    subscription { build(:billing_subscription) }
  end
end

Lembrando que no exemplo está com build no bloco lazy, mas poderia ser create ou build_stubbed dependendo da necessidade.

Prefira build_stubbed ao invés de create

Também é possível usar o build_stubbed que é o irmão mais novo do build e permite fazer o stub das associações além de permitir instanciar o objeto com todos os relacionamentos sem persistir.

1
let(:user) { create(:confirmed_user_with_account) }

A troca do create por build_stubbed evita que o registro chegue ao banco e volte a aplicação. Essa troca ganha performance em toda a camada de persistência, evitando que o objeto e todas suas respectivas associações cheguem ao banco.

1
let(:user) { build_stubbed(:confirmed_user_with_account) }

Evite acesso ao banco a cada login

Cada login no sistema exige uma verificação e validação do usuário. Então é possível eliminarmos essa ida e volta até o banco além de já evitarmos criar o usuário. A sugestão vem da própria wiki do devise.

1
2
3
4
5
6
7
8
9
10
module ControllerHelpers
  def sign_in(user = double('user'))
    if user.nil?
      allow(request.env['warden']).to receive(:authenticate!).and_throw(:warden, {scope: :user})
    else
      allow(request.env['warden']).to receive(:authenticate!).and_return(user)
    end
    controller.instance_variable_set("@current_user",user)
  end
end

Também é necessário configurar o rspec pra poder usar o sign_in como helper.

1
2
3
RSpec.configure do |config|
  config.include ControllerHelpers, :type => :controller
end

E nos specs então é possível fazer o sign_in com usuário build_stubbed.

1
2
3
4
let(:user) { build_stubbed(:confirmed_user_with_account) }
before do
  sign_in user
end

A combinação acima acaba transformando a maneira do login, evitando um insert e um select no banco de dados a cada teste que faz login.

Use stub para não inflar as associações default

Para evitar o relacionamento contínuo com o banco e continuar usando a alteração anterior, precisamos seguir construindo objetos e criar associações com stub, evitando que as associações também sejam persistidas ou mesmo.

Observe o seguinte exemplo fictício:

1
2
3
4
5
6
7
8
9
10
11
12
scenario "dashboard" do
  let(:user) { create(:confirmed_user_with_account) }
  before do
    user.dashboard.graphics << create(:graphic, numbers: [1,2,3,4])
    user.dashboard.graphics << create(:graphic, numbers: [4,6,7,8])
    sign_in user
    visit dashboard_path
  end
  it 'user see the dashboard numbers' do
     #...
  end
end

Logo, quando migrar de create para build_stubbed, o teste irá falhar para criar os gráficos e será necessário inserir os dados no banco.

A alternativa para não persistir os dados é fazer um stub da associação.

1
2
3
4
5
6
7
8
9
10
11
12
13
scenario "dashboard" do
  let(:user) { create(:confirmed_user_with_account) }
  let(:graphics) { [ build(:graphic, numbers: [1,2,3,4]), build(:graphic, numbers: [4,6,7,8]) ] }

  before do
    user.dashboard.stub('graphics') { graphics }
    sign_in user
    visit dashboard_path
  end
  it 'user see the dashboard numbers' do
     #...
  end
end

Converter testes de funcionalidade em unitários

Muitas vezes escrevemos testes de features que no fundo verificam se os testes unitários funcionam navegando pela tela. Dessa forma encorajamos a todos a modificarmos os testes de integração para mantermos apenas as funcionalidades e o fluxo das funcionalidades.

Exemplo:

Teste de funcionalidade: valida se exibe erros quando não passa o website:

1
2
3
4
5
6
it 'should show error with blank website' do
  account_page.fill_site ''
  account_page.click_on_save

  expect(account_page.get_error_message).to eq(invalid_site_message)
end

Por padrão o Rails injeta os erros nos forms em todas as telas, então, esse teste acaba re-testando o comportamento do framework.

Com certeza existe um teste unitário validando que o site não pode ficar em branco.

Então não é necessário provar que o errors_message_for está sendo exibido em todos os forms novamente já que estamos usando o framework que já tem esse comportamento e testa isso.

Futuro - next level

Hoje temos uma suíte única de testes, mas estamos planejando algo mais voltado a cada módulo/vertical do produto para rodar apenas os testes mais convenientes para cada caso.

Também planejamos estabelecer políticas para não rodar toda a suíte em todos os builds. Assim separamos um build noturno para rodar os smoke tests e evitamos rodar tudo no build convencional enquanto ainda estamos no flow com a ferramenta.

O time está crescendo, o RDStation está sempre evoluindo então precisamos estar preparados para continuar mantendo a suíte em ordem, sendo útil e funcional para saúde do produto e da equipe de desenvolvimento.

E você tem alguma boa prática ou tip para melhorar a performance/tempo de build dos testes? Não deixe de compartilhar!

Temos outros posts sobre RSpec e testes que talvez possam interessar:

Jônatas Davi Paganini

Jônatas Davi Paganini

Full Stack Trainer

Comentários