Практика написания модульных тестов в языке Go

14 января 2019

На первый взгляд, модульные тесты в Go пишутся очень просто. Создаем файл с именем пакет_test.go, в нем объявляем функции с именами TestЧтоТестируем, говорим go test, ну и считай готово. Однако на деле все оказывается чуточку сложнее. Например, оказывается, что из коробки в языке нет ни ассертов, ни моков. А когда ты начинаешь генерировать моки, они внезапно начинают участвовать в подсчете покрытия кода тестами. В общем, давайте разберемся, как все устроено на самом деле.

Тестируемый код

Как и в статье про сериализацию, тестировать будем героев для какой-нибудь RPG. Поскольку на этот раз сериализовать мы ничего не будем, перепишем немного код, воспользовавшись типичной для Go реализацией типов-сумм через interface{}:

type Hero struct {
  Name string
  HP   int
  XP   int
  info interface{} // *WarriorInfo or *MageInfo
}

Примечание: Как альтернативный вариант, мы могли бы сказать, что Hero — это интерфейс, а Warrior и Mage являются совершенно независимыми структурами, реализующими данный интерфейс. Преимущество такого подхода заключается в том, что он позволяет избежать использования type switches, благодаря чему является более типобезопасным. Кроме того, при таком подходе в структурах используется меньше указателей. Минус подхода — некоторое распухание кода, связанное с тем, что для каждого экземпляра Hero требуется объявить все методы интерфейса, даже если реализация отдельно взятого метода у них одинакова и представляет собой обертку над одной и той же функцией. А чем больше кода, тем менее эффективно используются кэши CPU. Кроме того, поскольку Hero становится интерфейсом, все его методы будут вызываться через указатели, что ломает branch prediction. В общем и целом, у этих двух подходов есть свои слабые и сильные стороны.

Объявим немного вспомогательных методов:

// IsDead return true if hero has zero HP.
func (h *Hero) IsDead() bool {
  return h.HP == 0
}

// IsWarrior return true if hero is a warrior.
func (h *Hero) IsWarrior() bool {
  switch h.info.(type) {
  case *WarriorInfo:
    return true
  default:
    return false
  }
}

// IsMage returns true if hero is a mage.
func (h *Hero) IsMage() bool {
  switch h.info.(type) {
  case *MageInfo:
    return true
  default:
    return false
  }
}

Реализуем метод Attack:

// Attack a given enemy
func (h *Hero) Attack(enemy CanTakeDamage) {
  if h.IsMage() {
    h.doMageAttack(enemy)
  } else if h.IsWarrior() {
    h.doWarriorAttack(enemy)
  } else {
    panic("unknown class")
  }
}

Атаковать можно не только других героев, но также мобов, предметы интерьера, и так далее. Поэтому аргументом методу передается не Hero, а интерфейс CanTakeDamage:

type CanTakeDamage interface {
  TakeDamage(num int) int
}

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

func (h *Hero) doMageAttack(enemy CanTakeDamage) {
  info := h.info.(*MageInfo)
  if info.Mana <= 5 {
    // there is no enough mana
    return
  }

  if len(info.Spellbook) == 0 {
    // there are no known spells
    return
  }

  info.Mana -= 5
  h.TakeDamage(enemy.TakeDamage(20))
}

Маги могут атаковать только при наличии маны и изученных заклинаний. Для простоты будем считать, что все заклинания наносят 20 очков урона и стоят 5 единиц маны.

func (h *Hero) doWarriorAttack(enemy CanTakeDamage) {
  info := h.info.(*WarriorInfo)
  if info.Weapon == BOW {
    if info.ArrowsNumber > 0 {
      // attack using a bow
      h.TakeDamage(enemy.TakeDamage(12))
      info.ArrowsNumber--
    }
  } else if info.Weapon == SWORD {
    h.TakeDamage(enemy.TakeDamage(8))
  } else {
    panic("unknown weapon")
  }
}

Воин, вооруженный луком, может атаковать только при наличии стрел. Мечем можно атаковать всегда. Стрелы всегда наносят 12 единиц урона, а меч — 8 единиц.

// TakeDamage takes the damage and returns the damage
// an attacker should take (e.g. because of applied spells)
func (h *Hero) TakeDamage(num int) int {
  h.HP -= num
  if h.HP < 0 {
    h.HP = 0
  }

  if (h.IsMage()) {
    // all mages are always protected
    return num / 10
  } else {
    return 0
  }
}

Наконец, реализуем TakeDamage для героев. Для простоты будем считать, что вокруг магов всегда есть волшебный щит, возвращающий атакующему 10% от нанесенного урона. Воины лишены каких-либо особых эффектов. Для баланса в будущих версиях игры, вероятно, им стоит добавить броню, благодаря которой воины принимают только часть урона.

Покрываем код тестами

Ну что же, думаю, логика получилась достаточно сложной, чтобы ее захотелось покрыть тестами. В предположении, что приведенный выше код находился в файле heroes.go, создадим файл с тестами под именем heroes_test.go.

Начнем со совсем простого теста:

package heroes

import (
  "github.com/stretchr/testify/require"
  "testing"
)

/* ... пропущено ... */

func TestHeroIsDead(t *testing.T) {
  // t.Skip()
  t.Parallel()
  h := heroMage()
  require.False(t, h.IsDead())

  h.HP = 0
  require.True(t, h.IsDead())
}

Как видите, ассерты было решено взять из пакета testify/require. Помимо проверок на True и False, также можно проверять Equal, NotEqual, Nil, NotNil, Zero, и много чего еще. Полную документацию вы найдете на godoc.org.

Строчка t.Parallel() говорит, что тест можно запускать параллельно с другими параллельными тестами. Если тест хочется на время выключить, можно написать t.Skip(). Главное не вмержить скипнутые тесты в мастер. Как по мне, уж лучше совсем выкинуть тест, чем держать куски мертвого кода.

Поскольку сейчас мы пишем модульные тесты, настоящим должен быть только код тестируемых функций, а все остальное необходимо замокать. Чтобы не писать моки руками, было решено воспользоваться minimock. Пользоваться им очень просто. Допустим, нам нужен мок с интерфейсом CanTakeDamage. Исправим код таким образом:

// на самом деле это одна строка, но здесь она не влезла:

//go:generate minimock -i github.com/afiskon/golang-unit-testing/⏎
//  heroes.CanTakeDamage -o . -s _mock.go
type CanTakeDamage interface {
  TakeDamage(num int) int
}

Говорим:

# если minimock будет валиться, убедитесь, что у вас объявлен GOPATH:
# export GOPATH=/home/eax/go
go generate ./...

Будем получен файл can_take_damage_mock.go с нашим моком. Пример его использования:

// Mage attack with -10 HP effect
func TestMageAttack(t *testing.T) {
  t.Parallel()
  h := heroMage()
  m := NewCanTakeDamageMock(t)
  m.TakeDamageMock.ExpectOnce(20).Return(10)
  h.Attack(m)
  require.Equal(t, h.HP, 90)
  require.Equal(t, h.info.(*MageInfo).Mana, 95)
}

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

m.TakeDamageMock.Set(func(p int) int { return p })

Или, например, сказать Expect вместо ExpectOnce и узнать, сколько раз вызывался метод, посмотрев на TakeDamageCounter. Тут проще всего ориентироваться по автокомплиту в IDE. Я лично в настоящее время пользуюсь GoLand от компании JetBrains.

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

Мной было написано еще несколько тестов. Однако они принципиально ничем не отличаются от приведенных выше, поэтому двигаемся дальше.

Из консоли тесты запускаются как-то так:

go test ./... -test.v

Go умеет по-умному кэшировать результаты выполнения тестов. Иногда это кэширование хочется выключить. Сделать это можно так:

GOCACHE=off go test ./... -test.v

Чтобы оценить степень покрытия кода тестами, говорим:

go test -coverprofile=coverage.out.tmp ./...

Исключаем из отчета сгенерированный код:

cat coverage.out.tmp | grep -v _mock.go > coverage.out

Посмотрим статистику по функциям:

go tool cover -func=coverage.out

Пример вывода:

.../heroes.go:40:       IsDead          100.0%
.../heroes.go:45:       IsWarrior       100.0%
.../heroes.go:55:       IsMage          100.0%
.../heroes.go:65:       Attack          80.0%
.../heroes.go:75:       doMageAttack    100.0%
.../heroes.go:91:       doWarriorAttack 87.5%
.../heroes.go:108:      TakeDamage      83.3%
total:                  (statements)    90.9%

Посмотрим, какие строки кода остались непокрыты тестами:

go tool cover -html=coverage.out

В итоге откроется браузер, и в нем мы увидим следующее:

Покрытие кода тестами в языке Go

На отрицательный HP, пожалуй, тест можно и добавить. А вот из-за всевозможных недостижимых кусков кода я бы не стал переживать. На практике покрытие 90% строк кода — очень даже хороший результат.

Два слова об интеграционных тестах

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

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

// +build integration

Легко убедиться, что такие тесты не будут выполняться со всеми остальными.

Для их запуска следует воспользоваться командой:

go test ./integration/... -test.v -tags integration

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

Примечание: Если вы пишите код в GoLand, по умолчанию он не индексирует файлы под тэгами. Исправить это можно, введя используемые вами тэги через пробел в GoLand → Preferences… → Go → Build Tags & Vendoring → Custom tags.

Заключение

Как видите, при написании тестов в языке Go есть кое-какие нюансы.

Кое-что, конечно, в итоге осталось за кадром. Например, бенчмарки в Go пишутся почти аналогично тестам. Но о бенчмарках и профайлинге мы лучше поговорим в другой раз.

Полную версию исходников к посту вы найдете в этом репозитории на GitHub.

А чем вы пользуетесь при написании модульных тестов?

Дополнение: Вас также могут заинтересовать посты Тестирование проектов на Go с dockertest и Практика использования codecov.io в проектах на Go.

Метки: , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.