Практика написания модульных тестов в языке Go
14 января 2019
На первый взгляд, модульные тесты в Go пишутся очень просто. Создаем файл с именем пакет_test.go
, в нем объявляем функции с именами TestЧтоТестируем
, говорим go test
, ну и считай готово. Однако на деле все оказывается чуточку сложнее. Например, оказывается, что из коробки в языке нет ни ассертов, ни моков. А когда ты начинаешь генерировать моки, они внезапно начинают участвовать в подсчете покрытия кода тестами. В общем, давайте разберемся, как все устроено на самом деле.
Тестируемый код
Как и в статье про сериализацию, тестировать будем героев для какой-нибудь RPG. Поскольку на этот раз сериализовать мы ничего не будем, перепишем немного код, воспользовавшись типичной для Go реализацией типов-сумм через interface{}
:
Name string
HP int
XP int
info interface{} // *WarriorInfo or *MageInfo
}
Примечание: Как альтернативный вариант, мы могли бы сказать, что Hero
— это интерфейс, а Warrior
и Mage
являются совершенно независимыми структурами, реализующими данный интерфейс. Преимущество такого подхода заключается в том, что он позволяет избежать использования type switches, благодаря чему является более типобезопасным. Кроме того, при таком подходе в структурах используется меньше указателей. Минус подхода — некоторое распухание кода, связанное с тем, что для каждого экземпляра Hero
требуется объявить все методы интерфейса, даже если реализация отдельно взятого метода у них одинакова и представляет собой обертку над одной и той же функцией. А чем больше кода, тем менее эффективно используются кэши CPU. Кроме того, поскольку Hero
становится интерфейсом, все его методы будут вызываться через указатели, что ломает branch prediction. В общем и целом, у этих двух подходов есть свои слабые и сильные стороны.
Объявим немного вспомогательных методов:
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
:
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
:
TakeDamage(num int) int
}
Число, возвращаемое TakeDamage
, представляет собой урон, наносимый атакующему. Например, маг может иметь защитное поле, а огненный элементаль жжется. В настоящей игре возможных эффектов, накладываемых на атакующего, конечно, должно быть куда больше. Но для простоты ограничимся только одним возможным эффектом — получение ответного урона при атаке.
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 единиц маны.
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 единиц.
// 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
.
Начнем со совсем простого теста:
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
}
Говорим:
# export GOPATH=/home/eax/go
go generate ./...
Будем получен файл can_take_damage_mock.go
с нашим моком. Пример его использования:
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
, а метод, тем не менее, будет вызван, то тест не пройдет. Можно также указать конкретную реализацию интересующего метода:
Или, например, сказать Expect
вместо ExpectOnce
и узнать, сколько раз вызывался метод, посмотрев на TakeDamageCounter
. Тут проще всего ориентироваться по автокомплиту в IDE. Я лично в настоящее время пользуюсь GoLand от компании JetBrains.
Вообще, IDE очень сильно упрощает работу с тестами. Можно быстро переходить к упавшему тесту, запускать только заданный тест, в том числе под отладчиком, и так далее.
Мной было написано еще несколько тестов. Однако они принципиально ничем не отличаются от приведенных выше, поэтому двигаемся дальше.
Из консоли тесты запускаются как-то так:
Go умеет по-умному кэшировать результаты выполнения тестов. Иногда это кэширование хочется выключить. Сделать это можно так:
Чтобы оценить степень покрытия кода тестами, говорим:
Исключаем из отчета сгенерированный код:
Посмотрим статистику по функциям:
Пример вывода:
.../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%
Посмотрим, какие строки кода остались непокрыты тестами:
В итоге откроется браузер, и в нем мы увидим следующее:
На отрицательный HP, пожалуй, тест можно и добавить. А вот из-за всевозможных недостижимых кусков кода я бы не стал переживать. На практике покрытие 90% строк кода — очень даже хороший результат.
Два слова об интеграционных тестах
В реальной жизни интеграционными тестами обычно называют те, которые явно не модульные (когда все что можно мокается, как было описано выше), и явно не системные (когда тестируется вся система целиком, честно установленная и настроенная на стенде), а где-то посередке. В принципе, это даже может быть и полноценный модульный тест, который просто очень долго выполняется. Таким тестам хочется присвоить какую-то метку и запускать их отдельно от всех остальных тестов.
Для достижения этого эффекта в Go нужно сделать две вещи. Во-первых, такие тесты нужно сложить в отдельном каталоге. Для определенности, пусть это будет каталог integration
. Во-вторых, тесты в этом каталоге должны начинаться со строчки, содержащей метку:
Легко убедиться, что такие тесты не будут выполняться со всеми остальными.
Для их запуска следует воспользоваться командой:
Само собой разумеется, тэгов можно использовать много, и называться они могут как угодно.
Примечание: Если вы пишите код в GoLand, по умолчанию он не индексирует файлы под тэгами. Исправить это можно, введя используемые вами тэги через пробел в GoLand → Preferences… → Go → Build Tags & Vendoring → Custom tags.
Заключение
Как видите, при написании тестов в языке Go есть кое-какие нюансы.
Кое-что, конечно, в итоге осталось за кадром. Например, бенчмарки в Go пишутся почти аналогично тестам. Но о бенчмарках и профайлинге мы лучше поговорим в другой раз.
Полную версию исходников к посту вы найдете в этом репозитории на GitHub.
А чем вы пользуетесь при написании модульных тестов?
Дополнение: Вас также могут заинтересовать посты Тестирование проектов на Go с dockertest и Практика использования codecov.io в проектах на Go.
Метки: Go, Тестирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.