Тестирование проектов на Go с dockertest
29 января 2020
Допустим, мы разрабатываем микросервис на языке Go. Мы успешно написали модульные тесты. Но также требуется написать и другие тесты, которые проверяли бы, что посылка определенной серии запросов к сервису приводит к получению ожидаемых ответов. Обычно такие тесты называют интеграционными. Существует более одного решения задачи. Можно поднимать стенды со всеми зависимостями микросервиса (или чем-то, что ими притворяется), что практически сводит задачу к системному тестированию. Или наоборот, можно замокать все зависимости, и свести задачу к модульному тестированию. Но в рамках этой заметки мне хотелось бы рассказать о решении, основанном на использовании Docker и библиотеки dockertest.
Для определенности будем писать тесты к приложению, описанному в посте Работа с PostgreSQL в языке Go при помощи pgx. Напомню, что приложение представляет собой телефонную книгу с REST-интерфейсом. Для работы приложению нужен PostgreSQL, других зависимостей у него нет.
По большому счету, dockertest представляет собой библиотеку для работы с Docker из языка Go:
pool, err := dockertest.NewPool("")
if err != nil {
log.Panicf("dockertest.NewPool failed: %v", err)
}
resource, err := pool.Run(
"postgres", "11",
[]string{
"POSTGRES_DB=restservice",
"POSTGRES_PASSWORD=s3cr3t",
},
)
if err != nil {
log.Panicf("pool.Run failed: %v", err)
}
// ...
Здесь мы запускаем контейнер с PostgreSQL. Перед запуском тестов нужно дождаться, когда постгрес реально поднимется:
// PostgreSQL needs some time to start.
// Port forwarding always works, thus net.Dial can't be used here.
connString := "postgres://postgres:s3cr3t@"+
resource.GetHostPort("5432/tcp")+
"/restservice?sslmode=disable"
attempt := 0
ok := false
for attempt < 20 {
attempt++
conn, err := pgx.Connect(context.Background(), connString)
if err != nil {
log.Infof("pgx.Connect failed: %v, waiting... (attempt %d)",
err, attempt)
time.Sleep(1 * time.Second)
continue
}
_ = conn.Close(context.Background())
ok = true
break
}
if !ok {
_ = pool.Purge(resource)
log.Panicf("Couldn't connect to PostgreSQL")
}
// ...
Теперь мы знаем, что постгрес запущен, а также знаем, на каком порту его искать. Сгенерируем соответствующий файл конфигурации для нашего сервиса:
tmpl, err := template.New("config").Parse(`
loglevel: debug
listen: 0.0.0.0:8080
db:
url: {{.ConnString}}
`)
if err != nil {
_ = pool.Purge(resource)
log.Panicf("template.Parse failed: %v", err)
}
configArgs := struct {
ConnString string
} {
ConnString: connString,
}
var configBuff bytes.Buffer
err = tmpl.Execute(&configBuff, configArgs)
if err != nil {
_ = pool.Purge(resource)
log.Panicf("tmpl.Execute failed: %v", err)
}
confFile, err := ioutil.TempFile("", "config.*.yaml")
if err != nil {
_ = pool.Purge(resource)
log.Panicf("ioutil.TempFile failed: %v", err)
}
log.Infof("confFile.Name = %s", confFile.Name())
_, err = confFile.WriteString(configBuff.String())
if err != nil {
_ = pool.Purge(resource)
log.Panicf("confFile.WriteString failed: %v", err)
}
err = confFile.Close()
if err != nil {
_ = pool.Purge(resource)
log.Panicf("confFile.Close failed: %v", err)
}
// ...
Возвращаем путь к файлу конфигурации, а также функцию, освобождающую ресурсы:
cleanerFunc := func() {
// purge the container
err := pool.Purge(resource)
if err != nil {
log.Panicf("pool.Purge failed: %v", err)
}
err = os.Remove(confFile.Name())
if err != nil {
log.Panicf("os.Remove failed: %v", err)
}
}
return confFile.Name(), cleanerFunc
}
Соответственно, перед запуском тестов, в процедуре TestMain нам необходимо запустить сервис со сгенеренным файлом конфигурации:
log.Infoln("About to start PostgreSQL...")
confPath, stopPostgreSQL := StartPostgreSQL()
log.Infoln("PostgreSQL started!")
// We should change the directory, otherwise the service will
// not find `migrations` directory
err := os.Chdir("../..")
if err != nil {
stopPostgreSQL()
log.Panicf("os.Chdir failed: %v", err)
}
cmd := exec.Command("./bin/rest-service-example", "-c", confPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Start()
if err != nil {
stopPostgreSQL()
log.Panicf("cmd.Start failed: %v", err)
}
log.Infof("cmd.Process.Pid = %d", cmd.Process.Pid)
// ...
Необходимо дождаться готовности API. Дело в том, что первым делом сервис выполняем миграцию схемы базы данных. Если сейчас мы перейдем к тестам, они все завершатся с ошибками, после чего мы остановим Docker-контейнер, что приведет к аварийному завершению сервиса. Такая вот интересная гонка. Код ожидания готовности сервиса:
attempt := 0
ok := false
client := httpClient{}
for attempt < 20 {
attempt++
_, _, err := client.sendJsonReq(
"GET",
"http://localhost:8080/api/v1/records/0",
[]byte{})
if err != nil {
log.Infof(
"client.sendJsonReq failed: %v, waiting... (attempt %d)",
err, attempt)
time.Sleep(1 * time.Second)
continue
}
ok = true
break
}
if !ok {
stopPostgreSQL()
_ = cmd.Process.Kill()
log.Panicf("REST API is unavailable")
}
// ...
Здесь httpClient
— это небольшая обертка над стандартным http.Client
. Он имеет незамысловатый интерфейс, упрощающий посылку REST-запросов и получение ответов.
Наконец, запускаем все тесты, а затем подчищаем за собой:
log.Infoln("REST API ready! Executing m.Run()")
// Run all tests
code := m.Run()
log.Infoln("Cleaning up...")
_ = cmd.Process.Signal(syscall.SIGTERM)
stopPostgreSQL()
os.Exit(code)
}
Пример теста, проверяющего создание новых записей:
Id int64 `json:"id"`
Name string `json:"name"`
Phone string `json:"phone"`
}
client := httpClient{}
record := PhonebookRecord{
Name: "Alice",
Phone: "123",
}
httpBody, err := json.Marshal(record)
require.NoError(t, err)
resp, respBody, err := client.sendJsonReq(
"POST",
"http://localhost:8080/api/v1/records",
httpBody)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
respBodyMap := make(map[string]string, 1)
err = json.Unmarshal(respBody, &respBodyMap)
require.NoError(t, err)
recId, err := strconv.ParseInt(respBodyMap["id"], 10, 31)
require.NoError(t, err)
require.NotEqual(t, 0, recId)
Прочие тесты написаны аналогичным образом. Ознакомиться с полной версией кода можно в репозитории на GitHub.
Итак, чем же хорош dockertest? Тем, что любой разработчик может запустить интеграционные тесты локально, просто сказав go test ./...
и нажав Enter. Нужен только Docker, а все остальное тест делает сам — поднимает чистый PostgreSQL, запускает на нем сервис, тестирует его, останавливает, подчищает за собой. Не нужно никаких стендов, не нужно писать никаких моков. Вы тестируете приложение в таком же состоянии, в каком оно будет работать на проде. Только все зависимости запускаются в контейнерах, а затем удаляются. Очень удобно!
Заинтересованным читателям предлагается попробовать dockertest самим, написав тесты на API GET /api/v1/records
. Напомню, что этот API предлагалось реализовать в конце заметки Работа с PostgreSQL в языке Go при помощи pgx.
Дополнение: В продолжение темы см заметку Непрерывная интеграция с GitHub Actions.
Метки: Go, Тестирование.
Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.