vyvchy
    Теми розділу

    09 · Git і CI/CD

    Docker і тестові середовища

    Зміст

    «Локально проходить, а в CI падає» — фраза, під якою підписалася б половина автоматизаторів. Причина частіше не в коді, а в середовищі: інша версія Node, інший браузер, інші системні бібліотеки, інший часовий пояс, інші шрифти. Docker прибирає цю невизначеність, пакуючи застосунок разом з усім оточенням в один незмінний артефакт, який поводиться однаково на ноутбуці, на агенті CI і на проді.

    Для AQA це не «щось для девопсів». Docker — це спосіб підняти застосунок з базою локально однією командою, запустити тести в тому самому середовищі, що й пайплайн, і нарешті відрізнити справжній баг продукту від різниці оточень. А ще це джерело власного класу пасток — від забутого монтування артефактів до скриншотних тестів, що падають через відсутні шрифти. Розберемо механіку, а тоді — де вона болить.

    Образ і контейнер: шаблон і його екземпляр

    Дві головні сутності Docker плутають найчастіше, тому почнемо з них.

    Образ (image) — це незмінний (immutable), тільки-для-читання шаблон: спакована файлова система плюс метадані (яку команду запускати, які порти відкрити, які змінні середовища задати). Образ будується один раз і не змінюється. Технічно він складається з шарів (layers) — кожна інструкція складання, що змінює файлову систему (RUN, COPY, ADD), додає новий шар поверх попереднього, а однакові шари перевикористовуються між образами й кешуються.

    Контейнер (container) — це запущений екземпляр образу: той самий незмінний образ плюс тонкий записуваний шар (writable layer) зверху плюс ізольований процес. З одного образу можна підняти скільки завгодно контейнерів, і кожен житиме своїм життям.

    Найточніша аналогія для тих, хто знає ООП: образ — це клас, контейнер — це об'єкт цього класу. Або в термінах інсталяції: образ — це інсталяційний носій, контейнер — це вже встановлена й запущена система. Ключовий наслідок для тестів: записуваний шар контейнера ефемерний — щойно ви видалили контейнер, усе, що він записав (окрім того, що винесено у volume), зникає. Тому базу даних, звіти й трейси треба свідомо виносити назовні — до цього повернемося.

    docker build

    docker run

    docker run

    docker run

    Dockerfile
    рецепт

    Образ
    незмінні шари

    Контейнер 1
    + записуваний шар

    Контейнер 2
    + записуваний шар

    Контейнер 3
    + записуваний шар

    docker build

    docker run

    docker run

    docker run

    Dockerfile
    рецепт

    Образ
    незмінні шари

    Контейнер 1
    + записуваний шар

    Контейнер 2
    + записуваний шар

    Контейнер 3
    + записуваний шар

    Контейнер проти віртуальної машини

    Щоб зрозуміти, чому контейнери легкі й швидкі, треба порівняти їх з віртуальними машинами (virtual machine, VM).

    Віртуальна машина працює через гіпервізор (hypervisor), який віртуалізує залізо. Кожна VM несе власне повне гостьове ядро операційної системи (guest OS kernel) і повний набір системних служб. Звідси її вага: гігабайти на диску, десятки секунд чи хвилини на старт.

    Контейнер натомість ділить ядро (kernel) хост-системи. Ізоляцію дають механізми самого ядра Linux: namespaces (окремий погляд на процеси, мережу, файлову систему) і cgroups (обмеження на CPU й пам'ять). Окремої гостьової ОС немає — є ізольований процес, який просто думає, що він сам на машині.

    АспектВіртуальна машинаКонтейнер
    ІзоляціяВласне ядро гостьової ОССпільне ядро хоста
    ВагаГігабайтиМегабайти
    СтартДесятки секунд–хвилиниСекунди й менше
    Накладні витратиПомітніМайже нульові
    Межа безпекиСильнішаСлабша (спільне ядро)

    Звідси випливає факт, який ставить у глухий кут на співбесіді: контейнери — це технологія ядра Linux. Коли ви запускаєте Docker на macOS чи Windows, він піднімає під капотом легку Linux-віртуалку (через Docker Desktop), і вже в ній крутить контейнери. Тому «нативний» Docker живе тільки на Linux, а на маку між вашим кодом і контейнером завжди є прошарок VM — це пояснює і повільніший ввід-вивід, і частину розбіжностей поведінки.

    Dockerfile: рецепт образу

    Образ описують текстовим файлом Dockerfile — це послідовність інструкцій, які виконуються згори вниз під час складання. П'ять, які треба знати напам'ять:

    # FROM — з якого базового образу починаємо
    FROM node:20-bookworm
    
    # WORKDIR — робоча директорія всередині образу
    WORKDIR /app
    
    # COPY — копіюємо файли з контексту складання в образ
    COPY package.json package-lock.json ./
    
    # RUN — виконуємо команду ПІД ЧАС складання, результат стає шаром
    RUN npm ci
    
    COPY . .
    
    # CMD — команда за замовчуванням ПРИ ЗАПУСКУ контейнера
    CMD ["npx", "playwright", "test"]

    Розберемо принципову різницю, яку часто питають:

    • FROM задає фундамент. Ви майже ніколи не будуєте образ з нуля — ви берете готовий (node, postgres, офіційний образ Playwright) і нашаровуєте своє.
    • RUN виконується під час складання (docker build). Кожен RUN створює новий шар, який кешується.
    • CMD виконується під час запуску (docker run), а не складання. Це процес, який стартує в контейнері. CMD в образі один — остання інструкція перемагає.

    Тепер найважливіше практичне правило, яке відрізняє того, хто «читав про Docker», від того, хто ним користувався. Зверніть увагу на порядок: спершу копіюємо тільки package.json і ставимо залежності, і лише потім копіюємо решту коду. Це не випадковість. Docker кешує шари й перебудовує лише ті, що змінилися, разом з усіма наступними. Залежності міняються рідко, код — щокоміта. Якщо покласти COPY . . перед npm ci, то будь-яка правка тесту інвалідує кеш і змусить перевстановлювати весь node_modules — складання з двох секунд перетворюється на двохвилинне. Правильний порядок економить хвилини на кожному прогоні CI.

    Основні команди: build, run, ps, logs, exec

    Мінімальний набір, яким ви керуєте образами й контейнерами з термінала (робота з CLI — глава «Термінал і Linux для QA»):

    # Зібрати образ з Dockerfile у поточній директорії (крапка — це контекст складання)
    docker build -t my-tests:1.0 .
    
    # Запустити контейнер з образу
    docker run --rm -p 3000:3000 my-tests:1.0
    
    # Показати запущені контейнери (додайте -a, щоб побачити й зупинені)
    docker ps
    
    # Прочитати stdout/stderr контейнера — тут ваші тестові логи
    docker logs <container>
    
    # Зайти всередину ЗАПУЩЕНОГО контейнера для дебагу
    docker exec -it <container> sh

    Кілька прапорців docker run, які трапляються щодня: -d — запуск у фоні (detached); -e KEY=value — передати змінну середовища; --name — дати контейнеру ім'я замість випадкового; --rm — прибрати контейнер одразу після зупинки, щоб не накопичувати сміття.

    Для тестувальника найцінніші дві команди — logs і exec. docker logs — це перше місце, куди дивляться, коли тести в контейнері впали: увесь вивід ранера саме там. А docker exec -it <container> sh пускає вас усередину живого контейнера, як по ssh: перевірити, чи справді на місці файл, які шрифти встановлені, що бачить застосунок у своїх змінних середовища. Це переводить дебаг з «здогадуюся» в «дивлюся».

    Порти і volume: як контейнер з'єднати зі світом

    Контейнер за замовчуванням ізольований — і мережа, і файлова система в нього свої. Щоб з ним взаємодіяти, потрібні два механізми.

    Порти. Контейнер має власний мережевий простір, тож порт, який слухає застосунок усередині, ззовні не видно, поки його явно не опублікувати. Це робить прапорець -p, і порядок аргументів тут регулярно плутають:

    docker run -p 8080:3000 my-app
    #             ^^^^ ^^^^
    #             хост контейнер

    Ліворуч — порт на хост-машині, праворуч — порт усередині контейнера. Тобто запит на localhost:8080 вашого ноутбука прокидається на порт 3000 контейнера. (Механіка портів як таких — у главі «DNS, IP, порти та мережа».)

    Volume. Оскільки записуваний шар контейнера ефемерний, усе, що має пережити контейнер, монтують назовні через volume. Два типові різновиди:

    • bind mount (-v /шлях/на/хості:/шлях/у/контейнері) — прокидає конкретну директорію хоста всередину. Саме так витягують артефакти тестів: змонтуйте ./playwright-report і ./test-results, і після падіння звіт, скриншоти й трейси лишаться на диску, а не помруть разом з контейнером.
    • named volume — сховище, яким керує сам Docker. Ним зберігають дані, що мають пережити перезапуск: наприклад, файли бази даних.
    # Прогнати тести в контейнері й забрати звіт та трейси на хост
    docker run --rm -v $(pwd)/playwright-report:/app/playwright-report my-tests:1.0

    Без цього монтування типова помилка новачка виглядає так: тест упав у контейнері, ви хочете подивитися трейс — а контейнера вже нема, і артефакти зникли разом з ним.

    docker compose: застосунок, база і тести разом

    Один контейнер — це рідко вся картина. Реальний e2e-прогін потребує щонайменше трьох учасників: сам застосунок, його база даних і, власне, тести. Піднімати їх поодинці, вручну зв'язуючи мережу, — біль. Для цього є docker compose: декларативний YAML-файл, який описує кілька сервісів, їхню мережу й залежності, і піднімає все однією командою docker compose up.

    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD: test
        healthcheck:
          test: ["CMD-SHELL", "pg_isready -U postgres"]
          interval: 2s
          retries: 10
    
      app:
        build: .
        environment:
          DATABASE_URL: postgres://postgres:test@db:5432/postgres
        depends_on:
          db:
            condition: service_healthy
        ports:
          - "3000:3000"
    
      tests:
        image: mcr.microsoft.com/playwright:v1.49.0-jammy
        command: npx playwright test
        environment:
          BASE_URL: http://app:3000
        depends_on:
          - app

    Тут коштовна деталь — мережа. Compose кладе всі сервіси в одну мережу, де вони бачать одне одного за іменем сервісу, як за доменним ім'ям. Тому застосунок звертається до бази за db:5432, а тести — до застосунку за http://app:3000. Ніяких IP-адрес, ніякого localhost (усередині мережі compose localhost кожного сервіса — це він сам).

    Друга коштовна деталь — це пастка, на якій горять постійно. depends_on керує лише порядком старту, а не готовністю. Postgres-контейнер може «запуститися» за півсекунди, але сама база всередині ще ініціалізується кілька секунд, і тести, які стартанули одразу, отримають «connection refused». Тому одного depends_on замало — потрібен healthcheck плюс condition: service_healthy, як у прикладі вище: тоді app дочекається, поки база справді відповідатиме. Конфігурацію під різні середовища зазвичай виносять у змінні (глава «Секрети та конфігурація в CI»).

    Цінність для QA пряма: увесь стенд — застосунок плюс база плюс тести — описаний одним файлом у репозиторії. Той самий compose піднімається і локально в розробника, і на агенті CI, тож «в мене працює» перестає бути аргументом.

    Готові образи: Playwright з браузерами

    Свій образ з нуля будують рідко — публічні реєстри (Docker Hub, GitHub Container Registry, Microsoft Container Registry) уже містять тисячі готових: node, postgres, redis, nginx. Для автоматизатора найважливіший — офіційний образ Playwright.

    Проблема, яку він розв'язує: браузери потребують десятків системних бібліотек (для рендерингу, звуку, шрифтів), і зібрати їх правильно на голому Linux — марудно. Офіційний образ уже містить Chromium, Firefox і WebKit разом з усіма їхніми залежностями й базовим набором шрифтів, підігнаними під конкретну версію Playwright:

    mcr.microsoft.com/playwright:v1.49.0-jammy
    

    Суфікс jammy — це кодова назва Ubuntu 22.04, на якій зібрано образ. І ось критичне правило: версія образу мусить збігатися з версією @playwright/test у вашому package.json. Кожен реліз Playwright прив'язаний до конкретних збірок браузерів; якщо образ v1.49, а бібліотека в проєкті v1.52, ви ризикуєте невідповідністю браузера й бібліотеки — від дивних падінь до відмови стартувати. З тієї ж причини в CI не тегують образи як latest (сьогодні це одне, завтра інше — недетермінованість) — фіксують конкретну версію.

    Тепер зберемо, чому пайплайни майже завжди крутять тести саме в контейнерах (будова пайплайна — глава «Будова пайплайна: стадії, джоби, тригери, артефакти»):

    • Відтворюваність. Образ фіксує все: версію Node, збірки браузерів, системні бібліотеки, шрифти, локаль, часовий пояс. Той самий образ дає той самий результат на будь-якій машині — головний засіб проти «локально зелене, у CI червоне» (глибше — глава «Автотести в CI: стабільність і дебаг»).
    • Ізоляція й чистота. Кожен прогін стартує з незмінного образу без слідів попередніх ранів. Ніякого «хтось лишив стан у системі».
    • Швидка інфраструктура на льоту. Підняти Postgres чи Redis для інтеграційних тестів — один рядок у конфізі. Ефемерні агенти піднімають контейнери, ганяють тести й викидають усе.
    • Паритет середовищ. Один образ їде через dev, CI і staging, тож поведінка збігається (середовища й деплой — глава «Середовища, деплой і релізи»).
    • Швидкість через кеш. Кешування шарів образу й залежностей помітно скорочує час пайплайна (детальніше — глава «Паралелізація і швидкість пайплайна»).

    Типові помилки

    Виглядає як «мій тест зловив баг», а насправді — розбіжність оточень. Тест зелений локально й червоний у CI. Перш ніж заводити баг на продукт, звіртеся: чи однакова версія браузера, локаль, часовий пояс, набір шрифтів? Найчастіше винна не логіка застосунку, а те, що локальний прогін і контейнер CI — різні світи. Docker для того й потрібен, щоб цієї розбіжності не було.

    Виглядає як «база впала», а насправді тести стартанули раніше за її готовність. connection refused на старті прогону — це майже завжди depends_on без healthcheck. Контейнер бази піднявся, але сама СУБД ще ініціалізується. Лікується condition: service_healthy, а не збільшенням випадкового sleep.

    Виглядає як «артефакти зникли», а насправді їх не змонтували. Тест упав, ви хочете трейс — а контейнера вже нема. Записуваний шар помер разом з контейнером. Артефакти (звіт, скриншоти, трейси) треба свідомо винести через bind-mount volume ще до запуску.

    Виглядає як «зламався baseline скриншота», а насправді — інші шрифти в контейнері. Про це — окремо, бо це класичне питання співбесіди.

    Пастка: скриншотні тести і шрифти в контейнері

    Скриншотні (візуальні) тести порівнюють рендер сторінки з еталонним зображенням (baseline) піксель-у-піксель. А рендер тексту залежить від шрифтів, доступних в операційній системі, і від того, як вона згладжує гліфи (font rendering, антиаліасинг). На macOS один набір шрифтів і своя логіка згладжування гліфів, у Linux-контейнері — зовсім інші, а якогось шрифту може не бути взагалі (тоді замість тексту — «квадратики», а замість емодзі — порожнеча).

    Наслідок закономірний: еталон, знятий локально на маку, гарантовано «розсиплеться» в Linux-контейнері CI — інша ширина рядків, інші контури літер, інше згладжування на краях. Це не баг застосунку і не флак у звичному сенсі — це різниця середовищ рендерингу.

    Playwright знає про цю проблему й за замовчуванням дописує до імені файла-еталона суфікс платформи (наприклад, ...-linux.png проти ...-darwin.png), щоб знімки з різних ОС фізично не змішувалися. Але це лише запобіжник, а не розв'язання. Правильний підхід один: генерувати й оновлювати еталони в тому самому середовищі, у якому вони перевірятимуться — тобто в тому самому Docker-образі, що й CI. На практиці еталони оновлюють, запустивши --update-snapshots усередині контейнера, і ніколи не комітять baseline, знятий локально на своєму маку чи Windows. Тут Docker — не просто зручність, а єдиний спосіб зробити скриншот-порівняння детермінованим. (Ширша механіка візуального тестування — тема розділу про інструменти автоматизації; таксономія флакі-тестів — тема розділу про стратегію автоматизації.)

    Підсумок

    • Образ незмінний, контейнер ефемерний. Образ — тільки-для-читання шаблон із шарів; контейнер — його запущений екземпляр із записуваним шаром, який зникає при видаленні. Усе цінне виносьте у volume.
    • Контейнер ділить ядро хоста, VM — ні. Звідси легкість і швидкість контейнерів; звідси ж те, що на macOS/Windows під Docker крутиться прихована Linux-VM.
    • У Dockerfile RUN — це складання, CMD — це запуск. Порядок інструкцій визначає ефективність кешу: залежності копіюйте й ставте до коду.
    • depends_on — це порядок старту, а не готовність. Для реальної готовності потрібен healthcheck.
    • Скриншотні еталони прив'язані до середовища рендерингу. Знімайте й оновлюйте їх у тому самому контейнері, що й CI, — інакше шрифти зруйнують порівняння.

    Що питають на співбесіді

    • «Чим образ відрізняється від контейнера?» Класична перевірка базового розуміння. Сильна відповідь: образ — незмінний шаблон, контейнер — його запущений екземпляр (аналогія клас/об'єкт), і згадка про ефемерний записуваний шар.
    • «Контейнер чи віртуалка — у чому різниця?» Інтерв'юер хоче почути про спільне ядро проти окремої гостьової ОС і наслідки: вага, швидкість старту, межа ізоляції. Бонус — що Docker на маку піднімає Linux-VM.
    • «Навіщо Docker у CI / для тестів?» Дивляться, чи бачите ви за модним словом користь: відтворюваність, ізоляція, паритет середовищ, легке підняття бази. Ключове формулювання — «прибирає різницю оточень, через яку тест падає лише в CI».
    • «Ваш скриншотний тест падає тільки в CI. Чому?» Улюблене питання для middle-рівня. Очікувана відповідь — різниця шрифтів і рендерингу між локальною ОС і контейнером, і що еталони треба генерувати в тому ж середовищі.
    • «У compose тести не бачать базу / connection refused. Що не так?» Перевірка на знання, що depends_on не чекає готовності. Правильна відповідь — healthcheck і condition: service_healthy.

    Джерела

    • Docker — Get Started: Docker overview — офіційне пояснення образів, контейнерів і архітектури.
    • Dockerfile reference — повний перелік інструкцій (FROM, COPY, RUN, CMD та інші).
    • Docker Compose overview — багатоконтейнерні застосунки, сервіси, мережа, depends_on і healthcheck.
    • Playwright — Docker — офіційний образ з браузерами й вимога збігу версій.
    • Playwright — Visual comparisons — чому знімки залежать від середовища й суфікс платформи в іменах еталонів.