Заняття 09 · місяць 3

Тести й оновлення однією командою

90 хвилин

Мета заняття: оновлювати сервіс, не перевіряючи щоразу все руками. Тести показують, що саме зламалося, а один скрипт ставить нову версію за кілька секунд.

Мій сайт
http://91.219.61.4/u/<логін>/
Мій кабінет
http://91.219.61.4/k/<логін>/
Вхід на сервер
ssh <логін>@91.219.61.4

Замість <логін> підставляєш свій логін виду bohdan.g9: ім’я, крапка, номер групи. Кутові дужки не пишуться. Кабінет — сторінка, де проти кожного критерію стоїть відмітка, зарахований він чи ні. У прикладах на цій сторінці скрізь написано bohdan.g9 — читай це як свій логін і виправляй у кожній команді, яку копіюєш.

Слова, якими користуватимемось

Усі ці речі ти вже робив на заняттях 02–08, але окремо ці слова не пояснювали. Далі на сторінці вони трапляються постійно, тому спочатку — що кожне з них означає. Сюди можна повертатися по ходу заняття.

API
Набір адрес, за якими твоя програма приймає й віддає дані, а не готову сторінку. У твоєму сервісі це адреси, що починаються з /api/.
Ендпоінт
Одна адреса API разом зі способом звернення. GET /api/habits віддає список, POST /api/habits додає запис, DELETE /api/habits/3 видаляє запис із номером 3. Це три різні ендпоінти, хоча адреса в перших двох однакова.
Декоратор
Рядок, що починається з @ і стоїть просто над функцією: @app.get("/api/habits"). Він каже FastAPI, на яке звернення відповідає ця функція. Список ендпоінтів твого застосунку задають саме декоратори в main.py.
JSON
Спосіб записати дані текстом. {"id": 1, "title": "Читати"} — це об’єкт із двома полями, у Python він стає словником; […] — перелік записів, у Python список, у розмові про JSON його часто називають масивом. Твоє API приймає й віддає саме JSON.
Код стану (HTTP-код)
Тризначне число, яке сервер ставить на початок кожної відповіді: воно каже, чим закінчився запит. Ті, що трапляються сьогодні: 200 — усе гаразд; 201 — запис створено; 401 — ти не увійшов; 403 — увійшов, але не маєш права на цю дію; 404 — за такою адресою нічого немає; 422 — дані в запиті не пройшли перевірку; 500 — програма впала, не встигнувши відповісти; 502 — nginx не отримав відповіді від служби.
nginx
Програма на сервері, яка приймає всі запити з інтернету й за початком адреси вирішує, кому їх передати: /u/bohdan.g9/ — твоїй службі, /u/olya.g9/ — службі Олі. Твій застосунок про неї нічого не знає.
Порт
Число, за яким на одному сервері розрізняють програми, що приймають запити. Порт у кожного учня свій, і nginx знає, на який саме порт передавати запити з твоєї адреси.
Служба, systemd
Служба — програма, яку сервер тримає запущеною постійно: піднімає її після перезавантаження й після падіння. Твоя служба називається club-api. systemd — та частина Linux, яка службами керує; команди до неї мають вигляд systemctl --user restart club-api, де --user означає «моя власна служба, не загальносистемна».
unit-файл
Текстовий файл з описом служби: що запускати, з якої теки, з якими обмеженнями. Твій лежить у ~/.config/systemd/user/club-api.service. Там же записаний твій порт.
Кука
Короткий рядок, який сервіс видає браузерові після вдалого входу, а браузер сам підставляє до кожного наступного запиту. За кукою сервіс упізнає, хто до нього звертається, і не питає пароль щоразу.
Коміт
Збережений у git стан твоїх файлів, з описом і часом. git commit створює коміт у твоїй теці, git push надсилає коміти в репозиторій ~/repo.git, git pull забирає їх звідти назад.
Оточення (venv)
Окрема тека з Python і встановленими бібліотеками. У нас воно одне на всю групу — /opt/club/venv, — тому команди пишуться повним шляхом /opt/club/venv/bin/python, а не просто python.
Чекер
Програма, яка за розкладом заходить на сервер, виконує ті самі команди, що й ти на практиці, і ставить у кабінеті відмітку проти кожного критерію: зараховано чи ні.

Що ти зробиш сьогодні

Ти напишеш п’ять тестів — коротких функцій, які самі звертаються до твого API і звіряють відповідь із очікуваною: список повертається, запис додається, порожня назва відхиляється, сторонній нічого не видаляє, звернення до неіснуючого запису дає 404. Замість того щоб після кожної правки відкривати сторінку й клікати по формі, ти запускатимеш одну команду і за секунду бачитимеш, що зламалося.

Друга частина — файл deploy.sh. У ньому будуть записані всі кроки оновлення сервісу: забрати коміти, запустити тести, перезапустити службу, перевірити відповідь. Після цього перехід на нову версію — це одна команда ./deploy.sh. Якщо хоча б один тест не проходить, скрипт зупиняється й нову версію не ставить.

Перші 15 хвилин заняття — розбір заняття 08 Сусід, який шукав баги у твоєму сервісі, відкриває твій BUGS.md і перевіряє, чи справді ці баги закрито. Якщо так — ставить підпис у файлі. Це критерій L08.4, і зараховує його людина, а не чекер. Хто не встиг закрити баги вдома — доробляє зараз, до початку теорії.

Теорія

1. Чому «я перевірив руками» перестає працювати

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

Складність у тому, що перевіряти доводиться не один раз, а після кожної правки. Причому перевіряти треба не лише те, що ти щойно змінив. Ось як це виглядає насправді. Ти правиш один рядок у функції, яка готує список, і не помічаєш, що тепер вона повертає записи без поля id. Кнопка «видалити» бере id саме звідти — і видалення перестає працювати, хоча ти його не чіпав. У браузері ти дивишся тільки на список, бо список і правив. Помилку знаходить хтось інший через тиждень.

Повна ручна перевірка всіх п’яти дій займає хвилини три. Правок за заняття буває п’ятнадцять. Ніхто не робить п’ятнадцять разів по три хвилини — тому перевіряють лише змінене, і решта поступово ламається непомітно.

Тест
Функція, яка сама виконує дію в програмі й перевіряє, що результат саме такий, як очікується. Якщо результат інший — тест повідомляє про це і показує, чим фактичне значення відрізняється від очікуваного. Про такий тест кажуть «тест упав» або «тест не пройшов»; далі на сторінці це слово вживається саме в цьому значенні.
assert
Команда Python, що означає «далі має бути правда». Рядок assert 2 + 2 == 4 не робить нічого й виконання йде далі. Рядок assert 2 + 2 == 5 зупиняє функцію з помилкою AssertionError. Тести складаються переважно з таких рядків.
pytest
Програма, яка сама знаходить у вказаній теці всі файли з іменами виду test_*.py, запускає в них усі функції, чиї імена починаються з test_, і друкує підсумок: скільки функцій пройшло, скільки впало і на якому саме рядку. Запускати тести по одному руками не треба.

Порівняння з тим, що ти вже робив: коли вчитель перевіряє контрольну за ключем із відповідями, він не розв’язує задачу заново — звіряє твій результат із очікуваним. Рядок assert робить те саме, тільки за мілісекунди й без участі людини.

Питання на розуміння

Оля виправила помилку в ендпоінті видалення, перевірила видалення в браузері — працює, зробила коміт. Наступного дня виявилося, що зламалося додавання запису: воно перестало працювати саме після тієї правки. Що з переліченого помітило б це ще до коміта?

2. Як влаштований тест на pytest

Тести лежать в окремій теці tests усередині ~/app. Це звичайні файли Python, тільки з двома правилами іменування: файл має називатися test_щось.py, а функція всередині — починатися з test_. Функцію з іншим іменем pytest просто не запустить і навіть не поскаржиться.

Щоб перевірити ендпоінт, тесту треба надіслати HTTP-запит. Можна було б робити це через мережу, командою на кшталт curl, але тоді тест залежав би від того, чи запущена служба, чи вільний порт, чи працює nginx. FastAPI дає простіший інструмент.

Тестовий клієнт (TestClient)
Об’єкт, який приймає запит і передає його твоєму застосунку напряму, у тому самому процесі Python, а повертає ту саму відповідь, що прийшла б через мережу: код стану, тіло, заголовки. Служба для цього не потрібна — тести працюють навіть тоді, коли club-api зупинена.
Фікстура
Функція, яка готує щось потрібне для тесту й віддає це тестові. Наприклад, фікстура owner віддає тестовий клієнт, який уже має куку власника. Тесту не треба вводити логін і пароль — він одразу пише owner.post(...).
conftest.py
Файл, у якому лежать фікстури. pytest знаходить його сам і підставляє фікстури в усі тести з тієї самої теки. Імпортувати цей файл у тестах не треба, і саме тому його ім’я має бути записане точно так.

Щоб отримати фікстуру, тест згадує її ім’я серед своїх параметрів:

def test_list_returns_200(guest):
    response = guest.get("/api/habits")
    assert response.status_code == 200

Тут guest — не звичайний аргумент, який ти передаєш сам. Ти ніде не викликаєш test_list_returns_200(...): функцію викликає pytest. Він бачить у параметрах ім’я guest, знаходить фікстуру з таким іменем у conftest.py, виконує її і підставляє результат.

Сенс фікстур простий: підготовка перед тестами в усіх п’яти функціях однакова. Замість чотирьох однакових рядків у кожній із п’яти функцій ти пишеш їх один раз у conftest.py.

Адреси в тестах пишуться без /u/bohdan.g9/ Тестовий клієнт звертається до застосунку напряму, а частину /u/bohdan.g9/ додає nginx уже дорогою через мережу. Усередині твого застосунку такого шляху не існує, там ендпоінт називається просто /api/habits. Якщо написати в тесті повну адресу з браузера, отримаєш 404, хоча в браузері та сама адреса відкривається.
Питання на розуміння

Максим написав тест, у якому тестовий клієнт звертається до /u/maksym.g9/api/habits, і отримав 404. У браузері та сама адреса відкривається і показує список. У чому причина?

3. Тести не повинні псувати справжні дані

Тест, який додає запис, справді його додає. Якщо тести працюють з тією самою базою, що й сервіс, то після десяти запусків у твоєму списку буде десять записів «Тестовий запис», і на демо-дні їх побачать усі гості.

Є й друга причина, менш очевидна. Тест «запис додається» рахує записи до і після. Якщо база щоразу інша, результат тесту залежить від того, скільки разів ти вже його запускав. Тест, який сьогодні проходить, а завтра ні, без жодної зміни коду, нічого не показує: за його результатом не можна судити про код, тому такий тест швидко перестають запускати.

Тому перед запуском тестів файл бази копіюють у тимчасову теку, і тести працюють з копією. Справжня база при цьому не змінюється, а кожен запуск починається з однакового стану.

Технічно це один рядок. db.py бере шлях до файлу бази зі змінної DB_PATH. Файл conftest.py робить копію й записує в DB_PATH шлях до неї — до того, як буде виконано перший запит. Далі всі запити йдуть у копію, бо db.py дивиться на цю змінну щоразу, коли відкриває з’єднання. Писати цей код тобі не треба, він уже є в заготовці; треба розуміти, що він там робить.

Питання на розуміння

Данило запустив тести п’ять разів поспіль. Першого разу тест «запис додається» пройшов, а далі почав падати: у списку виявлялося більше записів, ніж він очікував. Як це правильно виправити?

4. Деплой: кроки, записані у файл

Деплой (викочування)
Перенести нову версію коду туди, де нею користуються, і зробити так, щоб працювала саме вона. У тебе це чотири дії: забрати коміти з ~/repo.git, запустити тести, перезапустити службу, переконатися, що сервіс відповідає.
git pull
Забирає нові коміти з віддаленого репозиторію й додає їх у твою робочу теку. Віддалений репозиторій у нашому курсі — це ~/repo.git, тека на цьому самому сервері, яку ти налаштував на занятті 03. Не GitHub.
Залежності
Сторонні бібліотеки, без яких проєкт не запуститься: fastapi, uvicorn, pyyaml, bcrypt. У нас вони стоять у спільному оточенні /opt/club/venv, однаковому для всієї групи, тому кроку встановлення в нашому deploy.sh не буде. У проєктах поза курсом такий крок зазвичай є: там оточення в кожного своє.
deploy.sh
Текстовий файл із командами, які виконуються згори вниз — так само, ніби ти набрав їх у терміналі одну за одною. Розширення .sh означає, що виконує їх bash, та сама програма, що приймає твої команди в SSH.

Зараз ти оновлюєш сервіс руками: чотири-п’ять команд, які треба пам’ятати в правильному порядку. За тиждень порядок забувається, а перед демо-днем його згадують поспіхом і пропускають крок. Тому кроки записують у файл один раз.

Три налаштування на початку скрипта

Перший змістовний рядок будь-якого скрипта, який щось міняє на сервері, виглядає так:

set -euo pipefail

Це три окремі налаштування, записані разом:

Код виходу
Число, яке команда лишає після себе: 0 означає «виконано без помилок», будь-яке інше число — помилку. Подивитися код останньої команди можна так: echo $?. Саме за цим числом bash розуміє, впала команда чи ні. Коли хоч один тест падає, pytest повертає не 0 — і скрипт із set -e зупиняється саме на цьому рядку.

Порядок рядків у скрипті має значення. Перевірка, яка стоїть після дії, від цієї дії вже не убереже.

Питання на розуміння

У скрипті Софії рядки йдуть у такому порядку: git pull, потім перезапуск служби, потім тести. Тести впали. Що на цей момент уже сталося на сервері?

5. Двічі поспіль — той самий результат

Один із критеріїв заняття звучить так: deploy.sh двічі поспіль дає той самий результат. Це означає: запустив скрипт — він відпрацював до кінця; запустив ще раз одразу — він знову відпрацював до кінця без помилок, і стан сервера після другого запуску такий самий, як після першого.

Властивість «повторний запуск нічого не псує й лишає той самий стан» називають ідемпотентністю. Слово довге, ідея проста: команду можна виконати двічі, і другий раз нічого не змінить.

Різниця видна на двох прикладах. git pull, коли нових комітів немає, друкує Already up to date. і виходить з кодом 0 — стан не змінився. А рядок echo "готово" >> version.txt при кожному запуску дописує рядок у файл: після трьох запусків у файлі три рядки, і результат щоразу інший.

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

Наш скрипт ідемпотентний: git pull без нових комітів нічого не змінює, тести щоразу працюють з новою копією бази, перезапуск служби піднімає той самий код, перевірка тільки читає відповідь. Тому його можна запускати скільки завгодно разів поспіль.

Питання на розуміння

Богдан поправив main.py і не закомітив правку. У ~/repo.git тим часом уже лежав новий коміт, який змінює той самий файл. Богдан запустив ./deploy.sh — скрипт зупинився на першому кроці з повідомленням error: Your local changes to the following files would be overwritten by merge. Що це означає?

Практика

У прикладах — таблиця habit і ендпоінти /api/habits із занять 04 і 05. Підставляй свої назви: вони записані у project.yaml у розділах database і endpoints.

1

Перевірити, що все на місці

ssh bohdan.g9@91.219.61.4
cd ~/app
git status --short
/opt/club/venv/bin/python -m pytest --version
/opt/club/venv/bin/python -c "import httpx; print('httpx на місці')"
grep -n "api/ping" ~/app/main.py
grep -n -- "--port" ~/.config/systemd/user/club-api.service

git status --short має відповісти порожнечею. Якщо там є рядки, у тебе залишилися незакомічені зміни з минулого заняття — закомить їх зараз, інакше вони заважатимуть на кроках 6 і 11.

Команда python -m pytest --version друкує версію, наприклад pytest 8.2.0. Пиши її повним шляхом, як у прикладі: короткої команди pytest у тебе в системі немає, вона є тільки всередині оточення /opt/club/venv. httpx — бібліотека, якою тестовий клієнт надсилає запити всередині процесу; без неї TestClient не працює. Обидві вже стоять у спільному оточенні. Якщо якась із команд відповіла помилкою — скажи викладачеві й не став нічого через pip: оточення спільне для всієї групи, і прав на нього в тебе немає.

Перший grep має показати рядок @app.get("/api/ping"). /api/ping — найпростіший ендпоінт твого сервісу: він з’явився в main.py ще на занятті 02 і повертає {"status": "ok", …}. На кроці 8 скрипт оновлення звертатиметься саме до нього, щоб переконатися, що служба піднялася. Якщо grep нічого не знайшов, ендпоінта немає — поверни його в main.py за заняттям 02, інакше критерій L09.3 не зарахується.

Другий grep покаже рядок ExecStart= з твоїм портом. Подивись, як саме там записано порт: --port 8009 через пропуск чи --port=8009 через знак рівності. Скрипт із кроку 8 розуміє обидва записи, але знати свій варіант корисно. Якщо рядка з --port у файлі немає взагалі — скажи викладачеві.

2

Створити теку для тестів і .gitignore

mkdir -p ~/app/tests
printf '__pycache__/\n*.pyc\n.pytest_cache/\n' > ~/app/.gitignore
cat ~/app/.gitignore
ls -a ~/app

-p означає «не скаржитися, якщо тека вже існує». Завдяки цьому команду можна виконати двічі поспіль без помилки — це та сама ідемпотентність, про яку йшлося в теорії.

Під час запуску Python складає поруч зі своїми файлами теку __pycache__ з проміжними файлами .pyc, а pytest — теку .pytest_cache. Це тимчасові файли, у репозиторії вони не потрібні. Файл .gitignore перелічує те, чого git не має помічати: після нього git status --short не показуватиме зайвих рядків, а git add tests на кроці 7 не додасть у коміт файли .pyc. Сам .gitignore ти закомітиш на кроці 7 — доти git status --short показуватиме рядок ?? .gitignore, і це нормально.

3

Створити tests/conftest.py

Це готова заготовка. Створи файл і встав текст цілком, нічого в ньому не змінюючи.

nano ~/app/tests/conftest.py
"""Підготовка для тестів. Готова заготовка — цей файл не редагуємо.

Робить три речі:
  1) додає ~/app у список тек, де Python шукає модулі, щоб працював import main;
  2) підміняє базу на копію, щоб тести не змінювали справжніх даних;
  3) дає дві фікстури: guest — клієнт без входу, owner — клієнт із кукою власника.
"""
import shutil
import sys
import tempfile
from pathlib import Path

import pytest
import yaml
from fastapi.testclient import TestClient

APP_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(APP_DIR))

import db  # імпорт стоїть нижче за sys.path.insert навмисно

# --- копія бази -------------------------------------------------------
_tmp_db = Path(tempfile.mkdtemp(prefix="club-tests-")) / "test.db"
shutil.copy(db.DB_PATH, _tmp_db)
db.DB_PATH = _tmp_db          # з цієї миті всі запити йдуть у копію

import auth
from main import app

CFG = yaml.safe_load((APP_DIR / "project.yaml").read_text(encoding="utf-8"))


@pytest.fixture
def guest():
    """Клієнт без входу — так твій сервіс бачить стороння людина."""
    with TestClient(app) as client:
        yield client


@pytest.fixture
def owner():
    """Клієнт із кукою власника — пароль у тесті вводити не треба."""
    table = CFG["auth"]["table"]
    rows = db.query(f"SELECT login FROM {table} LIMIT 1")
    assert rows, "у таблиці власника немає жодного рядка, дивись заняття 07"
    with TestClient(app) as client:
        client.cookies.set(auth.COOKIE_NAME, auth.make_session(rows[0]["login"]))
        yield client

Розберемо, що тут відбувається.

sys.path.insert(0, str(APP_DIR)) додає теку ~/app у список тек, де Python шукає модулі. Без цього рядка import main усередині тестів не спрацював би: тести лежать у tests/, а main.py — на теку вище.

Три рядки з _tmp_db — те, про що йшлося в теорії. tempfile.mkdtemp() створює тимчасову теку з випадковим іменем, shutil.copy кладе туди копію файлу бази, а присвоєння db.DB_PATH = _tmp_db перемикає всі подальші запити на цю копію. Справжнього файлу бази тести не відкривають узагалі. Лежить він у теці ~/data/, а ім’я db.py бере з project.yaml, розділ database, поле file: у заготовці там app.db, тобто повний шлях — ~/data/app.db.

yield client усередині фікстури означає: віддати клієнт тестові, дочекатися, поки тест закінчиться, і лише потім закрити його. Рядки після yield виконуються після тесту — тут це робить сам with.

У фікстурі owner назва таблиці підставляється в текст запиту через f-рядок — рядок із літерою f перед лапками, у якому вираз у фігурних дужках замінюється своїм значенням: f"SELECT login FROM {table}" при table = "owner" перетворюється на SELECT login FROM owner. Назву таблиці не можна передати параметром, як передають значення: параметром передаються дані, а назва таблиці — частина самого тексту запиту. Небезпеки тут немає, бо назва береться з твого ж project.yaml, а не з поля вводу на сторінці — порівняй із тим, що розбирали на занятті 08.

auth.make_session і auth.COOKIE_NAME — з файлу auth.py, який ти отримав на занятті 07. Фікстура бере логін власника з бази й сама складає куку, тому запит на /api/login у тестах не потрібен.

4

Перший тест: список повертається

nano ~/app/tests/test_api.py
"""Тести API. Запуск із ~/app:
    /opt/club/venv/bin/python -m pytest -q tests
"""

LIST = "/api/habits"      # підстав свою адресу з project.yaml, розділ endpoints


def test_list_returns_200(guest):
    """Список відкривається без входу і повертає список записів."""
    response = guest.get(LIST)
    assert response.status_code == 200
    assert isinstance(response.json(), list)

Адреса винесена в змінну LIST угорі файлу: вона потрібна в чотирьох тестах із п’яти, і виправляти її потім доведеться в одному місці, а не в чотирьох.

response.json() розбирає JSON з відповіді й повертає звичайні об’єкти Python. isinstance(response.json(), list) перевіряє, що це саме список записів, а не, наприклад, словник з описом помилки.

Запусти:

cd ~/app
/opt/club/venv/bin/python -m pytest -q tests

Очікуваний вивід:

.                                                     [100%]
1 passed in 0.42s

Крапка — один пройдений тест. -q (від quiet) друкує коротко: без цього ключа pytest виводить рядок на кожен тест і шапку з версіями. Запускай саме python -m pytest, а не просто pytest: перший варіант додає поточну теку в шлях пошуку модулів, і імпорти працюють передбачувано.

5

Ще чотири тести

Дописуй у той самий файл, під першим тестом.

def test_owner_creates_item(owner):
    """Власник додає запис — він з'являється у списку."""
    before = len(owner.get(LIST).json())
    response = owner.post(LIST, json={"title": "Тестовий запис"})
    assert response.status_code in (200, 201)
    after = owner.get(LIST).json()
    assert len(after) == before + 1
    assert after[0]["title"] == "Тестовий запис"


def test_empty_title_rejected(owner):
    """Порожня назва — це помилка в даних, тобто 422, а не 500."""
    response = owner.post(LIST, json={"title": ""})
    assert response.status_code == 422


def test_guest_cannot_delete(guest, owner):
    """Сторонній не видаляє чужий запис."""
    owner.post(LIST, json={"title": "Не видаляти"})
    item_id = owner.get(LIST).json()[0]["id"]
    response = guest.delete(f"{LIST}/{item_id}")
    assert response.status_code == 401


def test_delete_missing_gives_404(owner):
    """Видалення неіснуючого запису — 404."""
    response = owner.delete(f"{LIST}/999999")
    assert response.status_code == 404

Кілька деталей у цьому коді.

after[0] — перший запис у списку. Це працює, якщо в тебе стоїть ORDER BY id DESC, тобто найновіший запис іде першим. Якщо сортування зростаюче, бери after[-1] — останній елемент списку.

status_code in (200, 201) — бо додавання може повертати і 200, і 201: обидва означають успіх. Якщо в тебе в декораторі стоїть status_code=201, залиш у тесті тільки 201, так перевірка буде суворіша.

== 401 у test_guest_cannot_delete — це «ти не увійшов». Частина сервісів у такій ситуації віддає 403, тобто «увійшов, але не маєш права». Подивись у main.py, яке число повертає твій DELETE сторонньому, і постав у тесті саме його.

У test_guest_cannot_delete запис створює owner, а видаляє його намагається guest. Обидві фікстури працюють з однією копією бази, тому запис, доданий одним клієнтом, видно другому.

Число 999999 в останньому тесті — свідомо завелике: такого id у твоїй базі немає й найближчим часом не буде.

Запусти всі п’ять:

/opt/club/venv/bin/python -m pytest -q tests
.....                                                 [100%]
5 passed in 0.71s
6

Подивитися, як виглядає падіння

Тест, який жодного разу не падав, нічого не гарантує: він може взагалі нічого не перевіряти. Зламай код навмисно.

nano ~/app/main.py

У функції, що повертає список, додай у кінець запиту LIMIT 1. У прикладі таблиця називається habit; у тебе назва своя, тому цей рядок не копіюй, а допиши LIMIT 1 у свій власний запит:

return query("SELECT * FROM habit ORDER BY id DESC LIMIT 1")

Тепер список завжди повертає один запис. Запусти тести:

/opt/club/venv/bin/python -m pytest -q tests
.F...                                                 [100%]
=================================== FAILURES ===================================
___________________________ test_owner_creates_item ____________________________

    def test_owner_creates_item(owner):
        before = len(owner.get(LIST).json())
        response = owner.post(LIST, json={"title": "Тестовий запис"})
        assert response.status_code in (200, 201)
        after = owner.get(LIST).json()
>       assert len(after) == before + 1
E       assert 1 == 2

tests/test_api.py:21: AssertionError
1 failed, 4 passed in 0.68s

Читається так: F на місці другого тесту — він упав. Далі pytest друкує саму функцію, позначає стрілкою рядок, на якому спрацював assert, і в рядку з E показує, що саме не збіглося: очікувалося 2 записи, повернувся 1. Передостанній рядок — файл і номер рядка, на якому спрацював assert; останній — підсумок: скільки тестів упало, скільки пройшло.

Ти дізнався про поломку за секунду, не відкриваючи браузер, і одразу знаєш, у якій перевірці справа.

Поверни код на місце:

git checkout -- main.py
/opt/club/venv/bin/python -m pytest -q tests

git checkout -- main.py повертає файл до стану останнього коміта. Усі незбережені зміни в ньому зникають — саме цього ми зараз і хочемо. Тести знову мають дати 5 passed.

7

Закомітити тести

cd ~/app
git add .gitignore tests
git status --short
git commit -m "тести: п’ять перевірок API"
git push origin master

git status --short перед комітом показує, що саме в нього потрапить: тільки .gitignore і файли з tests/. Якщо там видно tests/__pycache__/.gitignore з кроку 2 не створився або лежить не в ~/app.

Тести — такий самий код проєкту, як main.py, і мають лежати в репозиторії. git push надсилає коміти у ~/repo.git. Це потрібно й для критерію заняття 03 «репозиторій містить ті самі коміти», і для наступного кроку: скрипт починається з git pull, і йому має бути звідки забирати.

8

Переписати deploy.sh

Файл deploy.sh у тебе вже є з першого дня, але в заготовці він лише намічений: там є звертання до змінної VENV, якій ніхто не присвоїв значення, і крок встановлення залежностей, непотрібний у нашому спільному оточенні. Перепиши файл цілком.

nano ~/app/deploy.sh
#!/usr/bin/env bash
# Викочування нової версії. Заняття 09.
# Запуск:  ./deploy.sh
set -euo pipefail

VENV=/opt/club/venv
UNIT="$HOME/.config/systemd/user/club-api.service"
PORT=$(grep -oP '(?<=--port[ =])\d+' "$UNIT" || true)

if [ -z "$PORT" ]; then
    echo "ПОМИЛКА: у $UNIT немає рядка з --port"
    exit 1
fi

cd "$(dirname "$0")"

echo "==> 1/4 забираємо коміти з ~/repo.git"
git pull --ff-only origin master

echo "==> 2/4 тести"
"$VENV/bin/python" -m pytest -q tests

echo "==> 3/4 перезапуск служби"
systemctl --user restart club-api || {
    echo "ПОМИЛКА: служба не перезапустилася"
    echo "Дивись:  journalctl --user -u club-api -n 30"
    exit 1
}
sleep 2

echo "==> 4/4 перевірка, що сервіс відповідає"
code=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/api/ping" || true)
if [ "$code" = "200" ]; then
    echo "ГОТОВО: /api/ping відповів 200"
else
    echo "ПОМИЛКА: /api/ping відповів $code"
    echo "Дивись:  journalctl --user -u club-api -n 30"
    exit 1
fi

Що робить кожен рядок:

9

Дозволити запуск і запустити

chmod +x ~/app/deploy.sh
cd ~/app
./deploy.sh

chmod +x ставить файлу ознаку «його можна запускати». Без неї bash відповість Permission denied, навіть якщо всередині все правильно.

./ перед іменем означає «файл у поточній теці». Без крапки зі скісною рискою bash шукатиме команду deploy.sh серед системних програм і не знайде.

Очікуваний вивід:

==> 1/4 забираємо коміти з ~/repo.git
From /home/bohdan.g9/repo
 * branch            master     -> FETCH_HEAD
Already up to date.
==> 2/4 тести
.....                                                 [100%]
5 passed in 0.71s
==> 3/4 перезапуск служби
==> 4/4 перевірка, що сервіс відповідає
ГОТОВО: /api/ping відповів 200
10

Запустити ще раз, одразу

./deploy.sh
echo $?

Вивід має бути такий самий, а echo $? має надрукувати 0. Це і є критерій L09.2: скрипт двічі поспіль дає той самий результат.

Служба перезапускається обидва рази, і це нормально: вона піднімається з тим самим кодом, тому стан сервера після другого запуску не відрізняється від стану після першого. Зміни накопичував би скрипт, який щоразу щось дописує чи створює наново, — у нашому такого немає.

11

Перевірити, що скрипт зупиняється, коли тести не проходять

Це головна перевірка сьогоднішнього заняття. Зламай код ще раз, тим самим способом, і не комітуй зміну.

nano ~/app/main.py

Знову додай LIMIT 1 у запит списку й запусти скрипт:

./deploy.sh
echo $?
==> 1/4 забираємо коміти з ~/repo.git
From /home/bohdan.g9/repo
 * branch            master     -> FETCH_HEAD
Already up to date.
==> 2/4 тести
.F...                                                 [100%]
...
1 failed, 4 passed in 0.66s
1

Скрипт зупинився на другому кроці. Рядків «перезапуск служби» і «перевірка» немає взагалі — до них справа не дійшла. echo $? надрукував 1, тобто помилку.

git pull тут не зачепив твою зміну, бо нових комітів у ~/repo.git немає: він написав Already up to date. і нічого не переписував.

Тепер переконайся, що сервіс усе ще працює зі старим, справним кодом:

curl -s http://127.0.0.1/u/bohdan.g9/api/ping
curl -s http://127.0.0.1/u/bohdan.g9/api/habits

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

Поверни код і викоти нормально:

git checkout -- main.py
./deploy.sh
12

Закомітити скрипт

cd ~/app
git add deploy.sh tests
git status --short
git commit -m "деплой: тести перед перезапуском, перевірка ping після"
git push origin master
git -C ~/repo.git log --oneline -1

git status --short перед комітом показує, що саме потрапить у коміт. Якщо там видно main.py — ти не повернув зміну з кроку 11, вернись і виконай git checkout -- main.py. Тек __pycache__ і .pytest_cache у списку бути не повинно: їх не показує git через .gitignore з кроку 2.

Остання команда друкує верхній коміт у ~/repo.git. Він має збігатися з тим, що показує git log --oneline -1 у ~/app.

Що перевіряє чекер

Три критерії заняття 09, усі три перевіряє чекер. Він запускається за розкладом, тому відмітка в кабінеті з’являється не в ту саму секунду, коли ти виконав команду. Команди чекер виконує в тебе в ~/app ті самі, що й ти на практиці, тому все, що він побачить, ти можеш побачити сам заздалегідь.

КодКритерійЩо це означаєНе зараховано — що робити
L09.1 pytest без помилок, щонайменше 5 тестів Чекер заходить у ~/app і виконує /opt/club/venv/bin/python -m pytest -q tests. Дивиться дві речі: код виходу має бути 0, тобто жоден тест не впав, і зібраних тестів має бути щонайменше п’ять. Тести, які pytest не зміг навіть зібрати через помилку в імпортах, не рахуються. Виконай ту саму команду сам і читай перший рядок помилки, а не останній. ModuleNotFoundError: No module named 'main' — запуск не з ~/app або conftest.py лежить не в tests/. fixture 'owner' not found — у conftest.py немає фікстури з таким іменем або сам файл названо інакше. Рядок collected 4 items означає, що тестів менше п’яти: перевір, що імена всіх функцій починаються з test_ — функцію з іншим іменем pytest не бачить і мовчки пропускає.
L09.2 deploy.sh двічі поспіль дає той самий результат Чекер запускає ./deploy.sh двічі одне за одним і дивиться, що обидва рази код виходу 0 і вивід однаковий за змістом. Другий запуск нічого не має ламати: git pull без нових комітів завершується нормально, тести проходять знову, служба піднімається. Перевір сам: ./deploy.sh; echo $? — і одразу ще раз. Обидва рази має бути 0. Якщо скрипт зупинився, дивись, на якому кроці й з яким повідомленням. Your local changes would be overwritten на кроці 1 — у ~/app є незакомічені зміни, а в репозиторії лежить коміт, який зачіпає ті самі файли: закомить зміни або відклади їх командою git stash. Permission deniedchmod +x deploy.sh. unbound variable — у скрипті використана змінна, якій не присвоїли значення; звір свій файл із кроком 8 практики.
L09.3 після деплою сервіс відповідає 200 Одразу після запусків скрипта чекер звертається до /api/ping тією самою публічною адресою, що й браузер — http://91.219.61.4/u/bohdan.g9/api/ping, — і чекає код 200. Заразом він дивиться, чи є така перевірка в самому скрипті: деплой, який не перевіряє результат, лишає сервіс непрацездатним, а дізнаєшся ти про це від користувачів. Перевір із самого сервера — там ту саму адресу зручно писати через 127.0.0.1, запит однаково проходить через nginx: curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1/u/bohdan.g9/api/ping. Якщо 502 або порожньо — служба не піднялася: journalctl --user -u club-api -n 30, найчастіше там SyntaxError або ImportError з main.py. Якщо код зламаний, поверни останню справну версію: git checkout -- main.py, потім systemctl --user restart club-api. Якщо 404 — найімовірніше, у main.py немає ендпоінта /api/ping: перевір командою grep із кроку 1. Друга причина 404 — помилка в адресі, звір логін посимвольно. Якщо в скрипті немає кроку 4 з curl, додай його: без нього критерій не зарахується, навіть коли сервіс відповідає.

Домашнє завдання

Основне. Додати шостий тест — на функцію, яку ти писав сам. Порядок такий: спершу пишеш тест і запускаєш його, і він має впасти. Тільки після цього дописуєш код, щоб тест проходив.

Порядок саме такий не просто так. Тест, який пройшов з першого разу, міг взагалі нічого не перевіряти: помилка в адресі, забутий assert, перевірка, що завжди правдива. Побачивши, що тест спершу падає, а після правки коду проходить, ти точно знаєш, що він реагує на код. Такий спосіб роботи називають розробкою через тести.

Що перевіряти — на твій вибір. Приклади для таблиці звичок, підстав своє:

Зроби це двома комітами, щоб обидва кроки лишилися в історії:

git add tests
git commit -m "тест: довга назва має давати 422 (поки падає)"
# правиш main.py, домагаєшся 6 passed
git add main.py
git commit -m "перевірка довжини назви в моделі NewItem"
git push origin master

Наприкінці — перевір, що звична команда все ще працює від початку до кінця:

cd ~/app
./deploy.sh
echo $?

Додатково. Допиши в README.md розділ «Як оновити» з двома рядками: команда ./deploy.sh і одне речення про те, що станеться, якщо тести впадуть. Через місяць це читатимеш ти сам, і воно заощадить тобі кілька хвилин.

Якщо щось не працює

ModuleNotFoundError: No module named 'main'

pytest не бачить твого застосунку. Перевір дві речі: файл лежить там, де треба, і команда запускається з правильної теки.

ls ~/app/tests/conftest.py
cd ~/app
/opt/club/venv/bin/python -m pytest -q tests

Якщо ls відповів No such file or directory, файл із фікстурами опинився не в tests/ — перенеси його. Запускай саме python -m pytest: варіант без -m не додає поточну теку в шлях пошуку модулів, і імпорти працюють по-різному залежно від того, звідки ти запустив команду.

fixture 'owner' not found

Три причини, у порядку частоти. Перша: файл із фікстурами названо інакше — ім’я має бути точно conftest.py, це не довільна назва, pytest шукає саме її. Друга: у самому conftest.py сталася помилка, і pytest не зміг його виконати; тоді вище, у блоці ERRORS, буде видно справжню причину — читай її, а не рядок про фікстуру. Третя: одруківка в імені параметра — ownre замість owner.

Найчастіша помилка всередині conftest.pyKeyError: 'table'. Вона означає, що в project.yaml у розділі auth немає самого поля table. Якщо поле є, але порожнє, помилка буде інша: у запит підставиться None, і SQLite поскаржиться на неправильний запит. В обох випадках впиши в це поле назву своєї таблиці власника із заняття 07.

Тест на порожню назву дає 500 замість 422

500 означає, що код упав сам, не встигши нічого відповісти. 422 віддає FastAPI, коли дані не пройшли перевірку моделі — тобто коли в моделі стоїть обмеження, наприклад title: str = Field(min_length=1). Якщо ж перевірка написана руками, рядком на кшталт if not title: raise Exception(...), вийде саме 500.

Подивись, як описана модель NewItem у main.py, і порівняй із заняттям 05. Після правки перезапускати службу для тестів не треба — тестовий клієнт бере код напряму з файлу.

./deploy.sh: Permission denied або command not found

Перше — файлу не поставили ознаку «можна запускати»: chmod +x ~/app/deploy.sh. Друге — ти набрав deploy.sh без ./, і bash шукав таку команду серед системних програм.

Окремий випадок — повідомлення bad interpreter: /usr/bin/env bash^M. ^M — це другий символ кінця рядка: у Windows кінець рядка позначається двома символами, а в Linux одним. У файл він потрапляє, якщо текст скрипта набирали в Windows-редакторі й перенесли на сервер разом із цими символами. Якщо ти створював deploy.sh через nano прямо на сервері, такого не станеться. Прибрати їх:

sed -i 's/\r$//' ~/app/deploy.sh
./deploy.sh

git pull: Your local changes would be overwritten by merge

У ~/app є правки, яких немає в жодному коміті, і git не хоче їх затерти комітами, що приходять. Подивись, що це за файли:

git status --short

Далі одне з двох. Якщо правки потрібні — закомить їх:

git add -A
git commit -m "опис того, що змінив"
./deploy.sh

Якщо це залишки експериментів — відклади їх убік:

git stash
./deploy.sh

git stash прибирає незакомічені зміни в окреме сховище, і робоча тека стає чистою. Повернути їх потім можна командою git stash pop, а подивитися список відкладеного — git stash list.