Тестирование — важный шаг на всех этапах разработки ПО. Но не все компоненты имеют очевидные, известные и понятные пути тестирования. К примеру, образы Docker либо не тестируют вообще, либо тестируют только на пригодность к запуску. В сегодняшней статье я расскажу, как протестировать образ Docker так, чтобы убедиться в том, что он на 100% выполняет свои задачи.
РЕКОМЕНДУЕМ:
Безопасность Docker
Как с помощью Docker упаковать приложение ASP.NET Core
Введение в тестирование
Юнит‑тестирование (или модульное тестирование) — это процесс в разработке программного обеспечения, позволяющий проверить работоспособность отдельных модулей исходного кода. Такое тестирование привычно применяется в разработке непосредственно программного обеспечения, однако с ходу сложно себе представить юнит‑тестирование образа Docker.
Взглянем на простейший Dockerfile:
1 2 |
FROM busybox:1.32.1 RUN echo 'Hello, World!' > /test.txt |
Здесь мы выполняем единственное действие — добавляем файл со строкой Hello, <wbr />World! в файл /<wbr />test.<wbr />txt.
Как можно проверить, что мы достигаем желаемого результата? Можно запустить собранный контейнер и посмотреть, что, во‑первых, нужный файл присутствует, а во‑вторых, его содержимое равно ожидаемому.
1 2 3 4 5 6 |
$ docker build -t test . [+] Building 7.7s (6/6) FINISHED $ docker run --rm test ls -lha /test.txt -rw-r--r-- 1 root root 14 Feb 20 19:26 /test.txt $ docker run --rm test cat /test.txt Hello, World! |
Не слишком удобно, не так ли? К счастью, существует фреймворк terratest. Он позволяет писать тесты на Golang для Docker (и docker-compose) так же, как и для обычного кода!
Взглянем на программную реализацию данного теста:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
package docker_test import ( "testing" "github.com/gruntwork-io/terratest/modules/docker" "github.com/stretchr/testify/assert" ) func TestDockerImage(t *testing.T) { // Определяем название образа для тестирования tag := "test" buildOptions := &docker.BuildOptions{ Tags: []string{tag}, } // Собираем образ из Dockerfile’а docker.Build(t, "../", buildOptions) // Фактически выставляем как опции запуск контейнера со следующими командами // Команда, которая вернет 'exists', если файл существует eOpts := &docker.RunOptions{Command: []string{"sh", "-c", "[ -f /test.txt ] && echo exists"}} // Команда, которая вернет содержимое файла cOpts := &docker.RunOptions{Command: []string{"cat", "/test.txt"}} // Запускаем контейнер с проверкой на наличие файла chkExisting := docker.Run(t, tag, eOpts) // Проверяем, что вывод равен желаемому assert.Equal(t, "exists", chkExisting) // Запускаем контейнер с выводом содержимого файла chkContent := docker.Run(t, tag, cOpts) // Проверяем, что вывод равен желаемому assert.Equal(t, "Hello, World!", chkContent) } |
Стало ощутимо удобнее! Благодаря полноценному языку программирования мы можем создавать намного более сложные сценарии тестирования, использовать API докер и так далее.
Усложняем: тестирование HTTP-сервера с зависимостями
К сожалению, примеры вроде Hello World редко объясняют реальные кейсы применения технологии, поэтому давай представим несколько более сложный случай. К примеру, есть Golang-приложение (простой HTTP-сервер):
1 2 3 4 5 6 7 8 9 10 11 12 |
package main import ( "fmt" "net/http" ) func hello(w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, "hello") } func main() { http.HandleFunc("/hello", hello) http.ListenAndServe(":8000", nil) } |
Предположим, приложению также требуется бинарник curl для работы. Тогда Dockerfile будет выглядеть следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Первым делом собираем само приложение FROM golang:1.16 as builder WORKDIR /src/app COPY ./main.go /src/app RUN CGO_ENABLED=0 go build -o /go/bin/app main.go # Далее собираем базовый образ из alpine, добавляя туда бинарник curl FROM alpine:3.13.2 AS basis RUN apk add --no-cache curl # Следующим номером открываем порт 8080 и добавляем бинарник из шага сборки FROM basis AS production EXPOSE 8080 COPY --from=builder /go/bin/app /usr/bin/app ENTRYPOINT [ "/usr/bin/app" ] |
Что здесь можно проверить:
- наличие бинарника curl;
- что сервер успешно поднимается и порт 8080 открыт и прослушивается.
Взглянем, какие можно написать тесты (код полностью доступен в конце статьи).
Вынесем сборку образов в отдельную функцию, чтобы не повторяться:
1 2 3 4 5 6 7 8 |
func BuildWithTarget(t *testing.T, dCtx string, tag string, target string) { buildOptions := &docker.BuildOptions{ Tags: []string{tag}, // Target для сборки multi-stage Target: target, } docker.Build(t, dCtx, buildOptions) } |
Первым тестом проверим, как и в предыдущем примере, наличие бинарника curl:
1 2 3 4 5 6 7 8 9 10 11 12 |
func TestBasisLayer(t *testing.T) { tag := fmt.Sprintf("go_demo:%s", BasisTarget) // Собирается образ с нужным таргетом BuildWithTarget(t, "../", tag, BasisTarget) // И далее схожим образом проверяем наличие файла curl opts := &docker.RunOptions{ Command: []string{"sh", "-c", "[ -f /usr/bin/curl ] && echo exists"}, Remove: true, } chkExisting := docker.Run(t, tag, opts) assert.Equal(t, "exists", chkExisting) } |
Вторым — доступен ли HTTP-сервер. Здесь уже сложнее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
func TestProductionLayerServerAvailability(t *testing.T) { tag := fmt.Sprintf("go_demo:%s", ProdTarget) BuildWithTarget(t, "../", tag, ProdTarget) // Обязательно выставляем параметр Detach, в противном случае // процесс зависнет на выводе запущенного контейнера. // Параметр -P позволит пробросить порт на случайный свободный // порт на хосте, тем самым позволяя избежать ошибки с выбором занятого порта opts := &docker.RunOptions{ Remove: true, Detach: true, OtherOptions: []string{"-P"}, } // Далее запускаем контейнер и получаем его ID cntId := docker.RunAndGetID(t, tag, opts) // Через интерфейс функции Inspect получаем проброшенный порт cntInsp := docker.Inspect(t, cntId) hostPort := cntInsp.GetExposedHostPort(uint16(8000)) url := fmt.Sprintf("http://localhost:%d/hello", int(hostPort)) // Используя http_helper из библиотеки terratest, можно сделать // запрос к выбранному URL и проверить результаты запроса status, _ := http_helper.HttpGet(t, url, &tls.Config{}) assert.Equal(t, 200, status) // В последнюю очередь удаляем использованный контейнер docker.Stop(t, []string{cntId}, &docker.StopOptions{}) } |
Базы данных и Compose
Рассмотрим несколько более сложный пример, когда приложение требует подключиться к некоторой базе данных Postgres. У официального образа есть возможность подложить скрипт, который будет выполнять конфигурацию схемы и добавлять какие‑то тестовые данные. Используем это на этапе тестирования.
Примера скрипта инициализации БД:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#!/bin/bash set -e # Создаем тестовую базу psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL CREATE DATABASE demo; GRANT ALL PRIVILEGES ON DATABASE demo TO postgres; EOSQL # Добавляем таблицу и «тестовые данные» psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "demo" <<-EOSQL CREATE TABLE demo ( id SERIAL PRIMARY KEY, messages VARCHAR(100) NOT NULL ); INSERT INTO demo(messages) VALUES ('hello_teck-geek.ru!'); EOSQL |
В код программы добавим простую функцию, которая будет забирать из БД строку по ее ID в таблице:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
func getPsqlData(id string) string { host := os.Getenv("DB_HOST") port := os.Getenv("DB_PORT") user := os.Getenv("DB_USER") password := os.Getenv("DB_PASS") dbname := os.Getenv("DB_NAME") psqlconn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", host, port, user, password, dbname) db, err := sql.Open("postgres", psqlconn) if err != nil { panic(err) } defer db.Close() rows, err := db.Query(fmt.Sprintf(`SELECT "messages" FROM "demo" WHERE id=%s`, id)) if err != nil { panic(err) } var message string defer rows.Close() for rows.Next() { err = rows.Scan(&message) if err != nil { panic(err) } } return message } |
Dockerfie для сборки приложения выглядит схоже с прошлым примером, но добавилось скачивание пакетов:
1 2 3 4 5 6 7 8 9 |
FROM golang:1.16 as builder WORKDIR /src/app COPY ./ /src/app RUN go get -d -v -u all RUN CGO_ENABLED=0 go build -o /go/bin/srvapp server.go FROM alpine:3.12.0 EXPOSE 8000 COPY --from=builder /go/bin/srvapp /usr/bin/srvapp ENTRYPOINT ["/usr/bin/srvapp"] |
Для тестирования напишем docker-compose-файл, который запускает БД, и рядом собранное приложение:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
version: '3.1' services: # База данных с пробросом скрипта для инициализации db: image: postgres environment: POSTGRES_PASSWORD: dont_use_this_in_prod volumes: - ./scripts:/docker-entrypoint-initdb.d # Серверная часть с указанием параметров подключения к БД и пробросом портов server: image: demo:server environment: DB_USER: postgres DB_PASS: dont_use_this_in_prod DB_HOST: db DB_PORT: 5432 DB_NAME: demo ports: - 8000:8000 depends_on: - db |
Сам случай тестирования стал сложнее, но тест — проще. Простая функция для сборки самого образа:
1 2 3 4 5 6 |
func BuildDockerImage(t *testing.T, dCtx string, tag string) { buildOptions := &docker.BuildOptions{ Tags: []string{tag}, } docker.Build(t, dCtx, buildOptions) } |
И сам тест:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
func TestServerAvailability(t *testing.T) { // Сборка образа BuildDockerImage(t, "../", "demo:server") // Указание в опциях контекста для docker-compose opts := &docker.Options{ WorkingDir: "../", } // Обязательно указываем, что вне зависимости от исхода теста контейнеры будут удалены defer docker.RunDockerCompose(t, opts,"down") // Запускаем docker-compose docker.RunDockerCompose(t, opts, "up","-d") // Не очень элегантное решение, но надо подождать, чтобы база данных успела инициализировать схему time.Sleep(20*time.Second) url := "http://localhost:8000/message" // Проверка ответов от запроса status, response := http_helper.HttpGet(t, url, &tls.Config{}) assert.Equal(t, 200, status) assert.Equal(t, "hello_tech-geek.ru!", response) } |
Если все прошло хорошо, то вывод очень лаконичный.
Если тест валится по каким‑то причинам, то будет довольно просто понять, что именно пошло не так.
С этим фреймворком можно протестировать все шаги сборки внутри самого Dockerfile, а также общую функциональность приложения. И быть уверенным, что далее в окружения отправляются полностью рабочие образы.
Полный код теста HTTP-сервера:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
package docker_test import ( "crypto/tls" "fmt" "github.com/gruntwork-io/terratest/modules/docker" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/stretchr/testify/assert" "testing" ) const ( BasisTarget = "basis" ProdTarget = "production" ) func BuildWithTarget(t *testing.T, dCtx string, tag string, target string) { buildOptions := &docker.BuildOptions{ Tags: []string{tag}, Target: target, } docker.Build(t, dCtx, buildOptions) } func TestBasisLayer(t *testing.T) { tag := fmt.Sprintf("go_demo:%s", BasisTarget) BuildWithTarget(t, "../", tag, BasisTarget) opts := &docker.RunOptions{ Command: []string{"sh", "-c", "[ -f /usr/bin/curl ] && echo exists"}, Remove: true, } chkExisting := docker.Run(t, tag, opts) assert.Equal(t, "exists", chkExisting) } func TestProductionLayerServerAvailability(t *testing.T) { tag := fmt.Sprintf("go_demo:%s", ProdTarget) BuildWithTarget(t, "../", tag, ProdTarget) opts := &docker.RunOptions{ Remove: true, Detach: true, OtherOptions: []string{"-P"}, } cntId := docker.RunAndGetID(t, tag, opts) cntInsp := docker.Inspect(t, cntId) hostPort := cntInsp.GetExposedHostPort(uint16(8000)) url := fmt.Sprintf("http://localhost:%d/hello", int(hostPort)) status, _ := http_helper.HttpGet(t, url, &tls.Config{}) assert.Equal(t, 200, status) docker.Stop(t, []string{cntId}, &docker.StopOptions{}) } |
Полный код примера тестирования с помощью docker-compose:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
package docker_test import ( "crypto/tls" "github.com/gruntwork-io/terratest/modules/docker" http_helper "github.com/gruntwork-io/terratest/modules/http-helper" "github.com/stretchr/testify/assert" "testing" "time" ) func BuildDockerImage(t *testing.T, dCtx string, tag string) { buildOptions := &docker.BuildOptions{ Tags: []string{tag}, } docker.Build(t, dCtx, buildOptions) } func TestServerAvailability(t *testing.T) { BuildDockerImage(t, "../", "demo:server") opts := &docker.Options{ WorkingDir: "../", } defer docker.RunDockerCompose(t, opts,"down") docker.RunDockerCompose(t, opts, "up","-d") time.Sleep(20*time.Second) url := "http://localhost:8000/message" status, response := http_helper.HttpGet(t, url, &tls.Config{}) assert.Equal(t, 200, status) assert.Equal(t, "hello_tech-geek.ru!", response) } |
Готово!