В этой статье хотелось бы поделиться одним из способов простого и удобного интеграционного тестирования http-сервиса, написанного на Go. Интеграционные тесты бывает непросто создавать так, чтобы обходиться без сложных скриптов, но на помощь нам придет Docker, пакет из стандартной библиотеки httptest и билд-теги. Для примера мы будем использовать MySQL базу данных с миграциями, управляемыми пакетом goose. Финальной целью является получить простое и удобное кроссплатформенное интеграционное тестирование простым запуском команды go test, будь это рабочий ноутбук или Continuous Integration сервер.
Итак, для начала, вспомним, как Go относится к тестированию. Поскольку авторы Go делают акцент на том, что Go стимулирует хорошие практики — а тестирование — это одна, из пожалуй, самых хороших практик для программистов вообще, то тестирование в Go — неотъемлемая часть инструментария и идёт из коробки. Кроме того, чтобы им воспользоваться, нужно запомнить самый минимум.
Вот этот минимум:
Скажем, если вы написали функцию для подсчета площади круга, тест на Go будет выглядеть вот так:
Конечно, тем, кто привык к умным assert-ам и оберткам для тестирования, упрощающим проверки, такой код может показаться слишком многословным. Но «умные сравнение» разных типов это не такая уж тривиальная задача, и отдана полностью на откуп сторонним пакетам, о которых я расскажу чуть ниже. Такой же минималистичный подход позволяет с минимальным порогом входа начать писать тесты по поводу и без повода. Многие даже утверждают, что полюбили TDD (Testing Driven Development) именно в Go. Как бы, уже нет оправданий, чтобы не писать тесты — это стало слишком просто.
При этом, чтобы вы понимали, под капотом там происходят достаточно сложные вещи. go test берет ваш код, помещает его во временную директорию, модифицирует так, чтобы получилась самодостаточная программа, запускающая тесты, компилирует, запускает и выводит результаты с подсчетом времени. Всё это происходит за доли секунды, и такой подход стал возможным и удобным только благодаря простой грамматике языка и сверхбыстрой компиляции.
go test умеет из коробки еще много чего, включая запись покрытия (coverage), профайлинга памяти и процессора, такие же простые бенчмарки (func BenchmarkXxx), параллельное исполнение, рейс-детектор и много чего другого. Обо всем можно узнать выполнив команды go help test и go help testflag.
Разумеется для больших программ, есть смысл использовать более мощные способы тестирования. Для Go существует множество фреймворков, которые легко и просто подключаются к вашим тестам, и совместимы с командой go test. Мне больше всего нравится GoConvey, добавляющий DSL-подобный синтаксис для BDD-тестов. Вышеприведенный пример будет выглядеть с GoConvey вот так:
GoConvey умеет делать массу assertions, в том числе для глубокого сравнения структур и сложных типов, умеет работать со временем и так далее. Если начнёте его использовать, убедитесь, что прочитали про порядок выполнения вложенных Convey-функций — это важная фишка.
Как бонус, у Goconvey есть навороченный веб UI, который умеет мониторить изменения в коде и перезапускать тесты, присылать Desktop-уведомления и, вообще, выглядит как панель управления запуском шаттла. Очень круто на самом деле, удобно вынести на второй монитор. Как многие отзываются, GoConvey заставит вас полюбить тестирование еще больше :)
Есть еще популярные фреймворки вроде Ginkgo, testify, gocheck, Agouti, GoMega и другие. Вот тут есть неплохое сравнение.
Как видите, подход Go по принципу «самое необходимое — из коробки, все остальное — на откуп коммьюнити» как нельзя лучше себя оправдывает в тестировании.
Как уже отмечалось выше, интеграционные тесты, которые подразумевают тестирование всей системы в целом, а не отдельных частей кода, могут быть непростой задачей. Зачастую они требуют сложных скриптов, которые далеко не всегда кроссплатформенны, занимают много времени и так далее. Но и тут с Go подобные задачи становятся гораздо проще.
Я рассмотрю следующий пример:
Это может быть как типичный REST-бекенд, так и какой-нибудь сервис, следующий принципам 12-factor app, зависимостей и сервисов может быть намного больше. Сейчас цель — показать подход.
Для внешних сервисов (в данном случае MySQL-базы) я буду использовать, как это ни банально, Docker. Пока весь мир рассказывает друг-другу, что Docker — это не панацея и не нужно его использовать там где не нужно (и истину говорит же), применение контейнеров для быстрого поднятия зависимостей в интеграционных тестах — это самое оно.
Сначала займемся не-Go частью, а именно написанием Dockerfile и разберемся, как работать с миграциями.
goose — это бинарный файл, который при запуске ищет директорию db/, а в ней:
В yaml-файле описаны различные конфигурации баз данных, с которыми goose сможет работать, а в папке migrations/ — SQL-код, созданный с помощью goose create. На страничке проекта это расписано более детально, я не буду подробно останавливаться.
Наша задача — создать контейнер с MySQL, при билде контейнера стартануть его, поднять миграции до последней версии с помощью команды goose up, и подготовить контейнер для дальнейшей работы.
Dockerfile может выглядеть вот так:
Собираем контейнер командой «docker build -t mydb_test .» Теперь, при запуске docker run -p 3306:3306 mydb_test — мы получим свежезапущенную базу, с последними миграциями и в свежем состоянии.
Для начала, поставим билд-тег, чтобы этот тест не запускался каждый раз, а только когда мы принудительно попросим запустить «интеграционные тесты».
Начнем наш service_test.go:
Теперь, обычный вызов go test не будет трогать именно этот тест, а go test -tag=integration — будет. К слову, в go test есть режим -short — можно использовать его, только он, наоборот, по умолчанию выключен:
Первым делом, мы захотим поднимать наш Docker-контейнер при старте теста. Для Docker есть удобные Go-библиотеки, я воспользуюсь клиентом от fsouza, который пользую уже больше 1.5 лет. Чтобы запустить контейнер, нужно выполнить три шага:
createOptions() возвращает структуру с параметрами для операции создания контейнера. Именно там мы и указываем имя нашего контейнера, который будет использоваться для тестирования — mydb_test.
Всё что нам остается, это написать код, который будет ждать поднятия базы, и возвращать IP адрес или сразу отформатированный DSN для использования с mysql-драйвером Go.
Код не сильно интересный, поэтому также спрячу под спойлер:
Всего этого достаточно, чтобы двигаться дальше, но есть один момент — я хочу, чтобы этот код работал и на MacOS X, и на Windows, а это значит, что нужно уметь отличать Linux и не-Linux среду, и уметь поддерживать docker-machine или boot2docker (если кто ещё не переехал на docker-machine).
К счастью, это тоже тривиальная задача — необходимо всего лишь несколько функций. Для того, чтобы узнать IP адрес виртуальной машины, в которой запущен Docker. можно использовать вот такой код:
Также придется передавать параметры проброса портов в CreateContainerOptions.
В итоге, будет удобней вынести весь этот код в отдельный пакет, в отдельную поддиректорию. Чтобы не делать этот пакет доступным наружу, я положу его в подкаталог internal — это гарантирует, что только мой пакет сможет его (пере)использовать.
Код этого пакета целиком: pastebin.com/faUUN0M1
Теперь его можно смело импортировать в наш проект, в код для тестирования и одной функцией получать готовый DSN для подключения.
И передавать db в дальнейший код, который будет работать с базой данных. Обратите внимание, что функцию deferFn() мы вызываем, как положено, но даже понятия не имеем, что она делает — это на совести пакета dockertest, который знает, как почистить после себя и удалить контейнер.
Следующим шагом у нас стоит проверка http-запросов — возвращают ли они нужные коды ошибок, возвращают ли ожидаемые данные, передают ли необходимые заголовки и тому подобное. Конечно, можно запустить реальный сервис, и запускать «снаружи» curl-запросы, но это неуклюже, неудобно и некрасиво. В Go есть великолепный способ тестировать http-хендлеры — это пакет net/http/httptest
httptest был, пожалуй, одним из первых моментов, которые произвели на меня вау-эффект в Go. Сама архитектура построения http-приложений в Go и без того может вызвать подобный эффект, но тут было совсем удачно. Как устроен пакет net/http я в этой статье не буду рассказывать, это материал для отдельной статьи, но вкратце — есть стандартный интерфейс http.Handler, которому удовлетворяет любой тип, у которого есть метод ServeHttp(http.ResponseWriter, *http.Request):
Веб-фреймворк gin, как и подобает всем цивилизованным http-фреймворкам в Go, реализует эти интерфейсы, поэтому для его тестирования мы можем легко сконструировать произвольные объекты, удовлетворяющие http.ResponseWriter (это тоже интерфейс), передать нужный Request и смотреть на ответ! При этом не понадобится открывать никаких внешних портов, всё будет происходить в адресном пространстве программы-теста. И это очень круто.
Вот как это выглядит (я сразу буду использовать вышеописанный GoConvey):
Теперь можно добавить еще вызовы, и проверять состояние — скажем, добавить пользователя, и проверить снова список:
И так далее, для любых других stateful- или не очень запросов.
Как видите, Go не только упрощает написание unit-тестов, создавая стимул их писать на каждом шагу, и превращая Go программистов в приверженцев BDD и TDD методологий, но и открывает новые возможности для более сложных integration- и acceptance-тестов.
Пример, приведенный в статье, служит лишь демонстрацией, хоть и основан на реальном коде, который таким образом тестируется уже более 1.5 года в продакшене. На моём Macbook, в котором docker бежит внутри виртуальной машины (через docker-machine), весь тест (скомпилировать код для теста, поднять контейнер, прогнать ~35 http-запросов) — занимает три секунды. Как по мне, вполне неплохо для такого уровня теста, учитывая практически полную изоляцию от системы и кроссплатформенность. На Linux это, понятное дело, будет ещё быстрее.
Безусловно, разные сервисы требуют разных сценариев тестирования, но данный пример и не пытается ответить на все случаи (ремарка специально для главного хабратролля lair), а является демонстрацией того, как можно использовать потенциал Go для ускорения цикла интеграционного тестирования.
Так что, пишите тесты! С Go это так просто, что нет больше оправданий не писать.
Основы
Итак, для начала, вспомним, как Go относится к тестированию. Поскольку авторы Go делают акцент на том, что Go стимулирует хорошие практики — а тестирование — это одна, из пожалуй, самых хороших практик для программистов вообще, то тестирование в Go — неотъемлемая часть инструментария и идёт из коробки. Кроме того, чтобы им воспользоваться, нужно запомнить самый минимум.
Вот этот минимум:
- команда для запуска тестов — go test
- тесты находятся в файлах *_test.go. Код в этих файлах не используется при сборке, только при тестах.
- функция, которая называется TestXxx(t *testing.T) будет запускаться во время тестирования
- в пакете стандартной библиотеки testing есть все необходимое для тестов
Скажем, если вы написали функцию для подсчета площади круга, тест на Go будет выглядеть вот так:
package main
import (
"math"
"testing"
)
func TestCircle(t *testing.T) {
area := CircleArea(10)
want := 100 * math.Pi
if area != want {
t.Fatalf("Want %v, but got %v", want, area)
}
}
Конечно, тем, кто привык к умным assert-ам и оберткам для тестирования, упрощающим проверки, такой код может показаться слишком многословным. Но «умные сравнение» разных типов это не такая уж тривиальная задача, и отдана полностью на откуп сторонним пакетам, о которых я расскажу чуть ниже. Такой же минималистичный подход позволяет с минимальным порогом входа начать писать тесты по поводу и без повода. Многие даже утверждают, что полюбили TDD (Testing Driven Development) именно в Go. Как бы, уже нет оправданий, чтобы не писать тесты — это стало слишком просто.
При этом, чтобы вы понимали, под капотом там происходят достаточно сложные вещи. go test берет ваш код, помещает его во временную директорию, модифицирует так, чтобы получилась самодостаточная программа, запускающая тесты, компилирует, запускает и выводит результаты с подсчетом времени. Всё это происходит за доли секунды, и такой подход стал возможным и удобным только благодаря простой грамматике языка и сверхбыстрой компиляции.
go test умеет из коробки еще много чего, включая запись покрытия (coverage), профайлинга памяти и процессора, такие же простые бенчмарки (func BenchmarkXxx), параллельное исполнение, рейс-детектор и много чего другого. Обо всем можно узнать выполнив команды go help test и go help testflag.
Тестинг-фреймворки
Разумеется для больших программ, есть смысл использовать более мощные способы тестирования. Для Go существует множество фреймворков, которые легко и просто подключаются к вашим тестам, и совместимы с командой go test. Мне больше всего нравится GoConvey, добавляющий DSL-подобный синтаксис для BDD-тестов. Вышеприведенный пример будет выглядеть с GoConvey вот так:
package main
import (
"math"
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestCircle(t *testing.T) {
Convey("Circle should work correctly", t, func() {
Convey("Area should be calculated correctly", func() {
area := CircleArea(10)
So(area, ShouldEqual, 100 * math.Pi)
})
})
}
GoConvey умеет делать массу assertions, в том числе для глубокого сравнения структур и сложных типов, умеет работать со временем и так далее. Если начнёте его использовать, убедитесь, что прочитали про порядок выполнения вложенных Convey-функций — это важная фишка.
Как бонус, у Goconvey есть навороченный веб UI, который умеет мониторить изменения в коде и перезапускать тесты, присылать Desktop-уведомления и, вообще, выглядит как панель управления запуском шаттла. Очень круто на самом деле, удобно вынести на второй монитор. Как многие отзываются, GoConvey заставит вас полюбить тестирование еще больше :)
Есть еще популярные фреймворки вроде Ginkgo, testify, gocheck, Agouti, GoMega и другие. Вот тут есть неплохое сравнение.
Как видите, подход Go по принципу «самое необходимое — из коробки, все остальное — на откуп коммьюнити» как нельзя лучше себя оправдывает в тестировании.
Интеграционные тесты
Как уже отмечалось выше, интеграционные тесты, которые подразумевают тестирование всей системы в целом, а не отдельных частей кода, могут быть непростой задачей. Зачастую они требуют сложных скриптов, которые далеко не всегда кроссплатформенны, занимают много времени и так далее. Но и тут с Go подобные задачи становятся гораздо проще.
Я рассмотрю следующий пример:
- веб-бекенд, с использованием фреймворка gin
- данные для сервиса — в MySQL базе данных
- схема базы строится с использованием утилиты для миграций — goose
Это может быть как типичный REST-бекенд, так и какой-нибудь сервис, следующий принципам 12-factor app, зависимостей и сервисов может быть намного больше. Сейчас цель — показать подход.
Для внешних сервисов (в данном случае MySQL-базы) я буду использовать, как это ни банально, Docker. Пока весь мир рассказывает друг-другу, что Docker — это не панацея и не нужно его использовать там где не нужно (и истину говорит же), применение контейнеров для быстрого поднятия зависимостей в интеграционных тестах — это самое оно.
Миграции и Dockerfile
Сначала займемся не-Go частью, а именно написанием Dockerfile и разберемся, как работать с миграциями.
goose — это бинарный файл, который при запуске ищет директорию db/, а в ней:
- файл dbconf.yml
- папку migrations/
В yaml-файле описаны различные конфигурации баз данных, с которыми goose сможет работать, а в папке migrations/ — SQL-код, созданный с помощью goose create. На страничке проекта это расписано более детально, я не буду подробно останавливаться.
Наша задача — создать контейнер с MySQL, при билде контейнера стартануть его, поднять миграции до последней версии с помощью команды goose up, и подготовить контейнер для дальнейшей работы.
Dockerfile может выглядеть вот так:
Dockerfile
FROM debian
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update
RUN apt-get install -y mysql-server
RUN sed -i -e«s/^bind-address\s*=\s*127.0.0.1/bind-address = 0.0.0.0/» /etc/mysql/my.cnf
RUN apt-get install -y golang git ca-certificates gcc
ENV GOPATH /root
RUN go get bitbucket.org/liamstask/goose/cmd/goose
ADD. /db
RUN \
service mysql start && \
sleep 10 && \
while true; do mysql -e «SELECT 1» &> /dev/null; [ $? -eq 0 ] && break; echo -n "."; sleep 1; done && \
mysql -e «GRANT ALL ON *.* to 'root'@'%'; FLUSH PRIVILEGES;» && \
mysql -e «CREATE DATABASE mydb DEFAULT COLLATE utf8_general_ci;» && \
/root/bin/goose -env=default up && \
service mysql stop
EXPOSE 3306
CMD [«mysqld_safe»]
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get update
RUN apt-get install -y mysql-server
RUN sed -i -e«s/^bind-address\s*=\s*127.0.0.1/bind-address = 0.0.0.0/» /etc/mysql/my.cnf
RUN apt-get install -y golang git ca-certificates gcc
ENV GOPATH /root
RUN go get bitbucket.org/liamstask/goose/cmd/goose
ADD. /db
RUN \
service mysql start && \
sleep 10 && \
while true; do mysql -e «SELECT 1» &> /dev/null; [ $? -eq 0 ] && break; echo -n "."; sleep 1; done && \
mysql -e «GRANT ALL ON *.* to 'root'@'%'; FLUSH PRIVILEGES;» && \
mysql -e «CREATE DATABASE mydb DEFAULT COLLATE utf8_general_ci;» && \
/root/bin/goose -env=default up && \
service mysql stop
EXPOSE 3306
CMD [«mysqld_safe»]
Собираем контейнер командой «docker build -t mydb_test .» Теперь, при запуске docker run -p 3306:3306 mydb_test — мы получим свежезапущенную базу, с последними миграциями и в свежем состоянии.
Пишем Go тесты
Для начала, поставим билд-тег, чтобы этот тест не запускался каждый раз, а только когда мы принудительно попросим запустить «интеграционные тесты».
Начнем наш service_test.go:
// +build integration
package main
import (
"testing"
)
Теперь, обычный вызов go test не будет трогать именно этот тест, а go test -tag=integration — будет. К слову, в go test есть режим -short — можно использовать его, только он, наоборот, по умолчанию выключен:
if testing.Short() {
t.Skip("skipping test in short mode.")
}
Поднимаем Docker-контейнер из тестов
Первым делом, мы захотим поднимать наш Docker-контейнер при старте теста. Для Docker есть удобные Go-библиотеки, я воспользуюсь клиентом от fsouza, который пользую уже больше 1.5 лет. Чтобы запустить контейнер, нужно выполнить три шага:
client, err := docker.NewClientFromEnv()
if err != nil {
t.Fatalf("Cannot connect to Docker daemon: %s", err)
}
c, err := client.CreateContainer(createOptions())
if err != nil {
t.Fatalf("Cannot create Docker container: %s", err)
}
defer func() {
if err := client.RemoveContainer(docker.RemoveContainerOptions{
ID: c.ID,
Force: true,
}); err != nil {
t.Fatalf("cannot remove container: %s", err)
}
}()
err = client.StartContainer(c.ID, &docker.HostConfig{})
if err != nil {
t.Fatalf("Cannot start Docker container: %s", err)
}
createOptions() возвращает структуру с параметрами для операции создания контейнера. Именно там мы и указываем имя нашего контейнера, который будет использоваться для тестирования — mydb_test.
код этих функций
func сreateOptions() docker.CreateContainerOptions {
ports := make(map[docker.Port]struct{})
ports["3306"] = struct{}{}
opts := docker.CreateContainerOptions{
Config: &docker.Config{
Image: "mydb_test",
ExposedPorts: ports,
},
}
return opts
}
Всё что нам остается, это написать код, который будет ждать поднятия базы, и возвращать IP адрес или сразу отформатированный DSN для использования с mysql-драйвером Go.
// wait for container to wake up
if err := waitStarted(client, c.ID, 5*time.Second); err != nil {
t.Fatalf("Couldn't reach MySQL server for testing, aborting.")
}
c, err = client.InspectContainer(c.ID)
if err != nil {
t.Fatalf("Couldn't inspect container: %s", err)
}
// determine IP address for MySQL
ip = strings.TrimSpace(c.NetworkSettings.IPAddress)
// wait MySQL to wake up
if err := waitReachable(ip+":3306", 5*time.Second); err != nil {
t.Fatalf("Couldn't reach MySQL server for testing, aborting.")
}
// pass IP to DB connect code
Код не сильно интересный, поэтому также спрячу под спойлер:
wait code
// waitReachable waits for hostport to became reachable for the maxWait time.
func waitReachable(hostport string, maxWait time.Duration) error {
done := time.Now().Add(maxWait)
for time.Now().Before(done) {
c, err := net.Dial("tcp", hostport)
if err == nil {
c.Close()
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("cannot connect %v for %v", hostport, maxWait)
}
// waitStarted waits for container to start for the maxWait time.
func waitStarted(client *docker.Client, id string, maxWait time.Duration) error {
done := time.Now().Add(maxWait)
for time.Now().Before(done) {
c, err := client.InspectContainer(id)
if err != nil {
break
}
if c.State.Running {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("cannot start container %s for %v", id, maxWait)
}
Всего этого достаточно, чтобы двигаться дальше, но есть один момент — я хочу, чтобы этот код работал и на MacOS X, и на Windows, а это значит, что нужно уметь отличать Linux и не-Linux среду, и уметь поддерживать docker-machine или boot2docker (если кто ещё не переехал на docker-machine).
К счастью, это тоже тривиальная задача — необходимо всего лишь несколько функций. Для того, чтобы узнать IP адрес виртуальной машины, в которой запущен Docker. можно использовать вот такой код:
// DockerMachineIP returns IP of docker-machine or boot2docker VM instance.
//
// If docker-machine or boot2socker is running and has IP, it will be used to
// connect to dockerized services (MySQL, etc).
//
// Basically, it adds support for MacOS X and Windows.
func DockerMachineIP() string {
// Docker-machine is a modern solution for docker in MacOS X.
// Try to detect it, with fallback to boot2docker
var dockerMachine bool
machine := os.Getenv("DOCKER_MACHINE_NAME")
if machine != "" {
dockerMachine = true
}
var buf bytes.Buffer
var cmd *exec.Cmd
if dockerMachine {
cmd = exec.Command("docker-machine", "ip", machine)
} else {
cmd = exec.Command("boot2docker", "ip")
}
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
// ignore error, as it's perfectly OK on Linux
return ""
}
return buf.String()
}
Также придется передавать параметры проброса портов в CreateContainerOptions.
В итоге, будет удобней вынести весь этот код в отдельный пакет, в отдельную поддиректорию. Чтобы не делать этот пакет доступным наружу, я положу его в подкаталог internal — это гарантирует, что только мой пакет сможет его (пере)использовать.
Код этого пакета целиком: pastebin.com/faUUN0M1
Теперь его можно смело импортировать в наш проект, в код для тестирования и одной функцией получать готовый DSN для подключения.
// start db in docker container
dsn, deferFn, err := dockertest.StartMysql()
if err != nil {
t.Fatalf("cannot start mysql in container for testing: %s", err)
}
defer deferFn()
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("Couldn't connect to test database: %s", err)
}
defer db.Close()
И передавать db в дальнейший код, который будет работать с базой данных. Обратите внимание, что функцию deferFn() мы вызываем, как положено, но даже понятия не имеем, что она делает — это на совести пакета dockertest, который знает, как почистить после себя и удалить контейнер.
Тестируем http-запросы
Следующим шагом у нас стоит проверка http-запросов — возвращают ли они нужные коды ошибок, возвращают ли ожидаемые данные, передают ли необходимые заголовки и тому подобное. Конечно, можно запустить реальный сервис, и запускать «снаружи» curl-запросы, но это неуклюже, неудобно и некрасиво. В Go есть великолепный способ тестировать http-хендлеры — это пакет net/http/httptest
httptest был, пожалуй, одним из первых моментов, которые произвели на меня вау-эффект в Go. Сама архитектура построения http-приложений в Go и без того может вызвать подобный эффект, но тут было совсем удачно. Как устроен пакет net/http я в этой статье не буду рассказывать, это материал для отдельной статьи, но вкратце — есть стандартный интерфейс http.Handler, которому удовлетворяет любой тип, у которого есть метод ServeHttp(http.ResponseWriter, *http.Request):
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Веб-фреймворк gin, как и подобает всем цивилизованным http-фреймворкам в Go, реализует эти интерфейсы, поэтому для его тестирования мы можем легко сконструировать произвольные объекты, удовлетворяющие http.ResponseWriter (это тоже интерфейс), передать нужный Request и смотреть на ответ! При этом не понадобится открывать никаких внешних портов, всё будет происходить в адресном пространстве программы-теста. И это очень круто.
Вот как это выглядит (я сразу буду использовать вышеописанный GoConvey):
func NewServer(db *sql.DB) *gin.Engine {
r := gin.Default()
r.Use(cors.Middleware(cors.Options{}))
// more middlewares ...
// Health check
r.GET("/ping", ping)
// CRUD resources
usersRes := &UsersResource{db: db}
// Define routes
api := r.Group("/api")
{
v1 := api.Group("/v1")
{
rest.CRUD(v1, "/users", usersRes)
}
}
return r
}
...
r := NewServer(db)
Convey("Users endpoints should respond correctly", t, func() {
Convey("User should return empty list", func() {
// it's safe to ignore error here, because we're manually entering URL
req, _ := http.NewRequest("GET", "http://localhost/api/v1/users", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
So(w.Code, ShouldEqual, http.StatusOK)
body := strings.TrimSpace(w.Body.String())
So(body, ShouldEqual, "[]")
})
})
Теперь можно добавить еще вызовы, и проверять состояние — скажем, добавить пользователя, и проверить снова список:
Convey("Create should return ID of newly created user", func() {
user := &User{Name: "Test user"}
data, err := json.Marshal(user)
So(err, ShouldBeNil)
buf := bytes.NewBuffer(data)
req, err := http.NewRequest("POST", "http://localhost/api/v1/users", buf)
So(err, ShouldBeNil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
So(w.Code, ShouldEqual, http.StatusOK)
body := strings.TrimSpace(w.Body.String())
So(body, ShouldEqual, "1")
})
Convey("List should return one user with name 'Test user'", func() {
req, _ := http.NewRequest("GET", "http://localhost/api/v1/users", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
So(w.Code, ShouldEqual, http.StatusOK)
body := w.Body.Bytes()
var users []*User
err := json.Unmarshal(body, &users)
So(err, ShouldBeNil)
user := &User{
ID: 1,
Name: "Test user",
}
So(len(users), ShouldEqual, 1)
So(users[0], ShouldResemble, user)
})
И так далее, для любых других stateful- или не очень запросов.
Выводы
Как видите, Go не только упрощает написание unit-тестов, создавая стимул их писать на каждом шагу, и превращая Go программистов в приверженцев BDD и TDD методологий, но и открывает новые возможности для более сложных integration- и acceptance-тестов.
Пример, приведенный в статье, служит лишь демонстрацией, хоть и основан на реальном коде, который таким образом тестируется уже более 1.5 года в продакшене. На моём Macbook, в котором docker бежит внутри виртуальной машины (через docker-machine), весь тест (скомпилировать код для теста, поднять контейнер, прогнать ~35 http-запросов) — занимает три секунды. Как по мне, вполне неплохо для такого уровня теста, учитывая практически полную изоляцию от системы и кроссплатформенность. На Linux это, понятное дело, будет ещё быстрее.
Безусловно, разные сервисы требуют разных сценариев тестирования, но данный пример и не пытается ответить на все случаи (ремарка специально для главного хабратролля lair), а является демонстрацией того, как можно использовать потенциал Go для ускорения цикла интеграционного тестирования.
Так что, пишите тесты! С Go это так просто, что нет больше оправданий не писать.