rails
January 4, 2023

Табличные тесты Go, хочу такое же в RSpec

Go стал для меня первым языком (c Ruby я познакомился позже), который в процессе обучения приучает писать тесты и предоставляет для этого не плохой инструментарий.

Начиная знакомство с unit-тестированием на Go, тут же натыкаешься на понятие Table Driven Unit Tests.

И о чудо! Как же здорово эта концепция закрывает все потребности новичка в написании unit-тестов!

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

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

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

В этой статье я хочу осмыслить концепцию табличных тестов и попытаться понять их истинную значимость.

Мы поговорим о причине возникновения табличных тестов в Gо и почему при обучении тестированию им уделяют так много внимания.

Попытаемся понять, насколько табличные тесты полезны в современном Go и как концепция в целом.

Поговорим о том, как реализовать табличные тесты, если вы пишите не на Go, а на Ruby, используя RSpec.

Давайте говорить на одном языке

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

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

Что мне нужно при тестировании?

На чём бы я не писал, мне стабильно нужен инструментарий (синтаксис) для:

  • Описания тестируемого метода или функции (тест должен описывать, что мы тестируем);
  • Описания контекста тестирования (в ситуациях, когда пользователь авторизован и не авторизован, код метода M ведёт себя по-разному, этот контекст и поведение нужно отразить в тесте);
  • Описания примера/тестового случая (проверка, что 2+2=4);
  • Реализации возможности повторного использования кода, для тестирования одного и того же метода с разными входными параметрами.

Прежде чем идти дальше, я бы рекомендовал ознакомиться со статьёй Структура тестов Go, для RSpec-нутых, в которой как раз и идёт речь о том, как всё это реализуется в RSpec и Go, и как написать на Go такие же хорошо cтруктурированные тесты, как вы пишите на RSpec.

Немного больше про контекст

Если вы знакомы с RSpec, то уже понимаете о чём идёт речь, а если нет, то надеюсь уже поняли, что контекст - это синтаксический элемент (блок, если угодно), позволяющий отделить ситуацию, в которой проводится тестирование.
Пример: пользователь авторизован/не авторизован, в двигатель залили/не залили бензин, вышел на мороз в шапке/без шапки и так далее.

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

func TestDeleteDatabase(t *testing.T) {
    t.Run("When the user is logged in", func(t *testing.T) {
	    // test body
	})
	
    t.Run("When the user is not logged in", func(t *testing.T) {
	    // test body
	})
}

Проблемы контекста в Go

Проблема контекста в Go состоит в том, что этот функционал появился в Go 1.7. Было это в 2016 году. То есть с момента релиза первой версии в 2012 прошло 4 года, а с 2009, который считается годом рождения Go, все 7 лет, что довольно много.

Само собой, в сообществе были сформированы подходы для решения ряда задач, что и усилило роль табличных тестов в unit-тестировании на Go, они по сути были единственным решением возникающих проблем.

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

Например так:

testCases := []struct {
	x   int
    y   int
    res int
	err error
}{
	{2, 2, 4, nil},
	{101, 2, 0, errors.New("I don't like numbers over 100")},
	{"./test/data/testfile.dbf", fileEncoding, &TerReader{}, nil},
}

Далее, если в тест-кейсе ошибка не пуста, то и от тестируемого метода стоит ожидать ошибку, а иначе код работает не корректно.

Конечно же логичней разделить сценарии с ошибкой и без по контекстам (они же подтесты):

t.Run("when created successfully", func(t *testing.T) {
    testCases := []struct {
	    x   int
        y   int
        res int
    }{
	    {2, 2, 4},
	    {2, 3, 5}
    }
}

t.Run("when return error", func(t *testing.T) {
    testCases := []struct {
	    x   int
        y   int
        err error
    }{
        {101, 2, errors.New("I don't like numbers over 100")},
        {2, 200, errors.New("I don't like numbers over 100")}
    }
}

Насколько полезны табличные тесты?

Сразу спойлеры: табличные тесты полезны! Полезны, удобны, но вполне заменимы.

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

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

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

testCases := []struct {
    account_from string
    account_to   string
    amount       float64
    currency     string
}{
    {"123456", "654321", 1.0, "USD"},
    {"123456", "654321", 2.0, "RUB"},
    {"123456", "654321", 3.0, "EUR"},
}

Как реализовать табличные тесты на RSpec?

И опять хочется начать со спойлеров: реализовать табличные тесты (а так же нечто, что делает тоже самое, но выглядит иначе) можно. Разница с Go лишь в том, что об этом подходе мало где говорят. Во всяком случае наткнуться на эти материалы не так просто, как в Go.

И так к делу...

Способ 1 - Shared examples

О том, что такое shared examples, можно почитать вот тут.

Возьмём за основу пример с переводами средств в различной валюте и накидаем наш тест

shared_examples 'when the transfer is successful' do |account_from, account_to, amount, currency|
  it do
    # some expectation
  end
end

it_behaves_like 'when the transfer is successful', '123456', '654321', 1.0, 'USD'
it_behaves_like 'when the transfer is successful', '123456', '654321', 2.0, 'RUB'
it_behaves_like 'when the transfer is successful', '123456', '654321', 3.0, 'EUR'

При желании, чтобы добавить ясности, в shared examples можно добавить описание контекста таким образом:

shared_examples 'when the transfer is successful' do |account_from, account_to, amount, currency|
  context "with currency '#{currency}'" do
    it do
      # some expectation
    end
  end
end

или в любом удобном вам виде с перечислением всех параметров и так далее.

Способ 2 - Кодогенерация

Этот способ будет больше похож на применяемый в Go. Лишь с тем отличием, что наш цикл не обходит все случаи внутри тестовой функции, а создаёт эти самые тестовые функции на основе параметров, описанных в тестовом случае.

[
  {account_from: '123456', account_to: '654321', amount: 1.0, currency: 'USD'},
  {account_from: '123456', account_to: '654321', amount: 2.0, currency: 'RUB'},
  {account_from: '123456', account_to: '654321', amount: 3.0, currency: 'EUR'}
].each do |data|
  context "with currency '#{data[:currency]}'" do
    it do
      # some expectation
    end
  end
end

Способ 3 - Всё как в Go, только хуже

И в качестве третьего, но самого НЕПРАВИЛЬНОГО способа, опишем способ напоминающий концепцию Go, но в случае с RSpec, крайне неудобный и по сути вредный.

it do
  [
    {account_from: '123456', account_to: '654321', amount: 1.0, currency: 'USD'},
    {account_from: '123456', account_to: '654321', amount: 2.0, currency: 'RUB'},
    {account_from: '123456', account_to: '654321', amount: 3.0, currency: 'EUR'}
  ].each do |data|
    expect(transefer(data[:account_from], data[:account_to], data[:amount], data[:currency])).not_to raise_error
  end
end

Этот способ создаст множество "ожиданий" в одном тесте, и в случае провала одного из "ожиданий", будет не понятно, какой именно из случаев дал сбой.

Синтаксис Go позволяет использовать подтесты, которые решают эту проблему. Но как мы помним, subtests в Go и context в RSpec несут схожую смысловую нагрузку. Но так как согласно синтаксису RSpec, context не может находиться внутри it, этот вариант не подходит для использования в RSpec.

Как часто нужны табличные тесты в RSpec?

Это отражает лишь мой частный опыт, но в проектах, с которыми работаю, техника, подобная табличному тестирования, бывает нужна крайне редко.
98-99% всего, что написано в тестах, основано на describe и context.
В оставшихся 1-2% чаще используются shared examples и реже кодогенерация.

Выводы

Когда я впервые столкнулся с табличными тестами в Go, мне очень понравился этот подход. На тот момент я ещё не был знаком с Ruby и в целом был не очень опытным писателем тестов. Поэтому табличные тесты казались мне абсолютно великолепными, и мне хотелось распространить их везде, к чему я прикасался. Разумеется и за пределами Go кода. Однако, со временем приходит понимание, что это лишь инструмент для решения конкретных задач, и что важно - не единственный инструмент. Всему своё время и место.

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

Если же вы пишите тесты на RSpec, то понимание концепции табличных тестов Go поможет вам принимать более осознанные решения и лучше понимать, что должно быть выделено в контекст, что описано через shared examples, а для чего стоит сгенерировать тесты.

Относитесь ко всему разумно.