Otimizando testes Factory Girl e RSpec

Publicado por Alexandre Tavares no dia dev

No time de desenvolvimento da Resultados Digitais utilizamos Test Driven Development (TDD). Esta prática, além de mitigar erros que poderiam ser introduzidos por novos requisitos, ajuda a manter a qualidade do código.

Os testes servem como documentação do código e dão mais segurança para os desenvolvedores. Mesmo assim, um código coberto de testes pode gerar um overhead de tempo de execução, o que pode ser o gargalo entre o desenvolvimento e deploy em produção.

Neste post explicarei como detectamos e atacamos os principais problemas dos nossos testes com Factory Girl e RSpec, otimizando o tempo de execução e a prevenção de erros.

Onde gastamos nosso tempo

Somos orientados a dados. Quando falamos em tempo de execucação de testes, falamos em um tempo adicional para colocar uma melhoria no ar. Logo, se esse tempo for longo, perdemos produtividade - subimos menos atualizações por dia.

Para reduzir esse tempo e deixar o time mais ágil, focamos em três pontos:

  1. Especialização das factories para melhor desempenho dos testes.
  2. Correção dos testes que acusam falha erroneamente, os falsos positivos.
  3. Melhoria dos testes para uma qualidade maior da documentação e rastreabilidade de erros.

Especializando as factories para melhor desempenho dos testes

Uma das dificuldades para modelar um teste é interagir ou simular o comportamento dos objetos da base de dados. Em Ruby on Rails, uma biblioteca (gema) bastante difundida por sua simplicidade e eficiencia é a Factory Girl. Essa gema é uma linguagem de domínio específico (do inglês DSL) para criar e definir fábricas de objetos (factories), que herdam seus comportamentos e atributos das classes do modelo de negócios (ActiveRecord).

Um dos benefícios da Factory Girl é que podemos definir as associações entre objetos do modelo. Entretanto, isso também pode ser um problema, já que a gema cria essas associações por padrão e, por isso, pode deixar o teste mais lento. Por exemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# model
class Patrimony < ActiveRecord::Base
  belongs_to :company
  belongs_to :owner, class: Person

  validates :name, presence: true
end
# factory
factory :patrimony, :class => "Patrimony" do
  name     "Patrimony"

  association :company, factory: :company
  association :owner, factory: :person
end
# teste
let(:patrimony) { create :patrimony }
it "has owner" do
  expect(patrimony.owner).to be
end
1
2
3
4
5
6
7
8
9
10
11
12
13
/* Resultado (test.log) */
[1m[36mActiveRecord::SchemaMigration Load (0.1ms)[0m  [1mSELECT "schema_migrations".* FROM "schema_migrations"[0m
[1m[35m (0.1ms)[0m  begin transaction
[1m[36m (0.0ms)[0m  [1mSAVEPOINT active_record_1[0m
[1m[35mSQL (0.2ms)[0m  INSERT INTO "companies" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Company"], ["created_at", "2015-07-09 15:34:33.592120"], ["updated_at", "2015-07-09 15:34:33.592120"]]
[1m[36m (0.0ms)[0m  [1mRELEASE SAVEPOINT active_record_1[0m
[1m[35m (0.0ms)[0m  SAVEPOINT active_record_1
[1m[36mSQL (0.2ms)[0m  [1mINSERT INTO "people" ("name", "created_at", "updated_at") VALUES (?, ?, ?)[0m  [["name", "Person"], ["created_at", "2015-07-09 15:34:33.599956"], ["updated_at", "2015-07-09 15:34:33.599956"]]
[1m[35m (0.0ms)[0m  RELEASE SAVEPOINT active_record_1
[1m[36m (0.0ms)[0m  [1mSAVEPOINT active_record_1[0m
[1m[35mSQL (0.1ms)[0m  INSERT INTO "patrimonies" ("name", "company_id", "owner_id", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["name", "Patrimony"], ["company_id", 1], ["owner_id", 1], ["created_at", "2015-07-09 15:34:33.601424"], ["updated_at", "2015-07-09 15:34:33.601424"]]
[1m[36m (0.0ms)[0m  [1mRELEASE SAVEPOINT active_record_1[0m
[1m[35m (0.1ms)[0m  rollback transaction

A gema fez as associações entre os objetos do modelo. Porém, se quiséssemos testar se patrimonies possui o atributo name, não seria necessário criar a relação. Por exemplo:

1
2
3
4
5
6
7
# teste
context "validates" do
  let(:patrimony) { build :patrimony, name: nil }
  it "has name" do
    expect(patrimony.save).not_to be
  end
end
1
2
3
4
5
6
7
8
9
10
11
12
/* Resultado (test.log) */
[1m[36mActiveRecord::SchemaMigration Load (0.1ms)[0m  [1mSELECT "schema_migrations".* FROM "schema_migrations"[0m
[1m[35m (0.1ms)[0m  begin transaction
[1m[36m (0.0ms)[0m  [1mSAVEPOINT active_record_1[0m
[1m[35mSQL (0.3ms)[0m  INSERT INTO "companies" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "Company"], ["created_at", "2015-07-09 15:58:29.581534"], ["updated_at", "2015-07-09 15:58:29.581534"]]
[1m[36m (0.0ms)[0m  [1mRELEASE SAVEPOINT active_record_1[0m
[1m[35m (0.0ms)[0m  SAVEPOINT active_record_1
[1m[36mSQL (0.2ms)[0m  [1mINSERT INTO "people" ("name", "created_at", "updated_at") VALUES (?, ?, ?)[0m  [["name", "Person"], ["created_at", "2015-07-09 15:58:29.589891"], ["updated_at", "2015-07-09 15:58:29.589891"]]
[1m[35m (0.0ms)[0m  RELEASE SAVEPOINT active_record_1
[1m[36m (0.0ms)[0m  [1mSAVEPOINT active_record_1[0m
[1m[35m (0.1ms)[0m  ROLLBACK TO SAVEPOINT active_record_1
[1m[36m (0.1ms)[0m  [1mrollback transaction[0m

O teste passou novamente, mas foram inseridas associações que não foram utilizadas no teste. Ou seja, estamos afetando o desempenho negativamente. O ideal é especializar cada factory, para testar algum comportamento específico. Isto é, de maneira unitária. Com testes unitários, conseguimos avaliar comportamentos e atributos de maneira isolada. Por exemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
factory :patrimony, :class => "Patrimony" do
  name     "Patrimony"

  factory :patrimony_with_all_association do
    association :company, factory: :company
    association :owner, factory: :person
  end

  factory :patrimony_with_owner do
    association :owner, factory: :person
  end

  factory :patrimony_with_company do
    association :company, factory: :company
  end
end
1
2
3
4
5
6
/* Resultado (test.log) */
[1m[36mActiveRecord::SchemaMigration Load (0.1ms)[0m  [1mSELECT "schema_migrations".* FROM "schema_migrations"[0m
[1m[35m (0.1ms)[0m  begin transaction
[1m[36m (0.0ms)[0m  [1mSAVEPOINT active_record_1[0m
[1m[35m (0.0ms)[0m  ROLLBACK TO SAVEPOINT active_record_1
[1m[36m (0.0ms)[0m  [1mrollback transaction[0m

Com isso são efetuadas menos chamadas à base de dados e testamos apenas o necessário.

Falso positivo: associações em testes causando falhas

Um problema que enfrentamos ao trabalhar com testes automatizados é o dos falsos positivos. São testes que em teoria estão corretos, mas na prática falham. O ActiveRecord do Rails tem uma ligação muito forte com a base de dados e suas restrições. A restrição unique para alguns atributos, como os identificadores, deve ser respeitada diretamente no modelo.

Para gerar dados aleatórios para nossos testes, utilizamos a gema Faker. Essa gema é capaz de gerar dados variados como e-mails, nomes, endereços, etc. Ao utilizar essa ferramenta é necessário levar em consideração se os atributos para os dados aleatórios que serão gerados têm a restrição de serem únicos, diferente do exemplo abaixo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Model
class Person < ActiveRecord::Base
  belongs_to :company

  validates :name, uniqueness: true
end
# Factory
factory :person, :class => "Person" do
  name     Faker::Name.name
end
factory :patrimony, :class => "Patrimony" do
  name     "Patrimony"
  association :company, factory: :company
  association :owner, factory: :person
end

O problema deste código é que, durante a execução, pode fazer com que dois objetos tenham o mesmo valor do atributo (neste caso, name). Isso faz com que uma falha seja indicada durante a execução do teste, mesmo que o objetivo dele esteja correto. Por exemplo:

1
2
3
4
  1) Patrimony has owner
     Failure/Error: let(:patrimony) { create :patrimony }
     ActiveRecord::RecordInvalid:
       Validation failed: Name has already been taken

Nosso processo de deploy passa por uma etapa de integração contínua em que utilizamos o serviço CircleCI. Se um teste falha, faz parte do processo verificar a causa e corrigir, mas este é um processo manual. Ou seja, uma falha no processo faz com que o tempo de deploy aumente consideravelmente. Portanto, falsos positivos são um gargalo. A imagem a seguir mostra um falso positivo em nossa ferramenta de integração contínua:

fail in circleci

Aqui na RD solucionamos os falsos positivos investigando caso à caso, tomando ações de acordo com o tipo de problema. A principal ação que tomamos foi remover as associações sem utilidade para o teste.

Aumentando a qualidade da documentação e a rastreabilidade de erros

A função do teste é descrever as regras que iremos implementar. Depois disso, uma das finalidades da especificação (Spec) é servir como documentação. Por exemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# Model
class Company < ActiveRecord::Base
  validates :name, presence: true
  validates :site, presence: true
  validates :address, presence: true
  validates :zip_code, presence: true
end

# RUIM
RSpec.describe Company, :type => :model do
  describe "validates" do
    it "be valid" do
      expect(create(:company)).to be_valid
    end
    it "#valid?" do
      company = Company.new
      company.name = Faker::Company.name
      expect(company.valid?).to eq false
      company.site = Faker::Internet.url
      expect(company.valid?).to eq false
      company.address = Faker::Address.street_address
      expect(company.valid?).to eq false
      company.zip_code = Faker::Address.zip_code
      expect(company.valid?).to eq true
    end
  end
end

# BOM
RSpec.describe Company, :type => :model do
  describe "validates" do
    it "be valid" do
      expect(create(:company)).to be_valid
    end
    describe "raise record invalid" do

      it "when Name is blank" do
        expect { create(:company, name: nil) }.to raise_error(ActiveRecord::RecordInvalid,"Validation failed: Name can't be blank")
      end

      it "when Site is blank" do
        expect { create(:company, site: nil) }.to raise_error(ActiveRecord::RecordInvalid,"Validation failed: Site can't be blank")
      end

      it "when Address is blank" do
        expect { create(:company, address: nil) }.to raise_error(ActiveRecord::RecordInvalid,"Validation failed: Address can't be blank")
      end

      it "when Zip Code is blank" do
        expect { create(:company, zip_code: nil) }.to raise_error(ActiveRecord::RecordInvalid,"Validation failed: Zip code can't be blank")
      end
    end
  end
end

Em ambos os casos validamos os campos da Company, que não podem ficar em branco. Porém, no primeiro caso (RUIM), o texto de output não ficou lógico.

bad documentation

Já no segundo caso (BOM), conseguimos entender o que foi testado apenas pela descrição do resultado.

good documentation

Também podemos dizer que a rastreabilidade de erros ficam comprometida quando testamos muitas regras dentro do mesmo teste. No caso do RSpec, ter um expect para cada it.

Outro problema de ter um teste com muita responsabilidade é de que erros podem ser ofuscados. Se remover alguma validação do modelo, nada garante que o teste irá acusar o erro correto.

Considerações finais

Boas práticas e cuidados na hora de escrever seus testes vão melhorar o tempo de execução, aumentar a consistência da sua documentação e fazer com que o RSpec pare de acusar falhas inexistentes, os chamados falsos positivos. Isso aumenta a produtividade do time e previne possíveis erros em produção.

Alexandre Tavares

Alexandre Tavares

Full Stack Developer

Comentários