Sergey Potapov

A word from rustacean, rubist and linuxoid.

Тестируем вложенные ActiveRecord-модели с RSpec

Иногда бывает так, что вам нужно построить большой граф вложенных объектов, и конечно же протестировать, что ваш “builder” работает так, как нужно. На самом деле задача элементарная, но я всё же попробую поискать наиболее элегантный путь её решения.

Расмотрим следующий пример, когда у нас есть три небольшие модели: User, Account, Preference. (На практике обычно моделей намного больше с большим количеством свойств).

user.rb
1
2
3
4
5
class User < ActiveRecord::Base
  attr_accessible :last_name, :first_name

  has_one :account
end
account.rb
1
2
3
4
5
6
class Account < ActiveRecord::Base
  attr_accessible :email, :last_visit_date

  belongs_to :user
  has_one :preference
end
preference.rb
1
2
3
4
5
class Preference < ActiveRecord::Base
  attr_accessible :language, :weapon

  belongs_to :user
end

Предположим у нас есть некий builder, который строит объект User и все завимимые модели(Account и Preference). Всё что мы хотим сделать - это протестировать, что граф объектов построен правильно.

Вот пример реализации стандартного rspec-теста, который первым приходит на ум:

user_builder_spec.rb
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
describe UserBuilder do
  describe '#build' do
    describe 'user' do
      let(:user) { described_class.new(some_attrs).build }
      subject { user }

      its(:first_name) { should == "Rodion"      }
      its(:last_name)  { should == "Raskolnikov" }

      describe 'account' do
        subject { user.account }

        its(:email)           { should == "rodion@mail.ru"       }
        its(:last_visit_date) { should == Date.new(1866, 11, 27) }

        describe 'preference' do
          subject { user.account.preference }

          its(:language) { should == "Russian" }
          its(:weapon)   { should == "ax"      }
        end
      end
    end
  end
end

Всё читаемо, красиво и просто. Но проблема в том, что получилось шесть тестов(по тесту на каждый атрибут), которые тестируют лишь одно действие - постороение объекта. Когда операция построения занимает немало времени, а количество атрибутов на порядок больше, такой подход становится далеко не самым лучшим, поскольку возрастает время выполнения. Если объект не сохраняется в базе то вполне разумным будет использовать before :all. Но если вам приходится сохранять объект и вы используете опцию config.use_transactional_fixtures = true, потому что не хотите “гадить” в базу, то этот вариант не подойдёт.

Можно пойти по пути классического unit-тестирования и сделать тест подобным этому:

user_builder_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe UserBuilder do
  describe '#build' do
    let(:user) { described_class.new(some_attrs).build }

    it 'should correctly build a user' do
      user.first_name.should == "Rodion"
      user.last_name.should  == "Raskolnikov"
      user.account.email.should           == "rodion@mail.ru"
      user.account.last_visit_date.should == Date.new(1866, 11, 27)
      user.account.preference.language.should == "Russian"
      user.account.preference.weapon.should   == "ax"
    end
  end
end

Получилось даже лаконичнее, но менее читаемо(уж когда будет больше вложенность объектов, будет точно менее читаемо). Так же раздрожает длинные цепочки одинаковых методов.

Помедитировав над проблемой, я нашёл компроммиссное решение на основе метода instance_eval.

user_builder_spec.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
describe UserBuilder do
  describe '#build' do
    let(:user) { described_class.new(some_attrs).build }

    it 'should correctly build a user' do
      user.instance_eval do
        first_name.should == "Rodion"
        last_name.should  == "Raskolnikov"

        account.instance_eval do
          email.should           == "rodion@mail.ru"
          last_visit_date.should == Date.new(1866, 11, 27)

          preference.instance_eval do
            language.should == "Russian"
            weapon.should   == "ax"
          end
        end
      end
    end

  end
end

Это по-прежнему один тест, но мы избавились от длинных цепочек методов. Нужно заметить, что у такого подхода есть тоже свои недостатки: используя instance_eval мы покидаем контекст теста, и переходим прямо в контекст объекта, в котором не существует методов подобных be_valid, be_instance_of, etc.

Надеюсь, эта идея будет вам полезной. Буду рад узнать чужое мнение.

Comments