Pull to refresh

Тестирование веб-сервиса на Go

Reading time 11 min
Views 41K
В этой статье хотелось бы поделиться одним из способов простого и удобного интеграционного тестирования http-сервиса, написанного на Go. Интеграционные тесты бывает непросто создавать так, чтобы обходиться без сложных скриптов, но на помощь нам придет Docker, пакет из стандартной библиотеки httptest и билд-теги. Для примера мы будем использовать MySQL базу данных с миграциями, управляемыми пакетом goose. Финальной целью является получить простое и удобное кроссплатформенное интеграционное тестирование простым запуском команды go test, будь это рабочий ноутбук или Continuous Integration сервер.

image

Основы


Итак, для начала, вспомним, как 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»]

Собираем контейнер командой «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 это так просто, что нет больше оправданий не писать.
Tags:
Hubs:
+12
Comments 33
Comments Comments 33

Articles