Всем салют! До старта курса «Разработчик Golang» остается меньше недели и мы продолжаем делиться полезным материалом по теме. Поехали!



Go имеет хорошую и надежную встроенную библиотеку для тестирования. Если вы пишете на Go, то вы уже это знаете. В этой статье мы поговорим о нескольких стратегиях, которые могут улучшить ваши навыки тестирования с помощью Go. Из опыта написания нашей внушительной кодовой базы на Go мы узнали, что эти стратегии действительно работают и тем самым помогают экономить время и усилия на работу с кодом.

Используйте тестовые наборы (test suites)

Если вы усвоите для себя только одну полезную вещь из этой статьи, то это обязательно должно быть использование тестовых наборов. Для тех, кто не знаком с этим понятием, тестирование наборами – процесс разработки теста для проверки общего интерфейса, который может быть использован на многих реализациях этого интерфейса. Ниже вы можете посмотреть, как мы передаем несколько различных реализацией Thinger и запускаем их с одними и теми же тестами.

type Thinger interface {
    DoThing(input string) (Result, error)
}

// Suite tests all the functionality that Thingers should implement
func Suite(t *testing.T, impl Thinger) {
    res, _ := impl.DoThing("thing")
    if res != expected {
        t.Fail("unexpected result")
    }
}

// TestOne tests the first implementation of Thinger
func TestOne(t *testing.T) {
    one := one.NewOne()
    Suite(t, one)
}

// TestOne tests another implementation of Thinger
func TestTwo(t *testing.T) {
    two := two.NewTwo()
    Suite(t, two)
}
	

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

Использование такого подхода потенциально поможет сохранить часы, дни и даже время, достаточное для решения проблемы равенства классов P и NP. Также при замене одной базовой системы на другую пропадает необходимость написания (большого количества) дополнительных тестов, также появляется уверенность в том, что такой подход не нарушит работу вашего приложения. Неявно требуется создать интерфейс, который определяет площадь тестируемой области. С помощью инъекции зависимостей, вы можете настроить набор из пакета, передаваемого в реализацию всего пакета.

Полный пример вы можете найти здесь. Несмотря на то, что этот пример надуманный, можно представить, что одна база данных – удаленная, а вторая находится в памяти.

Еще один классный пример этой техники располагается в стандартной библиотеке в пакете golang.org/x/net/nettest. Он обеспечивает средства для проверки того, что net.Conn удовлетворяет интерфейсу.

Избегайте загрязнения интерфейса

Нельзя говорить о тестировании в Go, но не говорить об интерфейсах.

Интерфейсы важны в контексте тестирования, поскольку они являются самым мощным инструментом в нашем арсенале тестирования, поэтому необходимо использовать их правильно.

Пакеты часто экспортируют интерфейсы для разработчиков и это приводит к тому, что:

А) Разработчики создают свой собственную заглушку(mock) для реализации пакета;
Б) Пакет экспортирует свою собственную заглушку (mock).

«Чем больше интерфейс, тем слабее абстракция»
— Роб Пайк, Поговорки о Go

Интерфейсы должны быть тщательно проверены перед экспортом. Часто возникает соблазн экспортировать интерфейсы, чтобы дать пользователям возможность имитировать необходимое им поведение. Вместо этого документируйте, какие интерфейсы удовлетворяют вашим структурам, чтобы не создавать жесткой зависимости между потребительским пакетом (consumer package) и вашим собственным. Отличным примером этого является пакет errors.

Когда у нас есть интерфейс, который мы не хотим экспортировать, можно использовать  internal/ package subtree, чтобы сохранить его внутри пакета. Таким образом, мы можем не бояться, что конечный пользователь может от него зависеть, и, следовательно, может быть гибкими в изменении интерфейса в соответствии с новыми требованиями. Обычно мы создаем интерфейсы с внешними зависимостями, чтобы иметь возможность запускать тесты локально.

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

Не экспортируйте примитивы параллелизма

Go предлагает простые в использовании примитивы параллелизма, которые также иногда могут привести к чрезмерному их использованию в следствии той же простоты. В первую очередь нас беспокоят каналы и пакет sync. Иногда возникает соблазн экспортировать канал из вашего пакета, чтобы им могли пользоваться другие. Кроме того, распространенной ошибкой является встраивание sync.Mutex без установки ее в private. Это, как обычно, не всегда плохо, но создает определенные проблемы при тестировании вашей программы.

Если вы экспортируете каналы, вы дополнительно усложняете жизнь пользователю пакета, чего делать не стоит. Как только канал экспортируется из пакета, вы создаете сложности при тестировании для того, кто этим каналом пользуется. Для проведения успешного тестирования, пользователь должен знать:

  • Когда данные заканчивают пересылаться по каналу.
  • Были ли ошибки при получении данных.
  • Как пакет очищает канал после завершения, если очищает вообще?
  • Как обернуть интерфейс API пакета, так чтобы не вызывать его напрямую?

Обратите внимание на пример с чтением очереди. Вот пример библиотеки, которая читает из очереди и предоставляет пользователю канал для чтения.

type Reader struct {...}
func (r *Reader) ReadChan() <-chan Msg {...}

Теперь пользователь вашей библиотеки хочет реализовать тест для своего потребителя:

func TestConsumer(t testing.T) {
    cons := &Consumer{
        r: libqueue.NewReader(),
    }
    for msg := range cons.r.ReadChan() {
        // Test thing.
    }
}


Затем пользователь может решить, что инъекция зависимостей – это хорошая идея, и написать свои собственные сообщения в канал:

func TestConsumer(t testing.T, q queueIface) {
    cons := &Consumer{
        r: q,
    }
    for msg := range cons.r.ReadChan() {
        // Test thing.
    }
}


Подождите, а что по поводу ошибок?

func TestConsumer(t testing.T, q queueIface) {
    cons := &Consumer{
        r: q,
    }
    for {
        select {
        case msg := <-cons.r.ReadChan():
            // Test thing.
        case err := <-cons.r.ErrChan():
            // What caused this again?
        }
    }
}


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

func TestConsumer(t testing.T, q queueIface) {
    cons := &Consumer{
        r: q,
    }
    msg, err := cons.r.ReadMsg()
    // handle err, test thing
}


Если вы в чем-то сомневаетесь, то просто вспомните, что всегда легко добавить параллелизм в потребительский пакет (consuming package), и трудно или невозможно удалить его после экспорта из библиотеки. И самое главное, не забудьте написать в документации пакета, безопасна ли структура/пакет для одновременного доступа нескольким горутинам.
Иногда все же желательно или необходимо экспортировать канал. Чтобы нивелировать некоторые из проблем, приведенных выше, вы можете предоставить каналы через аксессоры, вместо прямого доступа и оставить их открытыми только для чтения () или только для записи (chan<) при объявлении.

Используйте net/http/httptest

Httptest позволяет выполнять http.Handler код без запуска сервера или привязки к порту. Это ускоряет тестирование и позволяет выполнять тесты параллельно с меньшими затратами.

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

func TestServe(t *testing.T) {
    // The method to use if you want to practice typing
    s := &http.Server{
        Handler: http.HandlerFunc(ServeHTTP),
    }
    // Pick port automatically for parallel tests and to avoid conflicts
    l, err := net.Listen("tcp", ":0")
    if err != nil {
        t.Fatal(err)
    }
    defer l.Close()
    go s.Serve(l)

    res, err := http.Get("http://" + l.Addr().String() + "/?sloths=arecool")
    if err != nil {
        log.Fatal(err)
    }
    greeting, err := ioutil.ReadAll(res.Body)
    res.Body.Close()
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(greeting))
}

func TestServeMemory(t *testing.T) {
    // Less verbose and more flexible way
    req := httptest.NewRequest("GET", "http://example.com/?sloths=arecool", nil)
    w := httptest.NewRecorder()

    ServeHTTP(w, req)
    greeting, err := ioutil.ReadAll(w.Body)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(string(greeting))
}

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

Чтобы посмотреть на этот принцип в действии, ознакомьтесь со статьей Марка Бергера.

Используйте отдельный пакет _test

Большинство тестов в экосистеме создаются в файлах pkg_test.go, но все еще остаются в том же пакете: package pkg. Отдельный тестовый пакет – это пакет, который вы создаете в новом файле, foo_test.go, в директории модуля, который вы хотите протестировать, foo/, с декларацией package foo_test. Отсюда вы можете импортировать github.com/example/foo и другие зависимости. Такая функция позволяет делать многие вещи. Это рекомендуемое решение для циклических зависимостей в тестах, оно предотвращает появление «хрупких тестов» (brittle tests) и позволяет разработчику почувствовать, каково это – использовать свой собственный пакет. Если ваш пакет сложно использовать, то проводить тестирование этим методом тоже будет непросто.

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

Наконец, это помогает избежать циклов импорта в тестах. Большинство пакетов, скорее всего, зависят от других пакетов, которые вы написали помимо тестирующихся, поэтому вы в конечном итоге столкнетесь с ситуацией, когда цикл импорта происходит естественно. Внешний пакет находится над обоими пакетами в иерархии пакетов. Возьмем пример из The Go Programming Language (Глава 11 Раздел 2.4), где net/url реализует парсер URL, который net/http импортирует для использования. Однако net / url необходимо протестировать с помощью реального варианта использования, импортировав net / http. Таким образом получается net/url_test.

Теперь, когда вы используете отдельный тестовый пакет, вам может потребоваться доступ к неэкспортированным сущностям в том пакете, где раньше они были доступны. Некоторые разработчики сталкиваются с этим впервые, когда тестируют что-либо, основывающееся на времени (например, time.Now становится заглушкой с помощью функции). В этом случае, мы можем использовать дополнительный файл, чтобы предоставлять сущности исключительно во время тестирования, так как файлы _test.go исключаются из регулярных билдов.

Что необходимо помнить?

Важно помнить, что ни один из методов описанных выше – не панацея. Самый лучший подход в любом деле – критически анализировать ситуацию и самостоятельно выбирать лучшее решение проблемы.

Хотите узнать больше о тестировании с Go?
Прочитайте эти статьи:

Writing Table Driven Tests in Go Дейва Чени
The Go Programming Language chapter on Testing.
Или посмотрите эти видео:
Hashimoto's Advanced Testing With Go talk from Gophercon 2017
Andrew Gerrand's Testing Techniques talk from 2014

Надеемся данный перевод оказался полезен для вас. Ждем комментарии, а всех желающих узнать подробнее о курсе, приглашаем на день открытых дверей, который пройдет 23 мая.

Комментарии (1)


  1. altSoftLLC
    22.05.2019 10:48

    Спасибо за отличную статью.