Ранее я уже публиковал заметку о том, как использовать интерфейсы в Go. Чтобы показать глубину задумки авторов языка, хочу ещё привести наглядный пример, что есть интерфейс, как он нам облегчает жизнь и как помогает при создании структур-заглушек (mock-объектов).

Warning: Весь код ниже написан “из головы” и нигде не тестировался

Как не должны взаимодействовать пакеты в Go

В качестве примера я буду говорить о работе с базой данных. Но всё сказанное справедливо для любых других случаев взаимодействия пакетов - http-клиентов, логгеров и т.д. и т.п.

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

Т.е., в условном main.go или контроллере, делал простой вызов вида user, err := db.Read(&models.User{}) в условный пакет db/db.go.

Проблема прямых вызовов функций

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

Давайте представим такую задачу.

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

Несложно догадаться, что для этого придётся задействовать живой сервер БД… А в него надо положить тестовый слепок данных… А ведь ещё вся эта история должна взлетать в докер-контейнерах… А случись чего - заменили базу, это ж весь код надо переписать…

Ответы для части этих вопросов есть.

Вот статья на Хабре, где человек описывает, как он поднимает тестовые базы и мигрирует слепки данных при тестировании.

Интерфейс Golang - средство изоляции пакетов

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

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

Начнём с условного пакета db/db.go. Сразу оговорюсь - код буду сильно упрощать и урезать, чтобы не лишать вас удовольствия самостоятельных экспериментов и поиска косяков :))

//Вот он наш интерфейс, с одним объявленным методом
//Метод служит, скажем, для проверки существования пользователя в базе
// и называться он будет IsExist
type Inquirer interface {
	IsExist(*models.User) bool
}

//А вот структура базы MongoDB, с одним полем, хранящим ссылку на пулл сессий
// у структуры должен быть ещё один метод - для подключения к базе,
// но мы его в целях упрощения опустим
type Mongo struct {
	session *mgo.Session
}

//Вот самая интересная для нас часть - метод структуры, который по сигнатуре соответствует методу интерфейса
//Тут у нас реализовано:
func (m *Mongo) IsExist(mu *models.User) bool {
	sess := m.session.Clone() //...клонирование сессии 
	defer sess.Close() //...отложенное закрытие сессии и возврат её в пулл

    num, err := sess.DB("mydb").C("mycollection").Find(bson.M{"_id":mu.ID}).Count() //...запрос к базе и подсчёт количества возвращённых записей
	if err != nil {                                                                 //...так мы проверим, что запись существует в БД
		return false
	}
	if num >= 1 {
		return true //...если записи найдены, метод вернёт true
	}
	return false //...если нет, вернётся false
}

В этом пакете живут две разные сущности - интерфейс Inquirer и структура Mongo. Между собой они пока что не связаны, но метод структуры IsExist имеет сигнатуру такую же, как функция IsExist интерфейса.

Давайте напишем наш main.go.

/*Вот она, ключевая точка нашего кода, функция exist(), 
 для которой мы хотим написать тесты, чтобы быть уверенными в её поведении.

Входящим параметром функция принимает наш ИНТЕРФЕЙС из пакета db,
 т.е. в неё можно передать ЛЮБУЮ структуру, методы которой СООТВЕТСТВУЮТ ИНТЕРФЕЙСУ
*/
func exist(i db.Inquirer, u *models.User) bool {
    //...вызываем метод интерфейса, ничего не зная о содержимом скрытой за ним структуры
    return i.IsExists(u) 
}

func main() {
    //А теперь мы вызываем нашу функцию, передав в неё реальные структуры 
    // и выполнив в return i.IsExists(u) вызов нужного нам метода
    b := exist(&db.Mongo{}, &models.User{ID: "0x7777777777777777777777777777"})
    if b {
        fmt.Println("Ура!")
        return
    }
    fmt.Println("А это не ура!")
}

Таким образом, структура Mongo из пакета db/db.go получается связана с переменной интерфейса, а её методы маскированы методами интерфейса. И связали мы их непосредственно в момент вызова функции.

Грубо говоря, была у нас интерфейсная переменная i, содержащая указатель на интерфейс. Стала интерфейсной переменной i, содержащей указатель на интерфейс и в нём - указатель на структуру, соответствующую интерфейсу.

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

А ещё, возвращаясь к теме заметки, в такой конфигурации, при тестировании функции exist(), мы можем подменить реальную структуру db.Mongo{} на структуру-заглушку. Достаточно, чтобы она (заглушка или же mock) была передана в функцию первым параметром.

Табличное тестирование

Создадим main_test.go. Поскольку тестируемая функция exist() имеет областью видимости пакет main, то и наш тест будет частью этого пакета. Это значит, что начинаться файл main_test.go должен со строчки package main.

Подобная схема называется тестированием “белого” (хотя по сути прозрачного) ящика.

Если бы мы хотели протестировать какие-нибудь экспортируемые функции, правильно было бы объявить package main_test первой строкой теста. И работать с тестируемыми функциями как будто бы извне.

Это метод “черного” ящика.

//Создаём подменную структуру с одним полем-функцией MockIsExist
type Mock struct {
	MockIsExist func(*models.User) bool
}
//... и методом IsExist, который, как мы видим, соответствует интерфейсу (который остался в пакете db)
func (m *Mock) IsExist(mu *models.User) bool {
	return m.MockIsExist(mu)
}

//Хитрость тут в наличии в нашей структуре поля-функции MockIsExist,
//позволяющей нам отдавать тот ответ, который нужен методу IsExist
//Ниже будет понятно, как это работает

//Вот наша функция, тест для функциии exist() из main.go
func TestExist(t *testing.T) {
    //Создадим набор структур для т.н. табличного тестирования
	tt := []struct {
		caseName string //название тест-кейса
		id     string //идентификатор пользователя, нужный для заполнения структуры models.User{}, передаваемой вторым параметром в exist()
		mock     Mock //наша структура-заглушка
		expected bool //ожидаемый результат
    }{//Подготавливаем данные для тест-кейсов
     
        //Тест-кейс 1 - на корректный ID пользователя должен вернуться ответ true
		{"Ok", "0x777777777777777777", Mock{ //...создаём структуру, для поля MockIsExist подсовываем функцию, возвращающую true
			MockIsExist: func(*models.User) bool {
				return true
            }}, true},
        //Тест-кейс 2 - на некорректный ID пользователя должен вернуться ответ false
		{"Not ok", "XXX", MockDB{ //...создаём структуру, для поля MockIsExist подсовываем функцию, возвращающую false
			MockIsExists: func(*models.User) bool {
				return false
			}}, false},
	}

	for _, tc := range tt { //Прогоняем набор тестов, tc - сокращённо от test case
		t.Run(tc.caseName, func(t *testing.T) { //В каждой итерации цикла вызываем сабтест - t.Run
			b := exist(&tc.mock, &models.User{ID: tc.id})
            assert.IsType(t, b, tc.expected)
            //Для сравнения получаемого результата с ожидаемым я использовал функцию assert из фрэймворка testify/assert
            //Функция assert покажет ошибку тестирования, если возвращаемый результат не совпадёт с ожидаемым
		})
	}
}

Вот и всё, можно запускать go test -v и увидеть, что наша функция exist(i db.Inquirer, u *models.User) bool из main.go оттестирована без всякой базы данных. В случае изменения её поведения, тесты перестанут проходить и мы узнаем, что кто-то допустил ошибку.

А вот если бы функция вызывала методы структуры db.Mongo{} напрямую, пришлось бы нам заморачиваться с живым сервером MongoDB. Иногда это нужно делать, но обсуждение таких ситуаций выходит за рамки данной заметки.

Пара слов о фреймворках для тестирования

Кроме фреймворка testify, маленький кусочек которого мы задействовали, существует ещё большой набор софта для тестирования.

Из них рекомендую ознакомиться с GoConvey, довеском к которому идёт шикарный веб-интерфейс. А также с Ginkgo + Gomega.

Удачи в исследованиях!