rails
January 16, 2022

RSpec. Структура тестов. Как и зачем?

Мы в RNDSOFT очень любим писать тесты, а самое главное, любим красивые тесты и тщательно следим за этим на code review. От любви к тестам и появилась эта статья.

RSpec - это самый популярный фреймворк для тестирования в мире Ruby и, казалось бы, что о нём можно сказать? Имеются и статьи, и документация, недостатка в информации нет. Но плохо написанных тестов поразительно много. Почему так происходит? Придётся разбираться.

В рамках этой статьи я полагаю, что вы уже знакомы с RSpec, и вам доводилось писать на нём тесты. Мы не будем детально разбирать синтаксис RSpec, наша задача разобраться, как мыслить и какой инструментарий использовать, чтобы сделать тесты лучше.

О чём поговорим?

RSpec - очень мощный инструмент, который позволяет не только эффективно тестировать код, но и документировать его.

Иметь в своём проекте хорошо структурированные тесты, которые ещё и документируют его, хотят наверное все, но не все умеют этого добиваться.

А причина кроется чаще всего в незнании или осознанном не соблюдении принципов форматирования тестов, а чаще и в том, и в другом, да еще и при нехватке времени и постоянных выкатках новых фич. Поэтому я попытаюсь не только рассказать о существующих стандартах (обзорно), но и хочу донести их смысл, чтобы соблюдение этих правил было более осознанным. Для этого мы рассмотрим следующие вопросы:

  • Что такое хорошая структура тестов?
  • Почему мы хотим структурировать тесты?
  • Как документация RSpec учит плохому?
  • Чему учит Better Specs?
  • Почему rubocop-rspec - не злой контролёр, а мудрый учитель?

Что такое хорошая структура тестов?

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

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

Тест плохо структурирован, если:

  • тест нарушает правила форматирования, принятые в команде или в сообществе;
  • тест недостаточно документирует тестируемую логику и код;
  • у теста плохая архитектура (код громоздкий, запутанный, но его можно улучшить);
  • у теста есть технические проблемы, вызванные ошибками структурирования (работает медленно, не оптимально использует память и т.д.).

Кажется этих критериев будет достаточно. В остальных случаях тест можно считать хорошо структурированным.

Тут бы привести несколько примеров хороших и плохих тестов, но написанное далее сделает всё понятным и так.

Почему мы хотим структурировать тесты?

Тестируем простые функции

Давайте напишем две функции sum и multiply в файле math.rb

# math.rb
def sum(x, y)
  x + y
end

def multiply(x, y)
  x * y
end

И напишем для них тест в максимально простой форме

# math_spec.rb
require './math'

RSpec.describe do
  it { expect(sum(2, 2)).to eq 4 }

  it { expect(multiply(2, 2)).to eq 4 }
end

Как видим, в данном случае у нас нет острой необходимости структурировать тест каким-то особым образом (практической пользы мы не получим), и будь на то возможность, мы бы отбросили и describe (но так нельзя делать в рамках используемого фреймворка тестирования).

Тестируем простые методы

Если представить что обе описанные выше функции стали методами класса MyMath, ситуация принципиально не поменяется и особая структура теста по-прежнему не нужна, а любовь к форматированию тестов это не достаточный повод.

# my_math_spec.rb
require './my_math'

RSpec.describe MyMath do
  it { expect(described_class.sum(2, 2)).to eq 4 }

  it { expect(described_class.multiply(2, 2)).to eq 4 }
end

А теперь представим, что один метод - это метод класса, а второй - метод инстанса.

# my_math_spec.rb
require './my_math'

RSpec.describe MyMath do
  describe '.sum' do
    it { expect(described_class.sum(2, 2)).to eq 4 }
  end

  describe '#multiply' do
    subject(:math) { described_class.new }

    it { expect(math.multiply(2, 2)).to eq 4 }
  end
end

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

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

Тестируем методы с бизнес логикой

Одно дело, когда метод выполняет простую работу, складывает числа например, невзирая на то, при каких обстоятельствах он это делает. И совсем другое, если результат этого метода будет зависеть от роли пользователя.

Представим, что только для роли admin 2 + 2 = 4, а обычные пользователи живут в мире где 2 + 2 = 5. С умножением ситуация аналогичная.

# my_math_spec.rb
require './my_math'

RSpec.describe MyMath do
  context 'when user is admin' do
    describe '.sum' do
      it { expect(described_class.sum(2, 2)).to eq 4 }
    end

    describe '#multiply' do
      subject(:math) { described_class.new }

      it { expect(math.multiply(2, 2)).to eq 4 }
    end
  end

  context 'when user is client' do
    describe '.sum' do
      it { expect(described_class.sum(2, 2)).to eq 5 }
    end

    describe '#multiply' do
      subject(:math) { described_class.new }

      it { expect(math.multiply(2, 2)).to eq 5 }
    end
  end
end

Тут мы уже отчётливо видим необходимость контекстов. Контексты помогут отделить одну логику от другой, инициализировать переменные и данные в БД (если нужно), а так же позволят нашим тестам стать отличной документацией для других разработчиков.

Мы структурируем тесты, потому что...

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

Тесты - не только проверка работы кода, но и документация к нему.
И чем сложнее бизнес-логика проекта, тем ценнее способность тестов её документировать.

Как документация RSpec учит плохому?

Заголовок звучит странно, не так ли? Но так оно и есть.

Прочитав документацию поверхностно и не изучив дополнительные материалы, можно легко выработать вредные привычки, избавиться от которых будет очень трудно.

Примеры из документации

Дословно приведём код из документации rspec-core:

RSpec.describe Order do
  it "sums the prices of its line items" do
    order = Order.new
    order.add_entry(LineItem.new(:item => Item.new(
      :price => Money.new(1.11, :USD)
    )))
    order.add_entry(LineItem.new(:item => Item.new(
      :price => Money.new(2.22, :USD),
      :quantity => 2
    )))
    expect(order.total).to eq(Money.new(5.55, :USD))
  end
end

Сразу оговорюсь, что я понимаю, что примеры в документации это максимально простая демонстрация возможностей гема, без лишних подробностей. Но увиденное тут становится для многих нормой, без попытки адаптировать пример к боевым условиям.

Приведём ещё код из документации:

RSpec.describe "Using an array as a stack" do
  def build_stack
    []
  end
  before(:example) do
    @stack = build_stack
  end
  it 'is initially empty' do
    expect(@stack).to be_empty
  end
  context "after an item has been pushed" do
    before(:example) do
      @stack.push :item
    end
    it 'allows the pushed item to be popped' do
      expect(@stack.pop).to eq(:item)
    end
  end
end

Что же не так?

1. Имя класса Order используется в тесте напрямую, вместо использования described_class

Когда в describe верхнего уровня описывается тестируемый класс (что является хорошей практикой), то разумно использовать внутри теста хелпер described_class. Это позволит сократить код в тех случаях, когда имя класса длинное, а также избавит от необходимости переписывать код теста в случае, если имя класса изменится.

2. Для описания тестируемого метода не используется describe

Читая тест, мы хотим сразу понимать, какой метод тестируется. Поэтому хорошей практикой считается описание тестируемого метода при помощи блока describe.

3. Не лучшее использование context

Несмотря на то, что context используется по назначению, а именно для описания того, в какой ситуации и при каких условиях выполняется конкретный тест, его использование должно было быть шире, а описание более строгим (далее поясню).

4. Использование переменных инстанса вместо let и let!

let и let! хороши тем, что имеют ограниченную область видимости, в отличии от переменных инстанса.
Переменная из let будет инициализирована, только если она действительно понадобилась.

5. Тестируемый код, не очень хорошо подходит для объяснения основных концепций и принципов тестирования на RSpec

Было бы лучше, если бы самые первые примеры в документации описывали тесты для самых простых случаев (как например метод для складывания чисел). Приведённые в документации примеры заставляют думать о большем, чем просто структура тестов (а ведь это может быть ваше первое знакомство с фреймворком, и вы вообще не понимаете, что происходит).

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

К сожалению, подобная ситуация не редкость, поэтому нужно иметь ввиду, что не всегда документация к тому или иному инструменту является эталонным примером его использования в реальных проектах, а лишь описанием его возможностей.

Чему учит Better Specs?

Better Specs - набор правил, призванный сделать ваши тесты не только читаемыми, но и эффективными.

Из правил всегда есть исключения, но лишь настоящие эксперты с уверенностью могут определить исключительную ситуацию и осознанно пренебречь правилом. Остальные же рискуют попасть в ловушку своего незнания.

Мы рассмотрим лишь часть правил, а остальные оставим на самостоятельное изучение.

Вводное слово

Структура тестов на RSpec в основном обеспечивается блоками:

  • describe;
  • context;
  • it.

Остальные синтаксические конструкции уже дополняют структуру, выстроенную на их основе. Обсуждение синтаксиса RSpec заслуживает отдельного разговора, поэтому остановимся пока на этих трёх.

Давайте посмотрим, как эти блоки применяются в документации rspec-core:

RSpec.describe Order
RSpec.describe Array
RSpec.describe Widget
RSpec.describe "Using an array as a stack"

describe '#add'

context "after an item has been pushed"
context "with no items"
context "with one item"

it "sums the prices of its line items"
it 'is initially empty'
it 'allows the pushed item to be popped'

Если вы опытный пользователь RSpec, то вы с лёгкостью определите назначения этих блоков, а также сможете отбросить мусорные примеры, они тут есть, но в целом не так всё и плохо. В реальных проектах встречаются тесты, написанные на порядок хуже, и описанные выше блоки в них используются как попало, разница между ними практически не различима.

Перейдём к правилам

Мы рассмотрим лишь часть правил, но следование им станет хорошим началом и ощутимо улучшит ваши тесты.

Describe Your Methods (описывайте ваши методы)

# ПЛОХО
describe 'the authenticate method for User' do
describe 'if the user is an admin' do
# ХОРОШО
describe '.authenticate' do
describe '#admin?' do

discribe стоит использовать для описания тестируемого метода. Если это метод инстанса, нужно начитать именование с #, если метод класса то с . (или ::).

Особых комментариев, я думаю, тут не нужно. Это просто очень удобно. Обозначил метод, который тестируешь, а далее уже внутри блока описываешь все возможные контексты проверок и сами проверки.

Сделав как велит правило, вы превратите тест для конкретного метода в отдельную абстракцию, со своей логикой и областью видимости, которая не будет конфликтовать с остальными.

Use contexts (используйте контексты)

# ПЛОХО
it 'has 200 status code if logged in' do
  expect(response).to respond_with 200
end

it 'has 401 status code if not logged in' do
  expect(response).to respond_with 401
end
# ХОРОШО
context 'when logged in' do
  it { is_expected.to respond_with 200 }
end

context 'when logged out' do
  it { is_expected.to respond_with 401 }
end

Важно! Описание контекста может начинаться с одного из трёх слов:

  • when;
  • with;
  • without.

Здесь мы получаем больше чем читаемость (но и её тоже)! Мы получили изолированную область видимости и можем делать в ней, что захотим. Можем описывать блоки before и after, можем описывать любые блоки let и let!, не боясь сломать другие тесты, в то время как блок it не может дать нам этого.

Блок context умеет то же, что и it, но даёт большую гибкость.

А что касательно правил именования (when, with, without), то это элементарное снижение когнитивной нагрузки. Если соблюдать это правило, блоки всегда будут выглядеть привычным и понятным образом и будут легко считываться всеми участниками команды.

Use let and let! (используйте let и let!)

# ПЛОХО
describe '#type_id' do
  before { @resource = FactoryBot.create :device }
  before { @type     = Type.find @resource.type_id }

  it 'sets the type_id field' do
    expect(@resource.type_id).to eq(@type.id)
  end
end
# ХОРОШО
describe '#type_id' do
  let(:resource) { FactoryBot.create :device }
  let(:type)     { Type.find resource.type_id }

  it 'sets the type_id field' do
    expect(resource.type_id).to eq(type.id)
  end
end

Better Specs ссылается на вот такой ответ на вопрос о целесообразности этого правила.

Я бы выделил тот факт, что переменная из let будет инициализирована только в случае необходимости, что позволит вам повысить производительность. В случае когда вы грамотно используете describe и context, риск задать не используемую переменную снижается, но всё ещё остаётся, так что почему бы его не исключить полностью?

Любые утверждения по поводу чистоты кода субъективны, но лично я согласен, что с использованием let код выглядит чище.

Почему rubocop-rspec - не злой контролёр, а мудрый учитель?

Первым делом, вот вам ссылочка на rubocop-rspec (пока я буду тут рассуждать, вы как раз успеете затянуть его во все ваши проекты, если его там ещё нет).

rubocop-rspec заслуживает отдельного разговора, но я не мог пройти мимо него, когда речь идёт о построении эффективных и читаемых тестов.

Есть те, кто не любят Rubocop как явление и считают, что он больше мешает, всё время ругая за что-то. Я же придерживаюсь противоположной точки зрения.

Не будем распыляться и сконцентрируемся на rubocop-rspec. Если вы ознакомились с правилами, описанными в Better Spec, то rubocop-rspec это тот инструмент, который поможет вам их соблюдать.

Немного из того, за что вас может поругать rubocop-rspec:

  • неверное именование контекстов;
  • использование переменных инстанса;
  • слишком большая вложенность блоков (многие страдают от огромной вложенности контекстов. А вот хороший способ её ограничить);
  • слишком много использований let (иногда их действительно нужно много, а иногда это разработчик разошёлся и решил вынести в let что-то, что нужно лишь для инициализации другого let, а в тесте не используется);
  • слишком много expect в одном тесте;
  • слишком длинный тест;
  • и т.д.

Как по мне, такой помощник просто незаменим.

Если rubocop-rspec вас ругает, то примерно в 99,9% случаев это вы решили сделать что-то недопустимое, и лишь 0,1% случаев это исключительная ситуация, где вы осознанно пренебрегаете правилом и отключаете его лишь там, где оно не актуально.
Чтобы уверенно отличать исключительные ситуации, где отключение правила Rubocop действительно оправдано, требуется определённый опыт, но и он не панацея, так что прежде чем отключить правило - подумайте дважды.

Итоги

Тесты - это не только проверка кода, но и его документация.

Документация должна быть читаемой, а значит вам нужны читаемые тесты.

Читаемые тесты достигаются через структурирование.

Хорошее структурирование будет затруднено без соблюдения правил форматирования (желательно общепринятых, вы же хотите, чтобы вас понимали другие).

Better Specs - это правила, которые вам стоит соблюдать.

rubocop-rspec поможет вам соблюдать правила из Better Specs и не только.