В процессе создания программного обеспечения тестирование является одним из ключевых моментов. Но не все компоненты имеют очевидные, известные и понятные пути тестирования. Например, образы Docker либо вообще не тестируются, либо тестируются только на исполняемость. В этой статье я покажу вам, как протестировать образ Docker, чтобы убедиться, что он работает на 100% по назначению.
Юнит‑тестирование (или модульное тестирование) — это процесс разработки программного обеспечения, в процессе которого проверяется функциональность отдельных модулей исходного кода. Этот тест обычно используется в самой разработке программного обеспечения, но сложно сразу представить модульное тестирование образа Docker.
Давайте посмотрим на простейший Dockerfile:
FROM busybox:1.32.1RUN echo 'Hello, World!' > /test.txtЗдесь нам нужно выполнить только одно действие – добавить в файл со строчкой Hello, World! в файл /.
Как мы можем убедиться, что достигаем желаемого результата? Вы можете запустить собранный контейнер и увидеть, что, с одной стороны, необходимый файл присутствует, а с другой стороны, его содержимое соответствует ожиданиям.$ 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.txtHello, World!В общем, это довольно неудобно.На счастье существует фреймворк terratest.Его можно использовать для написания тестов на Golang для Dockers (и dub-compose) так же,как и для обычного кода!
Взглянем на программную реализацию данного теста:
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)}Стало заметно удобнее! Благодаря полноценному языку программирования мы можем создавать гораздо более сложные тестовые сценарии, использовать docker API и так далее.
К сожалению, такие примеры, как Hello World, редко объясняют реальные варианты использования технологий, поэтому давайте представим немного более сложный случай. Например, есть приложение Golang (простой HTTP—сервер):
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 будет выглядеть так:
# Первым делом собираем само приложение
FROM golang:1.16 as builderWORKDIR /src/appCOPY ./main.go /src/appRUN CGO_ENABLED=0 go build -o /go/bin/app main.go# Далее собираем базовый образ из alpine, добавляя туда бинарник curlFROM alpine:3.13.2 AS basisRUN apk add --no-cache curl# Следующим номером открываем порт 8080 и добавляем бинарник из шага сборкиFROM basis AS productionEXPOSE 8080COPY --from=builder /go/bin/app /usr/bin/appENTRYPOINT [ "/usr/bin/app" ]Что здесь можно проверить:
curl;Взглянем, какие можно написать тесты (код полностью доступен в конце статьи).
Вынесем сборку образов в отдельную функцию, чтобы не повторяться:
func BuildWithTarget(t *testing.T, dCtx string, tag string, target string) {
buildOptions := &docker.BuildOptions{Tags: []string{tag},// Target для сборки multi-stageTarget: target,}docker.Build(t, dCtx, buildOptions)}Первым тестом проверим, как и в предыдущем примере, наличие бинарника curl:
func TestBasisLayer(t *testing.T) {
tag := fmt.Sprintf("go_demo:%s", BasisTarget)// Собирается образ с нужным таргетомBuildWithTarget(t, "../", tag, BasisTarget)// И далее схожим образом проверяем наличие файла curlopts := &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-сервер. Здесь уже сложнее:
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"},}// Далее запускаем контейнер и получаем его IDcntId := 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{})}Давайте рассмотрим более сложный пример, когда приложению необходимо подключиться к базе данных Postgres. Официальный образ может добавить скрипт, который настроит схему и добавит некоторые тестовые данные. Мы используем это на этапе тестирования.
Примера скрипта инициализации БД:
#!/bin/bash
set -e# Создаем тестовую базуpsql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQLCREATE DATABASE demo;GRANT ALL PRIVILEGES ON DATABASE demo TO postgres;EOSQL# Добавляем таблицу и «тестовые данные»psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "demo" <<-EOSQLCREATE TABLE demo (id SERIAL PRIMARY KEY,messages VARCHAR(100) NOT NULL );INSERT INTO demo(messages) VALUES ('hello_xakep.ru!');EOSQLВ код программы добавим простую функцию, которая будет забирать из БД строку по ее ID в таблице:
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 stringdefer rows.Close()for rows.Next() {err = rows.Scan(&message)if err != nil {panic(err)}}return message}Dockerfile для сборки приложения выглядит схоже с прошлым примером, но добавилось скачивание пакетов:
FROM golang:1.16 as builder
WORKDIR /src/app
COPY ./ /src/appRUN go get -d -v -u allRUN CGO_ENABLED=0 go build -o /go/bin/srvapp server.goFROM alpine:3.12.0EXPOSE 8000COPY --from=builder /go/bin/srvapp /usr/bin/srvappENTRYPOINT ["/usr/bin/srvapp"]Для тестирования напишем docker-compose-файл, который запускает БД, и рядом собранное приложение:
version: '3.1'
services:# База данных с пробросом скрипта для инициализацииdb:image: postgresenvironment:POSTGRES_PASSWORD: dont_use_this_in_prodvolumes:- ./scripts:/docker-entrypoint-initdb.d# Серверная часть с указанием параметров подключения к БД и пробросом портовserver:image: demo:serverenvironment:DB_USER: postgresDB_PASS: dont_use_this_in_prodDB_HOST: dbDB_PORT: 5432DB_NAME: demoports:- 8000:8000depends_on:- dbСам случай тестирования стал сложнее, но тест — проще. Простая функция для сборки самого образа:
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")// Указание в опциях контекста для docker-composeopts := &docker.Options{WorkingDir: "../",}// Обязательно указываем, что вне зависимости от исхода теста контейнеры будут удаленыdefer docker.RunDockerCompose(t, opts,"down")// Запускаем docker-composedocker.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_xakep.ru!", response)}Если все прошло хорошо, то вывод очень лаконичный.
success
Если тест валится по каким‑то причинам, то будет довольно просто понять, что именно пошло не так.
failed
С помощью этой платформы вы можете протестировать все этапы создания в самом файле Docker, а также общую функциональность приложения. И убедитесь, что полнофункциональные образы по—прежнему отправляются в окружения.
Полный код теста HTTP-сервера:
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:
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_xakep.ru!", response)
Чтобы взломать сеть Wi-Fi с помощью Kali Linux, вам нужна беспроводная карта, поддерживающая режим мониторинга…
Работа с консолью считается более эффективной, чем работа с графическим интерфейсом по нескольким причинам.Во-первых, ввод…
Конечно, вы также можете приобрести подписку на соответствующую услугу, но наличие SSH-доступа к компьютеру с…
С тех пор как ChatGPT вышел на арену, возросла потребность в поддержке чата на базе…
Если вы когда-нибудь окажетесь в ситуации, когда вам нужно взглянуть на спектр беспроводной связи, будь…
Elastic Security стремится превзойти противников в инновациях и обеспечить защиту от новейших технологий злоумышленников. В…