Как стать автором
Обновить

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

Время на прочтение6 мин
Количество просмотров29K

Обычный пятничный вечер. Ты уже расслабился, попивая кружечку чая или чего покрепче... Но тут, как назло, бомбит личку в телеге твой надоедливый коллега 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

Теги:
Хабы:
Всего голосов 12: ↑9 и ↓3+12
Комментарии6

Публикации

Истории

Работа

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
11 сентября
Митап по BigData от Честного ЗНАКа
Санкт-ПетербургОнлайн
14 сентября
Конференция Practical ML Conf
МоскваОнлайн
19 сентября
CDI Conf 2024
Москва
20 – 22 сентября
BCI Hack Moscow
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн
25 сентября
Конференция Yandex Scale 2024
МоскваОнлайн
28 – 29 сентября
Конференция E-CODE
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
30 сентября – 1 октября
Конференция фронтенд-разработчиков FrontendConf 2024
МоскваОнлайн