Допустим, мы разрабатываем микросервис на языке 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 .