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

Комментарии (3)


  1. CyganFx Автор
    30.08.2021 19:59

    Да да, автор имел ввиду именно 'четно', а никак не иначе. Благодарю всех за любопытство)


    1. batyrmastyr
      31.08.2021 09:44

      И какой же смысл сложил автор? Словари такого слова не знают.


  1. 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