Интерфейсы в Go
Интерфейсы — это абстракции, описывающие поведение различных типов, но не определяющие детали его реализации. Например, вы можете запросить и принять деньги от кассира в банке. Конечно, если деньги есть на вашем счёте. Или обналичить их у банкомата, который в этом случае будет “кассиром”. В обоих случаях интерфейс “кассир” для них будет одинаков. Вы по-прежнему сможете запрашивать и принимать деньги.
Множество объектно-ориентированных языков имеют своё представление интерфейсов. Интерфейсы в Go исключительны и интересны тем, что они реализуются неявно. Это означает, что вы не должны явно указывать, какой интерфейс реализуется вашим классом. Компилятор Go сделает это за вас, ориентируясь на факт реализации методов. Но как это поможет в тестировании?
Внешние библиотеки
Распространённый случай — тестирование кода, использующего внешние библиотеки. Например, данные, полученные через воображаемый веб-API. Представьте, что у нас есть пакет external
, написанный для взаимодействия со внешним веб-сервисом. Из него экспортируется Client
, содержащий методы взаимодействия с API. Представьте метод GetData
, который только возвращает данные, но мог бы делать запросы к веб-серверу и возвращать ошибку при необходимости, если бы это была реальная реализация.
package external
type Client struct{}
func NewClient() Client {
return Client{}
}
func (c Client) GetData() (string, error) {
return "data", nil
}
Ниже код пакета foo
, использующий Client
для того, чтобы получить данные из внешнего API и что-то сделать с ними. В этой реализации есть две возможные ошибки. Первая: запрос к серверу вообще не удался. Вторая: данные оказались не такими, какие мы ожидали, а значит, мы не можем их обработать. Обе ошибки приведут к тому, что функция в Controller
вернёт ошибку.
package foo
import (
"errors"
"interfaces/external"
)
func Controller() error {
externalClient := external.NewClient()
fromExternalAPI, err := externalClient.GetData()
if err != nil {
return err
}
// неожиданные данные
if fromExternalAPI != "data" {
return errors.New("unexpected data")
}
return nil
}
Теперь взглянем на то, как хотели бы тестировать функцию в Controller
. У нас могло бы быть два теста. Первый тестирует обработку успешного запроса, а второй — неудачного. Проблема в том, что мы не можем повлиять на поведение внешнего API, а значит, не можем заставить возвращать из GetData
нужный тесту результат:
package foo_test
import (
"testing"
"interfaces/foo"
)
func TestController_Success(t *testing.T) {
err := foo.Controller()
if err != nil {
t.FailNow()
}
}
func TestController_Failure(t *testing.T) {
// мы хотим, чтобы здесь всегда была ошибка,
// но у нас нет заглушки для external.Client
err := foo.Controller()
if err == nil {
// this test will fail :(
t.FailNow()
}
}
Тест TestController_Success
пройдёт, а TestController_Failure
— нет, потому что мы не управляем методом GetData
. Ниже скриншот с тестовым покрытием:
Внешнее API безжалостно, так что поведение юнит-тестов тоже неопределённо. Нужно найти способ написать заглушку для GetData
. И это именно тот случай, когда интерфейсы становятся крайне полезными.
Интерфейсы для заглушек
Определив интерфейс, удовлетворяющий структуре Client
, мы сможем использовать его в коде вместо реального объекта Client
. Это позволяет скармливать коду заглушку во время тестирования, а настоящий объект — во время работы.
В других языках может потребоваться изменить код внешней библиотеки, чтобы указать, что Client
реализует наш интерфейс. Но компилятор Go благодаря механизму неявного удовлетворения уже знает об этом!
Ниже мы определяем IExternalClient
с методами, которые мы используем в foo
и изменяем эту функцию так, чтобы она принимала тип интерфейса в качестве параметра. Остальные функции работают как и раньше, но теперь вызывают GetData
нашего интерфейса, а не реализации Client
из внешней библиотеки.
package foo
import (
"errors"
)
type IExternalClient interface {
GetData() (string, error)
}
func Controller(externalClient IExternalClient) error {
fromExternalAPI, err := externalClient.GetData()
if err != nil {
return err
}
// неожиданные данные
if fromExternalAPI != "data" {
return errors.New("unexpected data")
}
return nil
}
Теперь посмотрим, насколько проще стало тестировать foo
. Реализуем наш интерфейс через реализацию GetData
в MockClient
. В этой реализации мы можем возвращать, что захотим. Затем скармливаем нашу реализацию контроллеру в тестах. Используем MockClient
для различных значений данных, а FailureClient
для возврата ошибок.
package foo_test
import (
"errors"
"testing"
"interfaces/foo"
)
type MockClient struct {
GetDataReturn string
}
func (mc MockClient) GetData() (string, error) {
return mc.GetDataReturn, nil
}
func TestController_Success(t *testing.T) {
err := foo.Controller(MockClient{"data"})
if err != nil {
t.FailNow()
}
}
type FailingClient struct{}
func (fc FailingClient) GetData() (string, error) {
return "", errors.New("oh no")
}
func TestController_Failure(t *testing.T) {
// тестируем неудачный запрос
err := foo.Controller(FailingClient{})
if err == nil {
t.FailNow()
}
// тестируем неожиданные данные из GetData()
err = foo.Controller(MockClient{"not data"})
if err == nil {
t.FailNow()
}
}
Теперь мы покрываем все ветви тестируемой функции:
Итоги
Как вы видите, использование интерфейсов способствует повышению гибкости и тестируемости кода, а также может быть простым решением для создания заглушек внешних библиотек. Стандартная библиотека Go широко использует интерфейсы, и вы найдёте отличные примеры в io и net/http.
Читайте также:
А также Вы можете пройти тест по Golang: Насколько хорошо вы разбираетесь в Go?
Перевод статьи: Gabe Szczepanek: Using Go Interfaces for Testable Code