Тестирование Golang с использованием сервисов докеров через dockertest
За время своего обучения я наткнулся на несколько замечательных библиотек и утилит, одна из моих любимых для интеграционного тестирования — dockertest.
Всякий раз, когда я использую службу, поддерживаемую postgres, mongo, mysql или другими службами, которые не являются частью моей кодовой базы, я обычно создаю файл docker-compose, чтобы изолировать мои среды разработки. Затем, когда я работаю над конкретным проектом, все, что мне нужно сделать, это docker-compose up -d
, чтобы начать работу, и docker-compose down
, когда я закончу рабочий день.
Например, если мне просто нужна база данных Postgres, я обычно имею что-то вроде.
services:
postgres:
image: postgres:15
ports:
- 5432:5432
volumes:
- pg-db:/var/lib/postgresql/data
environment:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: example
volumes:
pg-data:
Это отлично подходит для разработки, но при запуске интеграционных тестов, если мне нужна чистая база данных, это означает, что мне нужно выполнить некоторые дополнительные действия. Я могу либо:
- Вручную удалить и создать базу данных
- Удаление и создание базы данных в рамках интеграционного теста
- Множество других вариантов
Вместо того, чтобы создавать некоторую автоматизацию для удаления и создания баз данных или таблиц, dockertest позволяет нам создать полностью чистый изолированный экземпляр службы, поэтому мы знаем, что каждый раз начинаем с нуля. Это следует из идеологии cattle not pets от Devops.
Здесь мы просто обсуждаем postgres, но для некоторых других служб докеров может потребоваться МНОГО ручных действий, чтобы привести их в хорошее состояние для тестовых запусков.
Пример
Я создал здесь пример проекта, чтобы показать все в действии, но буду прорабатывать каждый сделанный шаг.
Проект, который я создал, очень прост, и основная работа заключается в следующем коде:
package database
import (
"fmt"
"log"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type (
Person struct {
gorm.Model
Name string
Age uint
}
)
var db *gorm.DB
func Connect() {
log.Println("Setting up the database")
pgUrl := fmt.Sprintf("postgresql://postgres@127.0.0.1:%s/example", os.Getenv("POSTGRES_PORT"))
log.Printf("Connecting to %s\n", pgUrl)
var err error
db, err = gorm.Open(postgres.Open(pgUrl), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// Migrate the schema
db.AutoMigrate(&Person{})
}
func CreatePerson() {
log.Println("Creating a new person in the database")
person := Person{Name: "Danny", Age: 42}
db.Create(&person)
log.Println("Trying to write a new person to the database")
}
func CountPeople() int {
var count int64
db.Model(&Person{}).Count(&count)
return int(count)
}
Итак, у нас есть метод подключения, метод создания новой записи о человеке и метод подсчета записей. Все они вызываются из основной точки входа.
func main() {
database.Connect()
database.CreatePerson()
count := database.CountPeople()
log.Printf("Database has %d people", count)
}
Если я запускаю это из командной строки (после запуска Docker Compose), результат увеличивает счетчик при каждом запуске (как и должно быть).
go-dockertest-example λ git main → go run main.go
2023/09/03 13:41:06 Setting up the database
2023/09/03 13:41:06 Connecting to postgresql://postgres@127.0.0.1:5432/example
2023/09/03 13:41:06 Creating a new person in the database
2023/09/03 13:41:06 Trying to write a new person to the database
2023/09/03 13:41:06 Database has 3 people
go-dockertest-example λ git main → go run main.go
2023/09/03 13:41:07 Setting up the database
2023/09/03 13:41:07 Connecting to postgresql://postgres@127.0.0.1:5432/example
2023/09/03 13:41:08 Creating a new person in the database
2023/09/03 13:41:08 Trying to write a new person to the database
2023/09/03 13:41:08 Database has 4 people
Тест
Теперь посмотрим на тест. Сначала нужно настроить dockertest
:
func TestMain(m *testing.M) {
// Start a new docker pool
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
// Uses pool to try to connect to Docker
err = pool.Client.Ping()
if err != nil {
log.Fatalf("Could not connect to Docker: %s", err)
}
pg, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Tag: "15",
Env: []string{
"POSTGRES_DB=example",
"POSTGRES_HOST_AUTH_METHOD=trust",
"listen_addresses = '*'",
},
}, func(config *docker.HostConfig) {
// set AutoRemove to true so that stopped container goes away by itself
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
})
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}
pg.Expire(10)
// Set this so our app can use it
postgresPort := pg.GetPort("5432/tcp")
os.Setenv("POSTGRES_PORT", postgresPort)
// Wait for the Postgres to be ready
if err := pool.Retry(func() error {
_, connErr := gorm.Open(postgres.Open(fmt.Sprintf("postgresql://postgres@localhost:%s/example", postgresPort)), &gorm.Config{})
if connErr != nil {
return connErr
}
return nil
}); err != nil {
panic("Could not connect to postgres: " + err.Error())
}
code := m.Run()
os.Exit(code)
}
Итак, сначала мы создаем новый пул докеров, убеждаемся, что пул отвечает на пинг, а затем запускаем экземпляр postgres. Когда вы запускаете новый экземпляр, он захватывает случайный порт, поэтому вам нужен способ передать его в службу, поэтому у нас есть переменная окружения POSTGRES_PORT
. В самом конце настройки модуля мы устанавливаем эту среду, чтобы тест использовал ее.
// Set this so our app can use it
postgresPort := pg.GetPort("5432/tcp")
os.Setenv("POSTGRES_PORT", postgresPort)
Существует раздел ожидания готовности postgres, вы можете использовать его по-разному, но в основном вы используете какое-то условие для проверки готовности экземпляра. Это может быть что угодно: от проверки адресности порта до вызова проверки работоспособности http.
// Wait for the Postgres to be ready
if err := pool.Retry(func() error {
_, connErr := gorm.Open(postgres.Open(fmt.Sprintf("postgresql://postgres@localhost:%s/example", postgresPort)), &gorm.Config{})
if connErr != nil {
return connErr
}
return nil
}); err != nil {
panic("Could not connect to postgres: " + err.Error())
}
Тогда есть:
// Make sure we expire the instance after 10 seconds
postgres.Expire(10)
Это гарантирует, что если очистка не удастся, мы все равно удалим экземпляр докера через 10 секунд.
Наконец-то у нас есть настоящий тест
func TestCreatePerson(t *testing.T) {
// Connect to the database
database.Connect()
// Create a person in the database
database.CreatePerson()
// Check that the person was created
count := database.CountPeople()
if count != 1 {
t.Errorf("Expected 1 person to be in the database, got %d", count)
}
}