Прагматичные 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") }

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

  • Аж 3 использованные строчки кода

  • Не всеобъемлющее описание проверки.

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/575368/