Docker і тестові середовища
Зміст
«Локально проходить, а в CI падає» — фраза, під якою підписалася б половина автоматизаторів. Причина частіше не в коді, а в середовищі: інша версія Node, інший браузер, інші системні бібліотеки, інший часовий пояс, інші шрифти. Docker прибирає цю невизначеність, пакуючи застосунок разом з усім оточенням в один незмінний артефакт, який поводиться однаково на ноутбуці, на агенті CI і на проді.
Для AQA це не «щось для девопсів». Docker — це спосіб підняти застосунок з базою локально однією командою, запустити тести в тому самому середовищі, що й пайплайн, і нарешті відрізнити справжній баг продукту від різниці оточень. А ще це джерело власного класу пасток — від забутого монтування артефактів до скриншотних тестів, що падають через відсутні шрифти. Розберемо механіку, а тоді — де вона болить.
Образ і контейнер: шаблон і його екземпляр
Дві головні сутності Docker плутають найчастіше, тому почнемо з них.
Образ (image) — це незмінний (immutable), тільки-для-читання шаблон: спакована файлова система плюс метадані (яку команду запускати, які порти відкрити, які змінні середовища задати). Образ будується один раз і не змінюється. Технічно він складається з шарів (layers) — кожна інструкція складання, що змінює файлову систему (RUN, COPY, ADD), додає новий шар поверх попереднього, а однакові шари перевикористовуються між образами й кешуються.
Контейнер (container) — це запущений екземпляр образу: той самий незмінний образ плюс тонкий записуваний шар (writable layer) зверху плюс ізольований процес. З одного образу можна підняти скільки завгодно контейнерів, і кожен житиме своїм життям.
Найточніша аналогія для тих, хто знає ООП: образ — це клас, контейнер — це об'єкт цього класу. Або в термінах інсталяції: образ — це інсталяційний носій, контейнер — це вже встановлена й запущена система. Ключовий наслідок для тестів: записуваний шар контейнера ефемерний — щойно ви видалили контейнер, усе, що він записав (окрім того, що винесено у volume), зникає. Тому базу даних, звіти й трейси треба свідомо виносити назовні — до цього повернемося.
Контейнер проти віртуальної машини
Щоб зрозуміти, чому контейнери легкі й швидкі, треба порівняти їх з віртуальними машинами (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 (сьогодні це одне, завтра інше — недетермінованість) — фіксують конкретну версію.
Навіщо Docker у CI
Тепер зберемо, чому пайплайни майже завжди крутять тести саме в контейнерах (будова пайплайна — глава «Будова пайплайна: стадії, джоби, тригери, артефакти»):
- Відтворюваність. Образ фіксує все: версію 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 — чому знімки залежать від середовища й суфікс платформи в іменах еталонів.
Що таке образ (image) і контейнер (container) та чим вони відрізняються?
Образ — це запакований, тільки-для-читання шаблон: файлова система застосунку разом із метаданими про те, що і як усередині запускати. Контейнер — це вже піднятий екземпляр образу: той самий незмінний вміст плюс тонкий записуваний шар (writable layer) зверху й окремий ізольований процес. Якщо мислити категоріями ООП, образ грає роль класу, а контейнер — інстанс цього класу; з одного образу можна підняти хоч десяток контейнерів, і кожен житиме своїм життям. Практичний наслідок один: образ незмінний, а все, що контейнер устиг записати, живе в його короткочасному шарі й гине разом із ним. Тому базу, звіти й трейси свідомо виносять назовні — інакше після видалення контейнера їх просто не буде.
З чого складається образ і навіщо потрібні шари (layers)?
Образ зібраний зі стосу незмінних шарів, де кожен крок складання докладає ще один поверх попереднього. Ці шари кешуються й перевикористовуються: якщо два образи стартують з однакового базового node:20, спільний шар фізично лежить один раз. Саме тому послідовність кроків у рецепті так впливає на швидкість — перезбирається лише той шар, що змінився, і все, що йде за ним. Для тестувальника це не теорія, а хвилини на кожному прогоні CI: акуратно вибудуваний кеш перетворює двохвилинне складання на кількасекундне.
Чим контейнер відрізняється від віртуальної машини (virtual machine, VM)?
Різниця в тому, що саме ізолюється. VM піднімає через гіпервізор (hypervisor) повноцінну гостьову операційну систему з окремим ядром (guest OS kernel), тому важить гігабайти й прогрівається десятки секунд. Контейнер натомість спирається на ядро (kernel) хоста, а ізоляцію забезпечують вбудовані підсистеми Linux: namespaces розділяють видимість процесів, мережі й файлової системи, а cgroups нарізають ліміти на CPU та пам'ять. Виходить не окрема ОС, а лише відгороджений процес, який вважає, що машина належить йому одному. Звідси й вигоди: мегабайти замість гігабайтів, старт за секунди, майже непомітні накладні витрати; розплата — тонша межа безпеки, бо ядро одне на всіх.
Docker на macOS часто називають «не нативним». Що мають на увазі?
Контейнеризація виросла з ядра Linux, тож без Linux-ядра контейнерів не існує. На macOS чи Windows Docker Desktop нишком тримає компактну Linux-віртуалку й уже в ній ганяє контейнери. Тобто між кодом на вашому маку й контейнером завжди стоїть невидимий шар VM. На практиці це проявляється двома речами: пригальмованим вводом-виводом (файли перетинають межу віртуалки) і частиною відмінностей у поведінці проти Linux-агента CI. Повністю нативним Docker буває тільки під Linux.
Що описує Dockerfile і які інструкції треба знати напам'ять?
Dockerfile — це текстовий рецепт образу: набір директив, які складач читає й виконує по черзі, згори донизу. Обов'язковий мінімум, який питають: FROM вказує базовий образ, від якого відштовхуєтеся (з чистого аркуша будують украй рідко); WORKDIR призначає робочу теку всередині; COPY переносить файли з контексту складання в образ; RUN проганяє команду на етапі складання, і її результат осідає новим шаром; CMD задає команду, що піднімається вже при старті контейнера. Розуміння того, що робить кожна з цих п'яти й у який момент, і є та межа між знанням зі статті й реальним досвідом.
У чому принципова різниця між RUN і CMD?
Вони спрацьовують у різні фази життя образу. RUN відпрацьовує на етапі складання (docker build): ним ставлять залежності, компілюють, готують файлову систему, і кожен RUN фіксується окремим кешованим шаром. CMD під час складання не запускається зовсім — це стартова команда, що оживає, коли ви піднімаєте контейнер (docker run). Діючий CMD в образі рівно один: остання з таких інструкцій витісняє попередні. Проста перевірка розуміння: RUN npm ci покладе пакети в образ раз і назавжди, а CMD ["npx","playwright","test"] вистрелюватиме щоразу при старті контейнера з цього образу.
Чому в Dockerfile залежності копіюють і встановлюють до коду застосунку?
Це прямий висновок із кешування шарів. Спочатку в образ кладуть лише package.json із лок-файлом, ставлять залежності, і тільки після цього доливають решту коду. Логіка проста: залежності оновлюються нечасто, а код тесту — мало не при кожному коміті. Поставте COPY . . перед npm ci — і будь-яка дрібниця в тесті скине шар копіювання й усе за ним, тобто змусить наново тягнути весь node_modules. Правильна черга лишає важкий шар із залежностями в кеші, поки не зміниться сам лок-файл, і рятує хвилини на кожному складанні.
Які команди Docker найцінніші для QA під час дебагу?
Двома командами тестувальник орудує найчастіше. docker logs <container> віддає stdout/stderr контейнера — саме туди дивишся першим ділом, коли тести всередині впали, бо весь вивід ранера там. docker exec -it <container> sh закидає вас усередину живого контейнера, ніби по ssh: можна глянути, чи потрібний файл реально на місці, який набір шрифтів у системі, що застосунок бачить у своїх змінних середовища. Разом вони переводять розбір із «здогадуюся, чого впало» у «зайшов і подивився». Поряд стають у пригоді docker ps (що крутиться зараз, а з -a — і зупинене) та docker build/docker run для складання й запуску.
Що означає -p 8080:3000 у docker run і в якому порядку читати числа?
У контейнера окремий мережевий стек, тож слухаючий порт застосунку залишається невидимим іззовні, доки ви його не відкриєте явно прапорцем -p. Порядок аргументів регулярно плутають: зліва стоїть порт хост-машини, справа — порт усередині контейнера. Тобто -p 8080:3000 означає, що звернення на localhost:8080 вашого ноутбука перекидається на порт 3000 контейнера. Поміняєте місцями — і назовні відкриється не той порт, а застосунок здаватиметься «непідйомним», хоча всередині живий-здоровий.
Що таке volume і навіщо він тестувальнику?
Записуваний шар живе рівно стільки, скільки сам контейнер, тому дані, яким треба його пережити, кладуть у volume. Для QA головний різновид — bind mount (-v /шлях/на/хості:/шлях/у/контейнері): він прокидає конкретну теку хоста прямо всередину. Саме так забирають артефакти прогону: підключіть ./playwright-report і ./test-results, і після падіння звіт, скриншоти й трейси осядуть на диску, а не щезнуть із контейнером. Другий різновид — named volume, сховище під орудою самого Docker, яким тримають те, що має пережити перезапуск, наприклад файли бази. Класична помилка новачка без монтування: тест упав, ви тягнетеся за трейсом — а контейнера вже нема, разом із ним пропали й артефакти.
Що таке docker compose і яку проблему він розв'язує?
Справжній e2e-прогін рідко вкладається в один контейнер: потрібні щонайменше застосунок, його база й самі тести. Піднімати їх поодинці, вручну зшиваючи мережу й черговість старту, — суцільна морока. docker compose — це декларативний YAML, де перелічені сервіси разом з їхніми зв'язками, мережею та залежностями, і весь стенд встає однією командою docker compose up. Зиск для QA прямий: увесь стенд лежить одним файлом у репозиторії й піднімається однаково — що на машині розробника, що на агенті пайплайна, — тож «у мене працює» більше не аргумент.
Як сервіси в docker compose знаходять одне одного по мережі?
Compose збирає всі сервіси в одну спільну мережу, де кожен доступний за назвою сервісу, наче за доменом. Тому застосунок стукає в базу не за IP, а за db:5432, а тести в застосунок — за http://app:3000, де db і app — просто імена сервісів із YAML. Прописувати адреси вручну не доводиться. Головна засідка: localhost усередині мережі compose для будь-якого сервіса означає його самого, а не сусіда — тож тести, що ломляться на localhost:3000, застосунку в сусідньому контейнері не побачать.
У compose тести падають з «connection refused» на старті. Що не так?
Майже завжди винне те, що depends_on регулює лише черговість старту, а не готовність. Контейнер бази може «стартувати» за півсекунди, але сама СУБД усередині ще кілька секунд розкручується, і тести, що вискочили одразу за ним, ловлять «connection refused». Ліки — не роздутий випадковий sleep, а healthcheck на сервісі бази плюс condition: service_healthy у залежному сервісі: тоді застосунок і тести терпляче дочекаються, поки база справді почне відповідати. На співбесіді про це питають саме тому, що тут спотикаються постійно, змішуючи «контейнер піднявся» з «сервіс готовий».
Навіщо потрібен офіційний образ Playwright і чому його версія має збігатися з бібліотекою?
Браузери тягнуть за собою десятки системних бібліотек для рендерингу, звуку й шрифтів, і зводити їх докупи на порожньому Linux — заняття невдячне. Офіційний образ Playwright уже несе Chromium, Firefox і WebKit з усіма залежностями та стартовим набором шрифтів, підігнаними під конкретну версію. Критичне правило: тег образу (скажімо, v1.49.0-jammy, де jammy — це Ubuntu 22.04) має відповідати версії @playwright/test у вашому package.json. Кожен випуск зав'язаний на конкретні збірки браузерів, тож розбіжність бібліотеки й образу обертається то химерними падіннями, то повною відмовою запуститися. З того ж міркування в CI прибивають точну версію, а не тег latest, бо latest сьогодні одне, а завтра інше — чиста недетермінованість.
Навіщо взагалі ганяти тести в Docker усередині CI?
Docker знімає невизначеність оточення — головного винуватця «локально зелене, у CI червоне». Один образ замикає в собі геть усе: таймзону й локаль, шрифти та системні бібліотеки, збірки браузерів і саму версію Node; на будь-якій машині він відтворює той самий результат (відтворюваність). Кожен запуск починається з чистого незмінного образу, без залишків минулих ранів (ізоляція). Підняти Postgres чи Redis під інтеграційні тести — рядок у конфізі (інфраструктура на льоту). Один і той самий образ проходить dev, CI і staging, тож поведінка не розповзається (паритет середовищ). А кеш шарів помітно вкорочує пайплайн. Формулювання, яке чекають на інтерв'ю: Docker усуває розбіжність оточень, через яку тест червоніє виключно в CI.
Скриншотний тест червоніє лише в CI, а локально зелений. Чому?
Скриншотні (візуальні) тести звіряють знімок сторінки з еталоном (baseline) попіксельно, а те, як намальовано текст, залежить від наявних у системі шрифтів і від алгоритму згладжування гліфів (антиаліасинг). У macOS свій комплект шрифтів і власна логіка згладжування, у Linux-контейнері CI — геть інші, а деякого шрифту може не знайтися взагалі. Тому еталон, знятий на маку, майже гарантовано «попливе» в контейнері: інша ширина рядків, інші обриси літер, інші краї. Це не дефект застосунку й не флак у звичному розумінні — це відмінність середовищ рендерингу. Вихід один: народжувати й оновлювати baseline у тому самому Docker-образі, що й CI (проганяючи --update-snapshots усередині контейнера), і ніколи не комітити знімок, зроблений на власному маку чи Windows.
Тест зелений локально, але червоний у CI. З чого починати діагностику, перш ніж заводити баг на продукт?
Найперше — припустити не поломку застосунку, а розбіжність оточень: це найчастіша причина. Звіртеся, чи однакові версія браузера, локаль, таймзона й набір шрифтів між локальним запуском і контейнером CI; часто-густо ваш прогін і контейнер — просто різні планети. Далі корисно залізти в контейнер через docker exec і подивитися зсередини очима застосунку: змінні середовища, наявність файлів, встановлені шрифти. І лише переконавшись, що середовища тотожні, а поведінка все одно різниться, є сенс говорити про дефект продукту. Дисципліна та сама, що й усюди в діагностиці: не називати причину, поки під нею немає доказу.
Три кейси з робочого життя AQA, де Docker вирішує, чи буде прогін відтворюваним: складання образу з тестами так, щоб кеш шарів не перебудовувався щоразу; повний e2e-стенд у compose, де тести не стартують раніше за готовність бази; і скриншотний тест, що зелений локально й червоний у CI. Скрізь — що робити і чому саме так.
Кейс 1. Dockerfile для Playwright-тестів: порядок інструкцій вирішує швидкість
Задача проста: зібрати образ, який ганяє наш набір тестів, і забрати звіт на хост після падіння. Різниця між «наївним» і «правильним» Dockerfile — не в результаті складання, а в тому, скільки триває кожне наступне складання після дрібної правки тесту.
Спершу як роблять на автоматі — і чому це коштує хвилин:
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
WORKDIR /app
COPY . .
RUN npm ci
CMD ["npx", "playwright", "test"]
Тут COPY . . кладе в образ увесь код разом із package.json одним шаром. Правите один рядок у будь-якому тесті — цей шар інвалідується, а разом із ним і всі наступні, включно з важким RUN npm ci. Docker чесно перевстановлює весь node_modules з нуля, хоча залежності не змінилися.
Тепер правильний порядок — залежності окремим шаром до коду:
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
WORKDIR /app
# спершу тільки маніфести — цей шар кешується, поки не зміняться залежності
COPY package.json package-lock.json ./
RUN npm ci
# і лише потім решта коду — правки тестів не чіпають шар із npm ci
COPY . .
CMD ["npx", "playwright", "test"]
Складаємо й запускаємо, монтуючи звіт назовні:
# крапка в кінці — це контекст складання (поточна директорія)
docker build -t my-tests:1.0 .
# --rm приберемо контейнер після зупинки; -v витягне звіт на хост
docker run --rm -v $(pwd)/playwright-report:/app/playwright-report my-tests:1.0
Що дивитися і чому:
- Порядок
COPY— це не косметика, а кеш. Залежності міняються рідко, код тесту — щокоміта. ТримаючиRUN npm ciвище заCOPY . ., ви лишаєте важкий шар недоторканим, поки не зміниться самpackage-lock.json. Складання падає з двох хвилин до кількох секунд на кожному прогоні CI. - Версія образу мусить збігатися з бібліотекою. Тут образ
v1.49.0, тож і@playwright/testуpackage.jsonмає бути1.49.x. Не зійдуться версії — браузер і бібліотека не узгодяться, і ви дістанете або химерні падіння, або відмову запуститися взагалі. І ніякого:latestу CI: сьогодні це одна збірка, завтра інша. - Без
-vартефакти помруть із контейнером. Записуваний шар ефемерний:--rmприбере контейнер, і разом із ним зникнуть звіт, скриншоти й трейси, якщо їх не змонтувати назовні ще до запуску. Змонтований./playwright-reportлишиться на диску навіть після падіння.
Кейс 2. docker compose: застосунок, база і тести, які дочекалися готовності
Один контейнер — рідко вся картина. Для e2e потрібні щонайменше троє: сам застосунок, його база й тести. Опишемо стенд одним файлом, який однаково підніметься локально й на агенті CI.
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
Піднімаємо весь стенд однією командою:
docker compose up --abort-on-container-exit
Тепер типова помилка, на якій «горять» постійно. Приберіть у сервісу app блок condition: service_healthy, лишивши голий depends_on: [db], — і на старті прогону тести отримають:
Error: connect ECONNREFUSED db:5432
Що дивитися і чому:
- Сервіси знаходять одне одного за іменем, не за IP. У
DATABASE_URLстоїтьdb:5432, уBASE_URL—http://app:3000. Compose кладе всіх у спільну мережу, де ім'я сервісу працює як домен. Жодних IP прописувати не треба, аlocalhostусередині кожного контейнера означав би сам контейнер, а не сусіда. depends_on— це порядок старту, а не готовність. Контейнерpostgres«піднімається» за півсекунди, але сама СУБД ще ініціалізується кілька секунд. Без healthcheck тести стартують у цю щілину й ловлятьconnection refused. Лікує самеhealthcheck(тутpg_isready) плюсcondition: service_healthy, а не випадковийsleep 5.- Той самий файл — і локально, і в CI. Стенд лежить у репозиторії, тож розробник і пайплайн піднімають ідентичне оточення. Аргумент «у мене працює» тут просто нема куди подіти — середовище одне.
Кейс 3. Скриншотний тест зелений локально, червоний у CI: винні шрифти
Улюблене питання співбесіди і водночас реальна ситуація. Візуальний тест звіряє рендер сторінки з еталоном (baseline) піксель-у-піксель:
import { test, expect } from '@playwright/test';
test('картка товару збігається з еталоном', async ({ page }) => {
await page.goto('/product/42');
await expect(page.getByTestId('product-card')).toHaveScreenshot('card.png');
});
Локально на маку тест зелений. Той самий код у контейнері CI дає діф на кожному рядку тексту, хоча застосунок ніхто не чіпав. У звіті видно, що контури літер і ширина рядків «поплили».
Причина не в коді, а в середовищі рендерингу. Baseline знімали на macOS з її набором шрифтів і власною логікою згладжування гліфів (антиаліасинг). Linux-контейнер CI малює тими самими гліфами інакше, бо шрифти й згладжування в нього свої, а потрібного шрифту може й зовсім не виявитися — тоді замість літер вилазять «квадратики». Playwright знає про це й дописує до імені еталона суфікс платформи, тож знімки не змішуються:
card-darwin.png ← знято локально на macOS
card-linux.png ← потрібно для контейнера CI
Правильний фікс — оновити еталон у тому ж образі, що й CI, а не на своєму ноутбуці:
# оновлюємо baseline усередині Docker-образу CI, а не на macOS
docker run --rm \
-v $(pwd)/tests:/app/tests \
my-tests:1.0 \
npx playwright test --update-snapshots
Що дивитися і чому:
- Це не баг застосунку і не звичайний флак. Діф стабільний і відтворюваний — він завжди з'являється в Linux і ніколи локально. Заводити дефект на продукт тут неправильно: різниця в тому, чим ОС малює текст, а не в тому, що показує застосунок.
- Суфікс платформи — запобіжник, а не розв'язання.
-linux.pngпроти-darwin.pngлише не дає знімкам змішатися. Сам по собі він не робить порівняння детермінованим — для цього еталон має народитися в тому ж середовищі, де перевіряється. - Локальний baseline не комітять. Правило просте:
--update-snapshotsзапускають усередині контейнера CI, а знімок, знятий на власному маку чи Windows, у репозиторій не потрапляє. Тут Docker — не зручність, а єдиний спосіб зробити скриншот-порівняння відтворюваним.
Образ, контейнер, шари
- Можу пояснити різницю образ vs контейнер: образ — незмінний тільки-для-читання шаблон, контейнер — його запущений екземпляр із записуваним шаром (аналогія клас/об'єкт), і з одного образу піднімають скільки завгодно контейнерів.
- Розумію, що записуваний шар контейнера ефемерний: усе записане зникає при видаленні контейнера, тож цінне треба виносити у volume.
- Знаю, що образ складається з кешованих шарів (layers), однакові шари перевикористовуються, а перебудовується лише змінений шар і все за ним.
Контейнер проти віртуальної машини
- Можу пояснити, чому контейнер легший за VM: він ділить ядро хоста, а VM несе повну гостьову ОС із власним ядром — звідси мегабайти замість гігабайтів і старт за секунди.
- Знаю, що ізоляцію контейнера дають механізми ядра Linux —
namespaces(окремий погляд на процеси/мережу/ФС) іcgroups(ліміти CPU/пам'яті), а платою за спільне ядро є слабша межа безпеки. - Можу пояснити, чому Docker на macOS/Windows «не нативний»: під капотом крутиться прихована Linux-VM, звідси повільніший I/O і частина розбіжностей із Linux-CI.
Dockerfile і кеш шарів
- Знаю п'ять базових інструкцій:
FROM,WORKDIR,COPY,RUN,CMD— і що кожна робить. - Можу пояснити різницю
RUNvsCMD:RUNвиконується під час складання й стає шаром,CMD— команда за замовчуванням при запуску контейнера (і діючийCMDодин — остання інструкція перемагає). - Розумію, чому залежності копіюють і ставлять до коду: інакше будь-яка правка тесту інвалідує кеш і змушує перевстановлювати
node_modules.
Команди й дебаг
- Знаю мінімальний набір (
docker build,run,ps,logs,exec) і ключові прапорціdocker run:-d(фон),-e(змінна),--name,--rm,-p(порт). - Можу пояснити, чому
docker logs— перше місце для дебагу впалих тестів, аdocker exec -it <container> sh— спосіб зазирнути всередину живого контейнера.
Порти і volume
- Можу пояснити порядок у
-p host:container(ліворуч порт хоста, праворуч порт контейнера) і що порт застосунку ззовні не видно, поки його явно не опублікувати. - Знаю різницю bind mount vs named volume: перший прокидає директорію хоста (так витягують артефакти тестів), другий — сховище під керуванням Docker (так тримають дані бази).
- Розумію, чому артефакти (звіт, скриншоти, трейси) треба монтувати ще до запуску, інакше вони помруть разом із контейнером.
docker compose
- Знаю, що сервіси в compose бачать одне одного за іменем сервісу (
db:5432,http://app:3000), а не за IP, і щоlocalhostусередині мережі compose — це сам сервіс, а не сусід. - Можу пояснити, чому
depends_onне гарантує готовності: він керує лише порядком старту, а справжню готовність (протиconnection refusedна старті) даєhealthcheck+condition: service_healthy.
Готові образи й Docker у CI
- Знаю правило збігу версій: версія образу Playwright мусить дорівнювати версії
@playwright/test, інакше — від дивних падінь до відмови стартувати; у CI фіксують точну версію, а неlatest. - Можу перелічити, навіщо Docker у CI: відтворюваність, ізоляція, інфраструктура на льоту, паритет середовищ, швидкість через кеш.
Скриншотні тести і шрифти
- Можу пояснити, чому візуальний baseline з macOS «розсипається» в Linux-контейнері (інші шрифти й згладжування гліфів) і що це різниця середовищ рендерингу, а не баг застосунку чи флак.
- Знаю правильний підхід: генерувати й оновлювати еталони (
--update-snapshots) у тому ж образі, що й CI, не комітити локальні знімки, а суфікс платформи (-linux.png/-darwin.png) — лише запобіжник від змішування.
Чим образ (image) відрізняється від контейнера (container)?
Питання
Образ (image) проти контейнера (container) — у чому різниця?