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

Kate

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

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

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

Глава 1 - Условие​

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

17a0938a12925f48325f9eb053e4a05c.png

Напишем простенькую программу, в которой таксист может ехать, выбирать разные машины в зависимости от времени суток (мало ли жена решила нам подсобить с семейным заработком) и отправлять отчет в таксопарк в формате 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 - Заключение​

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

 
Сверху