Практика написания модульных тестов в языке Go
На первый взгляд, модульные тесты в 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.
Вообще, 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
В итоге откроется браузер, и в нем мы увидим следующее:
На отрицательный HP, пожалуй, тест можно и добавить. А вот из-за всевозможных недостижимых кусков кода я бы не стал переживать. На практике покрытие 90% строк кода – очень даже хороший результат.
Два слова об интеграционных тестах
В реальной жизни интеграционными тестами обычно называют те, которые явно не модульные (когда все что можно мокается, как было описано выше), и явно не системные (когда тестируется вся система целиком, честно установленная и настроенная на стенде), а где-то посередке. В принципе, это даже может быть и полноценный модульный тест, который просто очень долго выполняется. Таким тестам хочется присвоить какую-то метку и запускать их отдельно от всех остальных тестов.
Для достижения этого эффекта в Go нужно сделать две вещи. Во-первых, такие тесты нужно сложить в отдельном каталоге. Для определенности, пусть это будет каталог integration. Во-вторых, тесты в этом каталоге должны начинаться со строчки, содержащей метку:
// +build integration
Легко убедиться, что такие тесты не будут выполняться со всеми остальными.
Для их запуска следует воспользоваться командой:
$ go test ./integration/... -test.v -tags integration
Само собой разумеется, тэгов можно использовать много, и называться они могут как угодно.
Примечание: Если вы пишите код в GoLand, по умолчанию он не индексирует файлы под тэгами. Исправить это можно, введя используемые вами тэги через пробел в GoLand → Preferences... → Go → Build Tags & Vendoring → Custom tags.
Заключение
Как видите, при написании тестов в языке Go есть некоторые нюансы.
Кое-что, конечно, в итоге осталось за кадром. Например, бенчмарки в Go пишутся почти аналогично тестам. Но о бенчмарках и профайлинге мы лучше поговорим в другой раз.
Полную версию исходников к посту вы найдете в этом архиве.
А чем вы пользуетесь при написании модульных тестов?
Дополнение: Вас также могут заинтересовать посты Тестирование проектов на Go с dockertest и Практика использования codecov.io в проектах на Go.