Табличные тесты 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, а для чего стоит сгенерировать тесты.