Тес­тирова­ние — важ­ный шаг на всех эта­пах раз­работ­ки ПО. Но не все ком­понен­ты име­ют оче­вид­ные, извес­тные и понят­ные пути тес­тирова­ния. К при­меру, обра­зы Docker либо не тес­тиру­ют вооб­ще, либо тес­тиру­ют толь­ко на при­год­ность к запус­ку. В этой статье я рас­ска­жу, как про­тес­тировать образ Docker так, что­бы убе­дить­ся в том, что он на 100% выпол­няет свои задачи.

Введение в тестирование

Юнит‑тес­тирова­ние (или модуль­ное тес­тирова­ние) — это про­цесс в раз­работ­ке прог­рам­мно­го обес­печения, поз­воля­ющий про­верить работос­пособ­ность отдель­ных модулей исходно­го кода. Такое тес­тирова­ние при­выч­но при­меня­ется в раз­работ­ке непос­редс­твен­но прог­рам­мно­го обес­печения, одна­ко с ходу слож­но себе пред­ста­вить юнит‑тес­тирова­ние обра­за Docker.

Взгля­нем на прос­тей­ший Dockerfile:

FROM busybox:1.32.1
RUN echo 'Hello, World!' > /test.txt

Здесь мы выпол­няем единс­твен­ное дей­ствие — добав­ляем файл со стро­кой Hello, World! в файл /test.txt.

Как мож­но про­верить, что мы дос­тига­ем жела­емо­го резуль­тата? Мож­но запус­тить соб­ранный кон­тей­нер и пос­мотреть, что, во‑пер­вых, нуж­ный файл при­сутс­тву­ет, а во‑вто­рых, его содер­жимое рав­но ожи­даемо­му.

$ 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) так же, как и для обыч­ного кода!

Взгля­нем на прог­рам­мную реали­зацию дан­ного тес­та:

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-сер­вер):

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 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 открыт и прос­лушива­ется.

Взгля­нем, какие мож­но написать тес­ты (код пол­ностью дос­тупен в кон­це статьи).

Вы­несем сбор­ку обра­зов в отдель­ную фун­кцию, что­бы не пов­торять­ся:

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:

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-сер­вер. Здесь уже слож­нее:

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{})
}

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


1 комментарий

  1. Аватар

    Laglag

    26.04.2021 в 08:44

    Очень мощная статья. Спасибо. Многих выведет без знаний программирования на новый уровень. Спасибо. Очень хочетсч от Вас ещё статьи. Я человек который только начинает изучение и очень нужна информация.

Оставить мнение