← На главную

Тестирование проектов на Go с dockertest

Допустим, мы разрабатываем микросервис на языке Go. Мы успешно написали модульные тесты. Но также требуется написать и другие тесты, которые проверяли бы, что посылка определенной серии запросов к сервису приводит к получению ожидаемых ответов. Обычно такие тесты называют интеграционными. Существует более одного решения задачи. Можно поднимать стенды со всеми зависимостями микросервиса (или чем-то, что ими притворяется), что практически сводит задачу к системному тестированию. Или наоборот, можно замокать все зависимости, и свести задачу к модульному тестированию. Но в рамках этой заметки мне хотелось бы рассказать о решении, основанном на использовании Docker и библиотеки dockertest.

Для определенности будем писать тесты к приложению, описанному в посте Работа с PostgreSQL в языке Go при помощи pgx. Напомню, что приложение представляет собой телефонную книгу с REST-интерфейсом. Для работы приложению нужен PostgreSQL, других зависимостей у него нет.

По большому счету, dockertest представляет собой библиотеку для работы с Docker из языка Go:

func StartPostgreSQL() (confPath string, cleaner func()) { 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 нам необходимо запустить сервис со сгенеренным файлом конфигурации:

func TestMain(m *testing.M) { 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) }

Пример теста, проверяющего создание новых записей:

type PhonebookRecord struct { 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.