Pull to refresh

Comments 17

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

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

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

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

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

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 — там вечные проблемы с лаконичностью и все не очень просто. Может и сам напишу вариант, попозже :)
Важно помнить, что let вычисляется лениво, и если его выполнение имеет какие-то сайд-эффекты, то ленивое вычисление может привести к наступанию на грабли. Пример не очень хорош, но пояснит идею.

Если леность мешает, то ее можно отменить, использовав let с восклицательным знаком:
let!(:user) { Fabricate(:user) }
О, круто, спасибо, одну маленькую это решит :) Странно даже, что до сих пор не видел — много же читал и сэмплов видел.
Я так понимаю, что в инклуде у вас опечатка и должно быть «include_context „shared_context“».
Вот только непонятно, как во втором дескрайбе появился контекст, он же явно не инклудится.
Да, действительно опечатка. А вот с неявным инклюдом 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, обосновался в своем файлике с прозрачным именем, и мы выкосили его из конфига в спек-хелпере (раньше ручками проверяли метадату и хачили окружение теста как надо)
Спасибо, выходит очень лаконично. Жаль, только, что в последнем случае нельзя контекст вызывать как:
context :eventmachine do

end

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

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

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

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

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
Тут очень тонкая грань. Другой человек потом посмотрит и подумает, что это стандартная функция и в других проектах будет так писать. А контекст возьми, да и не подключись, но ошибки не выдаст. Я вон минут 20 голову ломал, почему контекст не подключается если параметр только один =)
В целом согласен, но мне кажется, что если это решение будет использоваться в коде регулярно, а не 1-2 раза, то почему бы и нет — и человек рано или поздно увидит и этот код с переопределением контекста, или спросит «почему везде так, а у нас не так», или заметит потом, что написанные им на другом проекте работают не так, как надо (хотя потратит час на дебаг — но всего лишь один раз :) )

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

Articles