← На главную

Практика написания модульных тестов в языке 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

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

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

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

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

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