Хакер - Контейнерно-модульное тестирование. Пишем юнит-тесты для образов Docker

Хакер - Контейнерно-модульное тестирование. Пишем юнит-тесты для образов Docker

hacker_frei

https://t.me/hacker_frei

Александр Шуляк 

Тес­тирова­ние — важ­ный шаг на всех эта­пах раз­работ­ки ПО. Но не все ком­понен­ты име­ют оче­вид­ные, извес­тные и понят­ные пути тес­тирова­ния. К при­меру, обра­зы 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{})

}

БАЗЫ ДАННЫХ И COMPOSE

Рас­смот­рим нес­коль­ко более слож­ный при­мер, ког­да при­ложе­ние тре­бует под­клю­чить­ся к некото­рой базе дан­ных Postgres. У офи­циаль­ного обра­за есть воз­можность под­ложить скрипт, который будет выпол­нять кон­фигура­цию схе­мы и добав­лять какие‑то тес­товые дан­ные. Исполь­зуем это на эта­пе тес­тирова­ния.


При­мера скрип­та ини­циали­зации БД:

#!/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_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 string

defer 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/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-файл, который запус­кает БД, и рядом соб­ранное при­ложе­ние:

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

Сам слу­чай тес­тирова­ния стал слож­нее, но тест — про­ще. Прос­тая фун­кция для сбор­ки самого обра­за:

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-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_xakep.ru!", response)

}

Ес­ли все прош­ло хорошо, то вывод очень лаконич­ный.

success

Ес­ли тест валит­ся по каким‑то при­чинам, то будет доволь­но прос­то понять, что имен­но пош­ло не так.

failed

С этим фрей­мвор­ком мож­но про­тес­тировать все шаги сбор­ки внут­ри самого Dockerfile, а так­же общую фун­кци­ональ­ность при­ложе­ния. И быть уве­рен­ным, что далее в окру­жения отправ­ляют­ся пол­ностью рабочие обра­зы.

Пол­ный код тес­та 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)

}

Го­тово!

Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei



Report Page