Прагматичные Unit тесты на Golang

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

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

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

Глава 1 — Условие

Предположим, что ваш стартап не удался, ваш пет проект онлайн магазина (на код коего благородный мастер с ютуба наделил вас единственными и исключительными правами) не шибко привлекает рекрутеров. Да и сидеть за монитором целыми днями — только зрение садить. Самое время переквалифицироваться и пойти потаксовать по улицам родного города.

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

vehicles/models.go
package vehicles import ( "encoding/json" "errors" "time"
) var ( PetrolError = errors.New("not enough fuel, visit a petrol station") GasError = errors.New("not enough fuel, visit a gas station")
) type TaxiDriver struct { Vehicle Vehicle `json:"-"` ID int `json:"id"` OrdersCount int `json:"orders"`
} func (x *TaxiDriver) SetVehicle(isEvening bool) { if !isEvening { x.Vehicle = &Camry{ FuelConsumption: 10, EngineLeft: 1000, IsPetrol: true, } } else { x.Vehicle = &LandCruiser{ FuelConsumption: 16, EngineLeft: 2000, IsPetrol: false, } }
} func (x *TaxiDriver) Drive() error { if err := x.Vehicle.ConsumeFuel(); err != nil { return err } x.OrdersCount++ return nil
} type ReportData struct { TaxiDriver Date time.Time `json:"date"`
} func (x *TaxiDriver) SendDailyReport() ([]byte, error) { data := ReportData{ TaxiDriver: *x, Date: time.Now(), } msg, err := json.Marshal(data) if err != nil { return nil, err } x.OrdersCount = 0 return msg, nil
} type Vehicle interface { ConsumeFuel() error
} type Camry struct { FuelConsumption float32 EngineLeft float32 IsPetrol bool
} func (x *Camry) ConsumeFuel() error { if x.FuelConsumption > x.EngineLeft { return PetrolError } x.EngineLeft -= x.FuelConsumption return nil
} type LandCruiser struct { FuelConsumption float32 EngineLeft float32 IsPetrol bool
} func (x *LandCruiser) ConsumeFuel() error { if x.FuelConsumption > x.EngineLeft { return GasError } x.EngineLeft -= x.FuelConsumption return nil
}

Quick notes:

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

  • Что если здесь появятся приватные поля в структурах? До тех пор, пока мы не зависим от структур с другого пакета, нам бояться нечего. В противном же, пришлось бы такие поля экспортировать или приписывать методы для получения таковых. Имхо, лучше объявлять поля публичными, пока нет веских оснований делать их недосягаемыми. Ну и нафига я джаву учил тогда?

  • Мы таксисты гордые и ездим Comfort+

Глава 2 — Unit тест

Для начала напоминание даже для самых закаленных в боях гоферов:

A unit test is a test of behaviour whose success or failure is wholly determined by the correctness of the test and the correctness of the unit under test.

— Kevlin Henney

И немного отсебятины от автора:

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

2.1 — Структура

Ну-с, приступим:

package vehicles import ( ...
) func TestTaxiDriver(t *testing.T) { driver := TaxiDriver{ ID: 1, } t.Log("Given the need to test TaxiDriver's behavior at different time.") { testID := 0 t.Logf("\tTest %d:\tWhen working in the morning.", testID) { ... } testID++ t.Logf("\tTest %d:\tWhen working in the evening.", testID) { ... } }
}

Такой стиль предложил использовать Билл Кеннеди. Здесь приводится доходчивое описание и разделение проверок на логические компоненты.

  1. (8-10) Инициализируем параметры, конфиги и тд, являющиеся общими для всего теста

  2. (12) С помощью логов создаем детальное описание того, что будет проверять наш тест. Это необходимая часть, т.к. тестируемая сущность может быть намного сложнее и иметь множество разных применений и отдельных тестов для этого. Всегда начинаем с конструкции «Given the need to …«

  3. (14) Логически разделяем тесты с testID

  4. (15) Объявляем один из наших подтестов. Обратите внимание на табуляцию и структуру сообщения. Всегда начинаем с ID теста и конструкции «When …«. Обособление тела подтеста кавычками полезно не только для читабельности, но и для изолирования от других, что, к примеру, позволит нам объявлять переменные с теми же именами

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

t.Logf("\tTest %d:\tWhen working in the morning.", testID)
{ driver.SetVehicle(false) car, ok := driver.Vehicle.(*Camry) ...

Здесь мы смотрим: правильную ли машину нам присвоили при вызове метода SetVehicle. ок должен вернуть нам true или false, но как это проверить? Рассмотрим несколько вариантов.

2.2 — Подходы

2.2.1 — Обычный подход

if !ok { t.Fatal("failed to cast interface")
}

Недостатками такого очевидного способа являются:

В общем, заносим данный подход смело в инвентарь плохих практик.

2.2.2 — Элегантный подход Билла Кеннеди

// Success and failure markers.
const ( success = "\u2713" failed = "\u2717"
) ...
if !ok { t.Fatalf("\t%s\tShould be able to set Camry : %T.", failed, car)
}
t.Logf("\t%s\tShould be able to set Camry", success)

В логах это выглядит примерно так:

Успешная проверка
Успешная проверка
При возникновении ошибки
При возникновении ошибки

Вывод в логах, конечно, мое почтение… Однако, даже у такого ‘crazy’ способа есть ряд недостатков:

  • Излишнее повторение кода

  • Запоминание табуляции

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

  • Вывод желаемого значения при ошибке не всегда читабелен. Что если бы мы сравнивали большие числа или очень длинные имена? К примеру: «Should be able to get 925120518250 : 925120158250«. Ну как, сразу ли нашли где не сходится?

  • Время, потраченное на оформление теста

Как бы грустно это не было, но Билл отправляется в инвентарь (но не с концами).

2.2.3 — Подход автора

Нам понадобится знаменитый и очень удобный пакет https://github.com/stretchr/testify, а также немного педантичности от Билла в оформлении сообщения:

require.Truef(t, ok, "Should be able to set Camry : %T.", car)

require — пакет, позволяющий проверять параметр на определенное значение, а в противном случае тут же прекращает тест. Возможно, у вас больше на слуху пакет assert. Различие в том, что он не сразу останавливает тест. А поскольку в 90% случаев нам нет смысла совершать дальнейшие проверки после ошибки, то лучше использовать его только в Table Driven тестах.

При возникновении ошибки
При возникновении ошибки

Преимущества данного метода:

  • Лаконичность

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

  • Детальное описание проверки с помощью конструкции «Should …»

  • Более чем детальный вывод ошибки

2.3 Продолжаем тест

Раз уж мы нашли оптимальный для нас подход, продолжим наш тест в том же духе:

 ... t.Logf("\tTest %d:\tWhen working in the morning.", testID) { driver.SetVehicle(false) car, ok := driver.Vehicle.(*Camry) require.Truef(t, ok, "Should be able to set Camry : %T.", car) car.EngineLeft = 15 // set on purpose to check for error err := driver.Drive() require.NoErrorf(t, err, "Should have enough fuel.") err = driver.Drive() require.Errorf(t, err, "Should not have enough fuel left.") require.ErrorIsf(t, err, PetrolError, "Should get error of appropriate type.") msg, err := driver.SendDailyReport() require.NoErrorf(t, err, "Should be able to marshall and send report.") require.Zerof(t, driver.OrdersCount, "Should reset OrdersCount.") expected := ReportData{ TaxiDriver: TaxiDriver{ ID: driver.ID, OrdersCount: 1, }, // skip Date on purpose } var actual ReportData err = json.Unmarshal(msg, &actual) require.NoErrorf(t, err, "Should be able to unmarshall.") if diff := cmp.Diff(expected, actual, cmpopts.IgnoreFields(ReportData{}, "Date")); diff != "" { t.Fatal(diff, "Should be able to unmarshall properly.") } } testID++ t.Logf("\tTest %d:\tWhen working in the evening.", testID) { ... }
...

Единственный момент, который стоит уточнить, это вызов метода Diff из пакета https://github.com/google/go-cmp. Это гугловский пакет, позволяющий сравнивать структуры между собой. Быстрее и эффективнее, чем более известный способ через reflect.DeepEqual.

В пакете testify тоже есть похожая и часто используемая функция Equal. Единственная причина по которой мы используем Diff вместо Equal: возможность исключить из проверки некоторые поля. Здесь мы не можем гарантировать одинаковое время создания отчета, поэтому можем скипнуть это поле.

При возникновении ошибки
При возникновении ошибки

Ну и следующий тест будет аналогичен первому, так что подведем на этом итог.

Глава 3 — Заключение

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

Почта: duman070601@gmail.com

LinkedIn

Читайте так же:

  • Европейский Центробанк запустил пилотный проект создания цифрового евроЕвропейский Центробанк запустил пилотный проект создания цифрового евро Европейский Центробанк запустил пилотный проект создания цифрового евро. Это позволит пользователям расплачиваться за товары и услуги без наличных денег по всей Европе. Этап исследований продлится два года, говорится на сайте регулятора. За это время разработчики решат вопросы […]
  • Симо Ахава: Мы входим в эру, в которой существуют две модели аналитикиСимо Ахава: Мы входим в эру, в которой существуют две модели аналитики Версия на русском English version  На конференции Go Analytics! 2019 редакция SEOnews познакомилась с экспертом по Google Analytics и Tag Manager Симо Ахава, и пообщалась с ним об аналитике, сервисах. SEO и написании статей в блог. Симо Ахава – финский аналитик. […]
  • Правовая охрана контента сайтаПравовая охрана контента сайта Информацию о компании см. В разделе LegalShield. Страхование правовой защиты (LPI), также известное как страхование юридических расходов (LEI) или просто юридическое страхование, является особым классом страхования [1], которое облегчает доступ к закону и правосудию путем […]
  • Линкбилдинг 2021: советы от практикаЛинкбилдинг 2021: советы от практика Ранее в этом месяце основатель и глава британского креативного агентства Rise at Seven Кэрри Роуз (Carrie Rose) поделилась своим опытом в области линкбилдинга. Собрав всю самую важную информацию в одном треде в Twitter. «За последние 7 лет я создала сотни тысяч ссылок на некоторых из […]