30 ноября 2012 в 22:18

Применение принципа DRY в RSpec



DRY(Don’t Repeat Yourself) — один из краеугольных принципов современной разработки, а особенно в среде ruby-программистов. Но если при написании обычного кода повторяющиеся фрагменты обычно легко можно сгруппировать в методы или отдельные модули, то при написании тестов, где повторяющегося кода порой еще больше, это сделать не всегда просто. В данной статье содержится небольшой обзор средств решения подобных проблем при использовании BDD-фреймворка RSpec.

1. Shared Examples


Самый известный и часто используемый метод создания многократно используемого кода для Rspec. Отлично подходит для тестирования наследования классов и включений модулей.

shared_examples "coolable" do
  let(:target){described_class.new}

  it "should make cool" do
    target.make_cool
    target.should be_cool
  end
end

describe User do
  it_should_behave_like "coolable"
end

Кроме того Shared Example Groups обладают и некоторым дополнительным функционалом, что делает их гораздо более гибкими в использовании: передача параметров, передача блока и использование let в родительской группе для определения методов.

shared_examples "coolable" do |target_name|
  it "should make #{ target_name } cool" do
    target.make_cool
    target.should be_cool
  end
end

describe User do
  it_should_behave_like "coolable", "target user" do
    let(:target){User.new}
  end
end

Подробнее о том, где и как будут доступны определенные методы, можно прочитать у Дэвида Челимски[2].

2. Shared Contexts


Данная фича несколько малоизвестна в силу своей относительной новизны(появилась в RSpec 2.6) и узкой области применения. Наиболее подходящей ситуацией для использования shared contexts является наличие нескольких спеков, для которых нужны одинаковые начальные значения или завершающие действия, обычно задаваемые в блоках before и after. На это намекает и документация:

shared_context "shared stuff", :a => :b do
  before { @some_var = :some_value }
  def shared_method
    "it works"
  end
  let(:shared_let) { {'arbitrary' => 'object'} }
  subject do
    'this is the subject'
  end
end

Очень удобной вещью в shared_context является возможность их включения по метаинформации, заданной в блоке describe:

shared_context "shared with somevar", :need_values => 'some_var' do
  before { @some_var = :some_value }
end

describe "need som_var", :need_values => 'some_var' do
  it “should have som_var” do
    @some_var.should_not be_nil
  end
end


3. Фабрики объектов


Еще один простой, но очень важный пункт.

@user = User.create(
  :email => ‘example@example.com’,
  :login => ‘login1’,
  :password => ‘password’,
  :status => 1,
  …
)

Вместо многократного написания подобных конструкций следует использовать гем factory_girl или его аналоги. Преимущества очевидны: уменьшается объем кода и не нужно переписывать все спеки, если вы решили поменять status на status_code.

4. Собственные матчеры


Возможность определять собственные матчеры — одна из самых крутых возможностей в RSpec, благодаря которой можно нереально повысить читабельность и элегантность ваших спеков. Сразу пример.
До:
it “should make user cool” do
  make_cool(user)
  user.coolness.should > 100
  user.rating.should > 10
  user.cool_things.count.should == 1
end

После:
RSpec::Matchers.define :be_cool do
  match do |actual|       
    actual.coolness.should > 100 && actual.rating.should > 10 && actual.cool_things.count.should == 1
  end
end

it “should make user cool” do
  make_cool(user)
  user.should be_cool
end

Согласитесь, стало в разы лучше.
RSpec позволяет задавать сообщения об ошибках для собственных матчеров, выводить описания и выполнять чейнинг, что делает матчеры гибкими настолько, что они просто ничем не отличаются от встроенных. Для осознания всей их мощи, предлагаю следующий пример[1]:

RSpec::Matchers.define :have_errors_on do |attribute|
  chain :with_message do |message|
    @message = message
  end
  match do |model|
    model.valid?
    @has_errors = model.errors.key?(attribute)
    if @message
      @has_errors && model.errors[attribute].include?(@message)
    else
      @has_errors
    end
  end
  failure_message_for_should do |model|
    if @message
      "Validation errors #{model.errors[attribute].inspect} should include #{@message.inspect}"
    else
      "#{model.class} should have errors on attribute #{attribute.inspect}"
    end
  end
  failure_message_for_should_not do |model|
    "#{model.class} should not have an error on attribute #{attribute.inspect}"
  end
end


5. Однострочники


RSpec предоставляет возможность использования однострочного синтаксиса при написании простых спеков.

Пример из реального opensource-проекта(kaminari):
context 'page 1' do
  subject { User.page 1 }
    it { should be_a Mongoid::Criteria }
    its(:current_page) { should == 1 }
    its(:limit_value) { should == 25 }
    its(:total_pages) { should == 2 }
    it { should skip(0) }
  end
end

Явно гораздо лучше, чем:
context 'page 1' do
  before :each do
    @page = User.page 1
  end     
  
  it  “should be a Mongoid criteria” do
    @page.should be_a Mongoid::Criteria
  end

  it “should have current page set to 1” do
    @page.current_page.should == 1
  end
   ….
  #etc


6. Динамически создаваемые спеки


Ключевым моментом здесь является то, что конструкция it (как впрочем и context и describe) является всего лишь методом, принимающим блок кода в качестве последнего аргумента. Поэтому их можно вызывать и в циклах, и в условиях, и даже составлять подобные конструкции:

it(it("should process +"){(2+3).should == 5}) do
  (3-2).should == 1
end

Оба спека кстати проходят успешно, но страшно даже подумать, где такое можно применить, в отличие от тех же циклов и итераторов. Пример из той же Kaminari:

[User, Admin, GemDefinedModel].each do |model_class|
  context "for #{model_class}" do
    describe '#page' do
      context 'page 1' do
        subject { model_class.page 1 }
          it_should_behave_like 'the first page'
        end
       …
     end
  end
end

Или же пример с условиями:

if Mongoid::VERSION =~ /^3/
  its(:selector) { should == {'salary' => 1} }
else
  its(:selector) { should == {:salary => 1} }
end


7. Макросы


В 2010 году, после введения нового функционала shared examples, Дэвид Челимски заявил, что макросы больше не нужны. Однако если вы все же считаете, что это наиболее подходящий способ улучшить код ваших спеков, вы можете создать их примерно так:

module SumMacro
  def it_should_process_sum(s1, s2, result)
    it "should process sum of #{s1} and #{s2}" do
      (s1+s2).should == result
    end
  end
end

describe "sum" do
  extend SumMacro

  it_should_process_sum 2, 3, 5
end

Более подробно останавливаться на этом пункте смысла не вижу, но если вам захочется, то можно почитать [4].

8. Let и Subject


Конструкции let и subject нужны для инициализации исходных значений перед выполнением спеков. Конечно все и так в курсе, что писать так в каждом спеке:
it “should do something” do
  user = User.new
  …
end

совсем не здорово, но обычно все пихают этот код в before:

before :each do
  @user = user.new
end

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

describe "number" do
  subject(:number){ 5 }

  it "should eql 5" do
    number.should == 5
  end
end


Let схож с subject’ом, но используется для объявления методов.

Дополнительные ссылки


1. Custom RSpec-2 Matchers
solnic.eu/2011/01/14/custom-rspec-2-matchers.html
2. David Chelimsky — Specifying mixins with shared example groups in RSpec-2
blog.davidchelimsky.net/2010/11/07/specifying-mixins-with-shared-example-groups-in-rspec-2
3. Ben Scheirman — Dry Up Your Rspec Files With Subject & Let Blocks
benscheirman.com/2011/05/dry-up-your-rspec-files-with-subject-let-blocks
4. Ben Mabey — Writing Macros in RSpec
benmabey.com/2008/06/08/writing-macros-in-rspec.html

А в заключение могу только сказать могу только сказать — старайтесь меньше повторяться.
@rsludge
карма
36,7
рейтинг 0,0
Похожие публикации
Самое читаемое Разработка

Комментарии (17)

  • +2
    Все ок, только по мне так примеры не очень. Спасибо за поднятую хорошую тему!
  • 0
    Let схож с subject’ом, но используется для объявления методов.

    Может быть вы в курсе. Чем именованные subject отличается от let? Тем, что однострочниках распознается?

    4. Собственные матчеры

    Пример с матчером be_cool очень плох на мой взгляд. Вместо читаемого спека тот код, что там есть превратился в какую-то нечитаемую неинтуитивную магию, особенно, если описание матчера вынести в другое место. Мне кажется, в этом случае, лучше оставить только has_error_on, поскольку только этот пример действительно проповедует добро.
  • 0
    Это перевод документации что ли?
  • +1
    Не идеально, но очень неплохо :)

    Из замечаний:

    1. Про «примеры не очень» сказали, имхо имело смысл начать с let, subject и one-liner'ов, и везде ниже их использовать

    2. Про shared_context не слышал, звучит интересно, но из описания тут не очень понял, как конкретно работает фича, пришлось лезть в доки, больше внимания деталям :)

    3. Макросы можно было, наверное, не описывать — велика вероятность, что они скоро попадут в deprecated, а shared_example действительно целиком замещают их функционал.

    4. В форматировании однострочников ошибка, там лишний отступ для It'ов и лишний end в конце :)

    От себя, про важные мелочи:

    Про describe: В сэмплах выше использовался describe со стрингой, но он умеет больше. В качестве параметра может принять класс klass, тогда он сам заинит subject как klass.new и будет поддерживать нужный неймспейс внутри тестов. Причем его можно дернуть внутри контекста существующих модулей, например. Очень удобно:

    module Project
      module Models
        describe User do # subject стал User.new
          its(:value) { should be_nil }
          specify { expect { subject.test }.to raise_error Errors::UserError }
          # Не Project::Models::Errors::UserError
        end
      end
    end
    


    Про context: контекст — примерно то же самое, что и describe, кроме того что не изменяет subject. Хорошая практика их использовани — когда у нас контекст целиком состоит из атомарных спек (те самые one-liner'ы), а конкретное описание теста складывается из имен вложенных контекстов. См. примеры ниже про let и subject

    Про let и subject: собственно отличаются они лишь тем, что subject указывает на тестируемый объект и в однострочниках expectation'ы автоматически связываются именно с ним, если вызваны не на каком-то конкретном объекте. Есть важные замечание про их природу.

    let — это лямбда, которая лениво вычисляется при первом обращении внутри теста, и в let сохраняется результат вычисления. Так что если у вас есть какое-то тяжелое вычисление, которое может просадить тесты — не бойтесь запихать его в let, оно выполнится только если необходимо в тесте.

    let'ы можно вызывать внутри друг друг, переопределять внутри контекста и т.д.

    context "#request" do
      let(:type) { 'correct_type' }
      let(:params) { {type: type} }
      subject { Interface.new.request(params) }
      it { should be_ok }
      context "with incorrect type" do
        let(:type) { 'madness' }
        it { should_not be_ok }
      end
    end
    


    Можно хитрить и заставить let возвращать лямбду, тогда можно сократить на синтаксисе еще немного :) Не всегда это хорошо, но иногда уместно.

    context "#request" do
      let(:interface_call) { ->{ subject.some_call(type) } }
      context "with incorrect type" do
        let(:type) { 'Invalid type' }
        specify { interface_call.should raise_error Errors::InvalidTypeError }
      end
    end
    


    Только стоит помнить, что let вычисляется ровно один раз. Потому если вы решите его использовать как шорт-кат для измерения какого-то значения, то вас ждет провал :)

    context "#add" do
      let(:value) { subject.some_hardly_accessible_value.to_i }
      specify { subject.add(20).should chage { value }.by(20) }
      # Провалится, value вычислится один раз и больше не изменится
    end
    


    Важно помнить, что let вычисляется лениво, и если его выполнение имеет какие-то сайд-эффекты, то ленивое вычисление может привести к наступанию на грабли. Пример не очень хорош, но пояснит идею.

    context "#collect_ids" do #Например, метод собирает из базы айдишники всех пользователей.
      let(:user) { Fabricate(:user) } # Фабрикатором cоздается запись про юзера в базке
      specify { User.all.collect_ids.should include user.id }
    end
    


    Эта спека провалится. Связано, очевидно, с тем, что список всех айдишников составляется до того, как будет вызвал let(:user), и на базке будет создан юзер. Соответственно делать let'ы надо без сайд-эффектов, что б на такие грабли не наступать. Ну или помнить о них. Чувствительные сайд-эффекты лучше явно указать в before.

    В общем, let'ы очень удобны и довольно мощны, рекомендую их щедро использовать.

    Пояснение про shared_context: Помимо того, что это переносной контекст, обращу внимание на одну фичу, которая из текста не очень ясна. Существует два метода его использования в спеках.

    shared_context "shared_context", state: :a do # в метаданных указан state: :a - это важно
      # some context
    end
    
    describe "#direct include" do
      include_context "shared stuff" # прямо инклюдим shared_context в наш текущий контекст
      context "subcontext_with_a_state", state: :a do
        #some specs
      end
      
      context "subcontext_with_b_state", state: :b do
        #other specs
      end
    
      #оба контекста будут включать в себя shared_context
    end
    
    describe "#metadata include" do
      context "subcontext_with_a_state", state: :a do
        #some specs
      end
      
      context "subcontext_with_b_state", state: :b do
        #other specs
      end
    
      #только первый контекст включит в себя shared_context, так как совпадет по метадате
    end
    

    Я не 100% уверен в точности интерпретации, нет возможности потестить, но из док получается так

    Из кратких замечаний наверное все %)
    Еще хотел бы прочитать чужое мнение про тестирование методов с помощью should_receive — там вечные проблемы с лаконичностью и все не очень просто. Может и сам напишу вариант, попозже :)
    • 0
      Важно помнить, что let вычисляется лениво, и если его выполнение имеет какие-то сайд-эффекты, то ленивое вычисление может привести к наступанию на грабли. Пример не очень хорош, но пояснит идею.

      Если леность мешает, то ее можно отменить, использовав let с восклицательным знаком:
      let!(:user) { Fabricate(:user) }
      • 0
        О, круто, спасибо, одну маленькую это решит :) Странно даже, что до сих пор не видел — много же читал и сэмплов видел.
    • 0
      Я так понимаю, что в инклуде у вас опечатка и должно быть «include_context „shared_context“».
      Вот только непонятно, как во втором дескрайбе появился контекст, он же явно не инклудится.
      • 0
        Да, действительно опечатка. А вот с неявным инклюдом shared_context — это очень крутая фича, о которой, собственно, и речь, а автор поста не обратил на неё особого внимания.

        Смотрите, вот рабочий тест, специально написал и проверил, и вам заодно покажу :)

        require 'rspec'
        
        shared_context "shared_context", state: :a do
          let(:val) { 2 }
        end
        
        describe 'shared context' do
          let(:val) { 1 }
          context 'included by metadata', state: :a do
            specify { val.should == 2 }
          end
        
          context 'not included with distinct metadata', state: :b do
            specify { val.should == 1 }
          end
        end
        

        У shared_context явно прописана метадата {state => :a}. Соответственно, в те контексты, которые имеют такую метадату (а соответственно и во все контексты-потомки) подключится этот shared_context. Тут есть сложные вопросы, как работает матч (полное совпадение, частичное? в каком порядке инклюдятся эти контексты, и т.д., но суть ясна.

        Как это использовать — довольно очевидно. Например, у нас есть код, работающий с логикой, и часть этого кода активно использует EventMachine. Для таких тестов требуется отдельная инициализация этой самой эвент-машины, что вы и вынесли в shared_context.

        К слову, есть такой отличный ключ в конфиге rspec'а:

        RSpec.configure do |config|
          config.treat_symbols_as_metadata_keys_with_true_values = true
        end
        

        Можете сами догадаться, что он делает :D

        Теперь тесты, подключающие эвентмашину, стали выглядеть так:

        context 'some logic state', :eventmachine do
          ..
        end
        

        Соответствующий код перекочевал в shared_context, обосновался в своем файлике с прозрачным именем, и мы выкосили его из конфига в спек-хелпере (раньше ручками проверяли метадату и хачили окружение теста как надо)
        • 0
          Спасибо, выходит очень лаконично. Жаль, только, что в последнем случае нельзя контекст вызывать как:
          context :eventmachine do

          end

          Кстати, а у Вас есть опыт тестирования эвентмашины, не хотите поделиться этим, может быть даже в статье?
          • 0
            Ну мы не тестировали саму EM, мы используем очень ограниченный её функционал — она обеспечивает нам на сервере глобальную очередь c таймерами, собственно всё что нам надо — запустить эвентмашину и поднять эту самую очередь. А потому всё те же stub и should_receive и should change, никакой особой специфики. Как протестировать написанный на ней вебсервер я до сих пор толком не знаю :)

            Агитирую вас не использовать пустые контексты, пусть всегда будут именованые — ведь эта группировка не с потолка же берется, должна быть за ней какая-то логика.
            • 0
              Я бы не стал писать веб сервер на EM, не для того она. Когда я писал на ней игру, то я ограничился тестированием только игровой логики, внутри EM без поднятия самой машины, используя should_receive на машиновских send'ах.

              >> Агитирую вас не использовать пустые контексты, пусть всегда будут именованые — ведь эта группировка не с потолка же берется, должна быть за ней какая-то логика.

              По сути это не пустой контекст, там вресто имени используется шаред контекст, чего rspec не позволяет.
              Можно было бы написать context :with_eventmachine do, вместо context 'with eventmachine' :with_eventmachine do
              • 0
                Ну в любом случае, что ж вы как не родной-то! Это ж руби, а спеки — ваш локальный код, всегда можно подкрутить чего-нибудь по желанию ;)

                require 'rspec'
                
                class RSpec::Core::ExampleGroup
                  class << self
                    alias_method :old_context, :context
                  end
                  def self.context *args, &block
                    args[0].is_a?(Symbol) ? old_context(args[0].to_s, *args, &block) : old_context(*args, &block)
                  end
                end
                
                RSpec.configure do |config|
                  config.treat_symbols_as_metadata_keys_with_true_values = true
                end
                
                shared_context "shared_context", :key do
                  let(:val) { 2 }
                end
                
                describe 'shared context' do
                  let(:val) { 1 }
                  context :key do
                    specify { val.should == 2 }
                  end
                
                  context :other_key do
                    specify { val.should == 1 }
                  end
                end
                
                • 0
                  Тут очень тонкая грань. Другой человек потом посмотрит и подумает, что это стандартная функция и в других проектах будет так писать. А контекст возьми, да и не подключись, но ошибки не выдаст. Я вон минут 20 голову ломал, почему контекст не подключается если параметр только один =)
                  • 0
                    В целом согласен, но мне кажется, что если это решение будет использоваться в коде регулярно, а не 1-2 раза, то почему бы и нет — и человек рано или поздно увидит и этот код с переопределением контекста, или спросит «почему везде так, а у нас не так», или заметит потом, что написанные им на другом проекте работают не так, как надо (хотя потратит час на дебаг — но всего лишь один раз :) )

                    У меня был схожий случай, когда сам дописал нетривиальный метод в RSpec, для вызова оригинального метода при использовании should_receive в сложных тестах, но тут вышла версия 2.12, где появился метод and_call_original :) Там, конечно, понятнее было, но сам факт, что мы расширяли стандартное поведение — на лицо, и нам это было только на руку.
              • +1
                Кстати, пока писал этот сэмпл, полез в код RSpec'а, узнал чем отличаются describe и context.
                Правильный ответ: ничем :)
                # rspec-core / lib / rspec / core / example_group.rb
                class << self
                  alias_method :context, :describe
                end
                
  • –2
    В статье слабо упоминается про Factory Girl, начинающим советовал бы заострить на ней побольше внимания.
  • 0
    В догонку betterspecs.org :)

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.