Простые заглушки в Go
И множестве других языков...
Перевод статьи "Simple Mock Objects in Go"
У меня нет фреймворка для заглушек.
Недавно я вычитал в оном из тредов вот такую идею:
В Go нет встроенного фреймока для создания заглушек(mocks) и gomock пока еще не готов для использования в продакшене! Когда же, в конце-то концов, мы сможем нормально TDD'ешить в Go?
Чувак, да ладно. Ты можешь делать свое TDD без всяких фреймворков для заглушек. Люди занимаются этим каждый день. Ты можешь начать разрабатывать через тестирование прямо сейчас. И без использования gomock и подобным ему вещей, если они тебе не нравятся.
Вся проблема в отношении разработчика, задающего вопрос. "У меня нет вот таких инструментов, предоставьте их мне или я не буду работать по TDD!". Навряд ли это ленивый или глупый разработчик. Go, как правило, не привлекает таких людей, но они уверены, что все делают правильно. В этом вопросе меня раздражает то, что как правило, он задается по привычке. Девелоперы приходят после работы с другими средами разработки, в частности ентерпрайзными, аналогичными C# например, и они привыкли к тем "официальным" инструментам, которыми они пользовались. И когда таких инструментов не обнаруживается, то они ждут их появления. Как ине кажется, это просто неприемлемо.
Что-то я много разглагольствую. Предположим, что у нас есть только gomock и нет никакого способа улучшить его - действительно ли это такая большая проблема, что стоит отказываться от TDD? Давайте на примере Go посмотрим, как можно самому писать заглушки и хелперы для тестирования.
Пишем собственные "Заглушки"
Давайте рассмотрим один из моих любимых пример - игровой цикл(Game Loop). Я писал про него в блоге "8th Light" и это было про C#. Пост была основан на вот этой замечательной статье. Почти каждая игра начинается с базового цикла:
while (true) {
processInput();
update();
render();
}
На самом деле, это достаточно стремный игровой цикл, потому что он не учитывает частоту кадров или время работы каждой итерации, а это необходимо для реализации правильной физике в игре, но для примера он подходит. Давайте посмотрим, как бы мы могли протестировать этот код.
Напишем первый тест:
package gameloop
import "testing"
func TestLoopUpdatesOnStart(t *testing.T) {
gl := &GameLoop{}
}
Это не соберется, пока не будет реализован объект GameLoop. У меня нет желания растягивать эту писанину на тысячу строк, поэтому я пропущу несколько шагов. И так, первый тест:
func TestLoopUpdatesOnStart(t *testing.T) {
game := &PhonyGame{}
gl := &GameLoop{Game: game}
gl.Update()
if true != game.Updated {
t.Error("Ожидается, что игра будет обновлена, но это не произошло")
}
}
В первой строке мы создаем указатель на PhonyGame
без указания дополнительных параметров. Посмотрим, что такое PhonyGame
:
type PhonyGame struct {
Updated bool
}
Откуда взялась эта структура? Возможно, это какая-то магия фреймворка для заглушек? Я забыл показать вам вызов go get
? Нет, все значительно проще:
func TestLoopUpdatesOnStart(t *testing.T) {
//...
}
type PhonyGame struct {
Updated bool
}
Ага, это просто объект, точнее структура. Структура с булевым параметром, который сетится в момент вызова Update
. Как мы ее устанавливаем?
func (g *PhonyGame) Update() {
g.Updated = true
}
Этот метод размещен, конечно же, прямо под определением структуры PhonyGame
. Все что я сейчас делаю - это создаю фейковые объекты для тестирования обновления. Но что насчет реального кода? Окей:
type Updater interface {
Update()
}
type GameLoop struct {
Game Updater
}
func (g *GameLoop) Update() {
g.Game.Update()
}
Updater
- это ужасное название, но я не придумал чего-то получше. Что он делает? Он обновляется. Объект GameLoop
оперирует объектом Game
типа Updater
, который является интерфейсом. Этот интерфейс реализован в объекте PhonyGame
, который я использую для своих тестов. Это одна из причин почему Go прекрасен. Принцип сегрегации интерфейса(Interface Segregation Principle) подразумевает, что клиент управляет интерфейсом, хотя мне больше нравится говорить "владеет" интерфейсом. В Go вы можете определить интерфейсы где угодно. И если объекты соответствуют этому интерфейсу, то они просто работают и не требуют никаких директив "implements". Такой подход, работа с парами интерфейс-реализация, это именно то, чего вам необходимо придерживаться при программировании на любом языке.
Что это означает? Это означает, что я могу протестировать свою игру, используя фейковые объекты(которые я создаю прям в тесте), если они реализуют необходимый мне интерфейс(ы). Показанный пример пока еще бесполезный, так что давайте немного расширим его. Игра должна остановить обновление по завершению, но должна делать обновления и прорисовку на каждой итерации. Давайте напишем все необходимые тесты:
func TestLoopUpdatesOnEachUpdate(t *testing.T) {
game := NewPhonyGame()
gl := &GameLoop{Game: game, Canvas: game}
game.SetTurnsUntilGameOver(1)
gl.Start()
AssertTrue(t, game.Updated())
}
func TestLoopDrawsOnEachUpdate(t *testing.T) {
game := NewPhonyGame()
gl := &GameLoop{Game: game, Canvas: game}
game.SetTurnsUntilGameOver(1)
gl.Start()
AssertTrue(t, game.Drawn())
}
func TestLoopDoesntUpdateWhenTheGameIsOver(t *testing.T) {
game := NewPhonyGame()
gl := &GameLoop{Game: game, Canvas: game}
game.SetTurnsUntilGameOver(0)
gl.Start()
AssertFalse(t, game.Drawn())
AssertFalse(t, game.Updated())
}
func TestLoopUpdatesUntilTheGameIsOver(t *testing.T) {
game := NewPhonyGame()
gl := &GameLoop{Game: game, Canvas: game}
game.SetTurnsUntilGameOver(2)
gl.Start()
AssertEquals(t, 2, game.DrawCount)
AssertEquals(t, 2, game.UpdateCount)
}
type PhonyGame struct {
UpdateCount int
DrawCount int
IsOverAnswers []bool
}
func NewPhonyGame() *PhonyGame {
g := &PhonyGame{}
g.IsOverAnswers = make([]bool, 0)
return g
}
func (g *PhonyGame) Updated() bool {
return g.UpdateCount > 0
}
func (g *PhonyGame) Update() {
g.UpdateCount++
}
func (g *PhonyGame) Drawn() bool {
return g.DrawCount > 0
}
func (g *PhonyGame) Draw() {
g.DrawCount++
}
func (g *PhonyGame) SetTurnsUntilGameOver(turns int) {
for i := 0; i < turns; i++ {
g.IsOverAnswers = append(g.IsOverAnswers, false)
}
}
func (g *PhonyGame) IsOver() bool {
if len(g.IsOverAnswers) > 1 {
answer := g.IsOverAnswers[0]
g.IsOverAnswers = append(g.IsOverAnswers[:0], g.IsOverAnswers[1:]...)
return answer
}
if len(g.IsOverAnswers) == 1 {
answer := g.IsOverAnswers[0]
g.IsOverAnswers = make([]bool, 0)
return answer
}
return true
}
func AssertTrue(t *testing.T, value bool) {
if !value {
t.Error("Expected true, got false")
}
}
func AssertFalse(t *testing.T, value bool) {
if value {
t.Error("Expected false, got true")
}
}
func AssertEquals(t *testing.T, expected, actual int) {
if expected != actual {
t.Errorf("Expected %d but got %d", expected, actual)
}
}
Это довольно длинный пример, поэтому я попытался кое-что сократить:
func TestLoopUpdatesUntilTheGameIsOver(t *testing.T) {
game := NewPhonyGame()
gl := &GameLoop{Game: game, Canvas: game}
game.SetTurnsUntilGameOver(2)
gl.Start()
AssertEquals(t, 2, game.DrawCount)
AssertEquals(t, 2, game.UpdateCount)
}
Я заменил в тестах использование &PhonyGame{}
на вызов фабричной функции NewPhonyGame()
. Внутри этой функции используется слайс и я должен быть уверен, что он правильно инициализируется. На следующей строчке инициализируется GameLoop
с указанием параметра Game
(наш Updater
) и Canvas
(объект для рисования). Для этих параметров нужны разные интерфейсы, но PhonyGame
реализует сразу оба интерфейса. Конечно, GameLoop
может использовать один объект, но я считаю, что правильно разделять и использовать два объекта для Draw
и Update
. Поэтому это два интерфейса. Функция SetTurnsUntilGameIsOver()
настроит PhonyGame
так что бы IsOver
возвращала true после двух "повторов". В динамический фреймворках мы можем писать game.stub(IsOver).andReturn([false, false, true])
и мне кажется так намного наглядней. Это говорит о том, что заглушка более "вещественна" чем хотелось бы, в основном, потому что у нас нет встроенного типа Queue
. Весьма вероятно, что в будущем я реализую этот тип.
О, и я написал несколько хелпереров для асертов.
Актуальный код:
package gameloop
type Updater interface {
Update()
IsOver() bool
}
type Canvas interface {
Draw()
}
type GameLoop struct {
Game Updater
Canvas Canvas
}
func (g *GameLoop) update() {
g.Game.Update()
g.Canvas.Draw()
}
func (g *GameLoop) Start() {
for !g.Game.IsOver() {
g.update()
}
}
Этот код не очень труден для понимания и его удобно использовать в тестах, но обратите внимание на проверку !g.Game.IsOver()
. Это некоторое поведение и я реализовал это поведение не правильно. Собственно, сам игровой цикл может иметь ограничения на частоту кадров, получать входные данные. И убедитесь, что игровой цикл запускается в своем собственном треде(go-рутине), отдельном от прорисовки. Все эти различные варианты поведений нужно проверить и это можно сделать изолированно.
Вы можете делать это в любых языках
Я регулярно общаюсь с разработчиками, будь то мои коллеги, студенты, или случайные люди в интернете, которые жалуются, что причина, по которой они не могут делать "хорошее" TDD, в отсутствии инструментов. На самом деле, инструменты для TDD не обязательны. И тут уместна цитата:
Первая атака на проблему проверки может быть сделана еще до написания кода. Чтобы быть уверенным в точности ответа, необходимо знать ручной способ расчета результатов. Нужно проверить результаты по некоторым кейсам, которые позднее будут проверяться на машине.
Отсылка к "машине" может дать вам подсказку. Эта цитата из бородатого 1957. Так что имейте ввиду, когда ваш архитектор скажет что вы не можете использовать фрейворк для заглушек или у IDE нет ранера, просто вспомните, что люди делали это еще на перфокартах!
Agile отпусти!
Помните как Agile грозился изменить мир? Да, этого не произошло, но у вас все еще есть возможность писать чистый, работающий, быстрый и надежный код.