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

29 января 2020

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

А используете ли вы dockertest и если да, то что вы с его помощью запускаете?

Дополнение: В продолжение темы см заметку Непрерывная интеграция с GitHub Actions.

Метки: , .