Обычный пятничный вечер. Ты уже расслабился, попивая кружечку чая или чего покрепче... Но тут, как назло, бомбит личку в телеге твой надоедливый коллега 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)
{
...
}
}
}
Такой стиль предложил использовать Билл Кеннеди. Здесь приводится доходчивое описание и разделение проверок на логические компоненты.
(8-10) Инициализируем параметры, конфиги и тд, являющиеся общими для всего теста
(12) С помощью логов создаем детальное описание того, что будет проверять наш тест. Это необходимая часть, т.к. тестируемая сущность может быть намного сложнее и иметь множество разных применений и отдельных тестов для этого. Всегда начинаем с конструкции "Given the need to ..."
(14) Логически разделяем тесты с
testID
(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
Комментарии (3)
ninedraft
03.09.2021 12:17+1Хорошая статья для новичков!
Подскажите, а чем обусловлен выбор использования блоков кода +
t.Log
вместо запуска под-тестов с помощью t.Run?P.S.
Для дальнейшего чтения советую онлайн книгу Learn Go With Tests https://quii.gitbook.io/learn-go-with-tests/
А для красивого вывода результатов тестов рекомендую https://github.com/gotestyourself/gotestsum
CyganFx Автор
Да да, автор имел ввиду именно 'четно', а никак не иначе. Благодарю всех за любопытство)
batyrmastyr
И какой же смысл сложил автор? Словари такого слова не знают.