9. (Л) Тестування програм, що працюють із базами даних (PostgreSQL) за допомогою pytest¶
Зміст лекції¶
- Навіщо тестувати код, що працює з базою даних
- Нагадування: основи pytest
- Фікстури pytest
- Організація тестової бази даних
- Фікстури для роботи з PostgreSQL
- Тестування CRUD-операцій
- Ізоляція тестів
Навіщо тестувати код, що працює з базою даних¶
Код, що працює з базою даних, особливо вразливий до помилок:
- SQL-запити можуть містити синтаксичні помилки, які виявляються лише під час виконання
- Зміни схеми БД можуть зламати існуючі запити
- Транзакції можуть працювати некоректно — дані губляться або зберігаються частково
- Граничні випадки (порожня таблиця, дублікати, NULL-значення) часто залишаються непокритими
Автоматичні тести дозволяють:
- Виявляти помилки одразу після внесення змін
- Безпечно рефакторити код
- Документувати очікувану поведінку
- Запобігати регресіям — повторному появленню виправлених помилок
Нагадування: основи pytest¶
Ви вже працювали з pytest у першому семестрі. Коротко нагадаємо ключові моменти.
Підготовка середовища¶
pytest потрібно запускати у віртуальному середовищі (venv):
# Створення віртуального середовища
python3 -m venv env
# Активація (Linux/macOS)
source env/bin/activate
# Встановлення залежностей
pip install pytest psycopg2-binary
Коротке нагадування¶
- Файли тестів:
test_*.pyабо*_test.py - Функції тестів починаються з
test_ - Перевірки — через
assert - Перевірка винятків — через
pytest.raises - Запуск:
pytestабоpytest -v(детальний вивід)
import pytest
def test_simple():
assert 2 + 2 == 4
def test_exception():
with pytest.raises(ValueError, match="invalid"):
int("not_a_number") # ValueError: invalid literal...
Фікстури pytest¶
Фікстури — це механізм pytest для підготовки та очищення ресурсів, потрібних для тестів (з'єднання з БД, тестові дані тощо).
Базовий приклад¶
import pytest
@pytest.fixture
def sample_users():
"""Фікстура повертає список тестових користувачів"""
return [
{"name": "Alice", "email": "alice@example.com"},
{"name": "Bob", "email": "bob@example.com"},
]
def test_user_count(sample_users):
assert len(sample_users) == 2
def test_first_user(sample_users):
assert sample_users[0]["name"] == "Alice"
Коли pytest бачить параметр sample_users у сигнатурі тесту, він автоматично викликає фікстуру з такою ж назвою та передає результат як аргумент.
Фікстури з підготовкою та очищенням (yield)¶
import pytest
@pytest.fixture
def temp_file():
# Підготовка (setup)
import tempfile, os
path = tempfile.mktemp(suffix=".txt")
with open(path, "w") as f:
f.write("test data")
yield path # Тест отримає це значення
# Очищення (teardown) — виконується після тесту
os.remove(path)
def test_read_temp_file(temp_file):
with open(temp_file) as f:
assert f.read() == "test data"
Код до yield виконується перед тестом (setup), код після yield — після тесту (teardown). Це гарантує очищення ресурсів незалежно від результату тесту.
Область дії фікстур (scope)¶
import pytest
@pytest.fixture(scope="session")
def expensive_resource():
"""Створюється один раз для всієї тестової сесії"""
print("Створення ресурсу...")
resource = {"connection": "established"}
yield resource
print("Звільнення ресурсу...")
@pytest.fixture(scope="function") # За замовчуванням
def per_test_data():
"""Створюється заново для кожного тесту"""
return {"counter": 0}
| Scope | Опис |
|---|---|
function |
Нова фікстура для кожного тесту (за замовчуванням) |
class |
Одна фікстура на клас тестів |
module |
Одна фікстура на файл тестів |
session |
Одна фікстура на всю тестову сесію |
Організація тестової бази даних¶
Важливо: тести ніколи не повинні працювати з продакшн-базою даних. Для тестів завжди використовують окрему базу.
Стратегії¶
- Окрема тестова база даних — створюється перед запуском тестів, видаляється після. Тести працюють з реальним PostgreSQL.
- Мокування (mocking) — підміна реального з'єднання імітацією. Тести не потребують запущеної БД, але менш реалістичні.
У цій лекції ми розглянемо перший підхід.
Фікстури для роботи з PostgreSQL¶
Фікстура з'єднання з тестовою базою¶
import pytest
import psycopg2
TEST_DB_URL = "postgresql://postgres:secret@localhost:5432/mydb"
@pytest.fixture(scope="session")
def db_connection():
"""Створення з'єднання з тестовою базою на всю сесію тестів"""
conn = psycopg2.connect(TEST_DB_URL)
conn.autocommit = True # Кожен запит виконується автономно
yield conn
conn.close()
@pytest.fixture(scope="session", autouse=True)
def create_tables(db_connection):
"""Створення таблиць перед запуском тестів"""
with db_connection.cursor() as cursor:
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
owner VARCHAR(100) NOT NULL,
balance NUMERIC(12, 2) NOT NULL DEFAULT 0,
CHECK (balance >= 0)
)
''')
Параметр autouse=True означає, що фікстура застосовується автоматично до всіх тестів без потреби явно вказувати її в аргументах.
autocommit = True — кожен SQL-запит виконується та фіксується автоматично, без явного виклику commit(). Це спрощує тести: після помилки (наприклад, IntegrityError) з'єднання залишається робочим, бо немає «зламаної» транзакції.
Повний файл conftest.py¶
pytest автоматично завантажує фікстури з файлу conftest.py. Розміщуйте спільні фікстури в цьому файлі, щоб вони були доступні всім тестам:
# conftest.py
import pytest
import psycopg2
TEST_DB_URL = "postgresql://postgres:secret@localhost:5432/mydb"
@pytest.fixture(scope="session")
def db_connection():
"""З'єднання з тестовою базою"""
conn = psycopg2.connect(TEST_DB_URL)
conn.autocommit = True
yield conn
conn.close()
@pytest.fixture(scope="session", autouse=True)
def create_tables(db_connection):
"""Створення таблиць"""
with db_connection.cursor() as cur:
cur.execute('''
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
''')
cur.execute('''
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
owner VARCHAR(100) NOT NULL,
balance NUMERIC(12, 2) NOT NULL DEFAULT 0,
CHECK (balance >= 0)
)
''')
@pytest.fixture(autouse=True)
def clean_tables(db_connection):
"""Очищення таблиць перед кожним тестом"""
with db_connection.cursor() as cur:
cur.execute("DELETE FROM users")
cur.execute("DELETE FROM accounts")
Фікстура clean_tables з autouse=True автоматично очищає всі таблиці після кожного тесту. Це гарантує, що дані, створені одним тестом, не впливають на інші. Завдяки autocommit = True фікстура працює надійно — навіть якщо тест спровокував помилку БД (наприклад, IntegrityError), з'єднання залишається робочим.
Тестування CRUD-операцій¶
Модуль, який тестуємо¶
# user_repository.py
import psycopg2
class UserRepository:
def __init__(self, conn):
self.conn = conn
def create(self, name, email):
"""Створити нового користувача"""
with self.conn.cursor() as cur:
cur.execute(
"INSERT INTO users (name, email) VALUES (%s, %s) RETURNING id",
(name, email)
)
return cur.fetchone()[0]
def get_by_id(self, user_id):
"""Отримати користувача за ID"""
with self.conn.cursor() as cur:
cur.execute("SELECT id, name, email FROM users WHERE id = %s", (user_id,))
row = cur.fetchone()
if row is None:
return None
return {"id": row[0], "name": row[1], "email": row[2]}
def get_all(self):
"""Отримати всіх користувачів"""
with self.conn.cursor() as cur:
cur.execute("SELECT id, name, email FROM users ORDER BY id")
return [{"id": r[0], "name": r[1], "email": r[2]} for r in cur.fetchall()]
def update_email(self, user_id, new_email):
"""Оновити email користувача"""
with self.conn.cursor() as cur:
cur.execute(
"UPDATE users SET email = %s WHERE id = %s",
(new_email, user_id)
)
return cur.rowcount > 0
def delete(self, user_id):
"""Видалити користувача"""
with self.conn.cursor() as cur:
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
return cur.rowcount > 0
Тести для CRUD-операцій¶
# test_user_repository.py
import pytest
import psycopg2
from user_repository import UserRepository
@pytest.fixture
def repo(db_connection):
"""Фікстура: екземпляр UserRepository"""
return UserRepository(db_connection)
# --- CREATE ---
def test_create_user(repo):
user_id = repo.create("Alice", "alice@example.com")
assert user_id is not None
assert isinstance(user_id, int)
def test_create_user_duplicate_email(repo, db_connection):
repo.create("Alice", "alice@example.com")
with pytest.raises(psycopg2.IntegrityError):
repo.create("Bob", "alice@example.com")
# --- READ ---
def test_get_by_id(repo):
user_id = repo.create("Bob", "bob@example.com")
user = repo.get_by_id(user_id)
assert user is not None
assert user["name"] == "Bob"
assert user["email"] == "bob@example.com"
def test_get_by_id_not_found(repo):
user = repo.get_by_id(99999)
assert user is None
def test_get_all(repo):
repo.create("Alice", "alice@example.com")
repo.create("Bob", "bob@example.com")
users = repo.get_all()
assert len(users) == 2
assert users[0]["name"] == "Alice"
assert users[1]["name"] == "Bob"
# --- UPDATE ---
def test_update_email(repo):
user_id = repo.create("Charlie", "charlie@example.com")
result = repo.update_email(user_id, "new_charlie@example.com")
assert result is True
user = repo.get_by_id(user_id)
assert user["email"] == "new_charlie@example.com"
def test_update_nonexistent_user(repo):
result = repo.update_email(99999, "no@example.com")
assert result is False
# --- DELETE ---
def test_delete_user(repo):
user_id = repo.create("Dave", "dave@example.com")
result = repo.delete(user_id)
assert result is True
assert repo.get_by_id(user_id) is None
def test_delete_nonexistent_user(repo):
result = repo.delete(99999)
assert result is False
Приклад виконання тестів¶
❯ pytest -v
===================================================================================== test session starts ======================================================================================
platform linux -- Python 3.13.5, pytest-9.0.2, pluggy-1.6.0 -- /home/kurotych/sources/python-postgre/env/bin/python3
cachedir: .pytest_cache
rootdir: /home/kurotych/sources/python-postgre
collected 9 items
test_user_repository.py::test_create_user PASSED [ 11%]
test_user_repository.py::test_create_user_duplicate_email PASSED [ 22%]
test_user_repository.py::test_get_by_id PASSED [ 33%]
test_user_repository.py::test_get_by_id_not_found PASSED [ 44%]
test_user_repository.py::test_get_all PASSED [ 55%]
test_user_repository.py::test_update_email PASSED [ 66%]
test_user_repository.py::test_update_nonexistent_user PASSED [ 77%]
test_user_repository.py::test_delete_user PASSED [ 88%]
test_user_repository.py::test_delete_nonexistent_user PASSED [100%]
Зверніть увагу: завдяки фікстурі clean_tables кожен тест починає з порожньої таблиці users. Тести не впливають один на одного.
Ізоляція тестів¶
Ізоляція тестів — критично важлива концепція. Кожен тест повинен працювати незалежно від інших: порядок запуску не повинен впливати на результат.
З'єднання з autocommit = True та видалення даних після кожного тесту:
@pytest.fixture(scope="session")
def db_connection():
conn = psycopg2.connect(TEST_DB_URL)
conn.autocommit = True
yield conn
conn.close()
@pytest.fixture(autouse=True)
def clean_tables(db_connection):
"""Очищення таблиць перед кожним тестом"""
with db_connection.cursor() as cur:
cur.execute("DELETE FROM users")
cur.execute("DELETE FROM accounts")
Перед кожним тестом фікстура видаляє всі рядки з таблиць, гарантуючи чистий стан. Завдяки autocommit немає проблеми зі «зламаними» транзакціями після помилок БД.
Приклад тестування класів.¶
# Створення та активація venv
python3 -m venv env
source env/bin/activate
# Встановлення залежностей
pip install pytest psycopg2-binary
# Також потрібно створити базу даних mydb (якщо ще не створена)
account_service.py¶
import psycopg2
class InsufficientFundsError(Exception):
pass
class AccountNotFoundError(Exception):
pass
class AccountService:
def __init__(self, conn):
self.conn = conn
def create_account(self, owner, balance=0):
with self.conn.cursor() as cur:
cur.execute(
"INSERT INTO accounts (owner, balance) VALUES (%s, %s) RETURNING id",
(owner, balance)
)
return cur.fetchone()[0]
def get_balance(self, account_id):
with self.conn.cursor() as cur:
cur.execute("SELECT balance FROM accounts WHERE id = %s", (account_id,))
row = cur.fetchone()
if row is None:
raise AccountNotFoundError(f"Рахунок {account_id} не знайдено")
return float(row[0])
def transfer(self, from_id, to_id, amount):
if amount <= 0:
raise ValueError("Сума переказу повинна бути додатною")
balance = self.get_balance(from_id)
self.get_balance(to_id) # Перевірка існування отримувача
if balance < amount:
raise InsufficientFundsError(f"Недостатньо коштів: {balance} < {amount}")
with self.conn.cursor() as cur:
cur.execute(
"UPDATE accounts SET balance = balance - %s WHERE id = %s",
(amount, from_id)
)
cur.execute(
"UPDATE accounts SET balance = balance + %s WHERE id = %s",
(amount, to_id)
)
conftest.py¶
import pytest
import psycopg2
TEST_DB_URL = "postgresql://postgres:secret@localhost:5432/mydb"
@pytest.fixture(scope="session")
def db_connection():
conn = psycopg2.connect(TEST_DB_URL)
conn.autocommit = True
yield conn
conn.close()
@pytest.fixture(scope="session", autouse=True)
def create_tables(db_connection):
with db_connection.cursor() as cur:
cur.execute('''
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
owner VARCHAR(100) NOT NULL,
balance NUMERIC(12, 2) NOT NULL DEFAULT 0,
CHECK (balance >= 0)
)
''')
@pytest.fixture(autouse=True)
def clean_tables(db_connection):
"""Очищення таблиць перед кожним тестом"""
with db_connection.cursor() as cur:
cur.execute("DELETE FROM accounts")
test_account_service.py¶
import pytest
from account_service import AccountService, InsufficientFundsError, AccountNotFoundError
@pytest.fixture
def service(db_connection):
return AccountService(db_connection)
@pytest.fixture
def funded_accounts(service):
"""Два рахунки: Олена (1000 грн), Тарас (500 грн)"""
id1 = service.create_account("Олена", 1000)
id2 = service.create_account("Тарас", 500)
return id1, id2
class TestCreateAccount:
def test_create_with_default_balance(self, service):
acc_id = service.create_account("Нова людина")
assert service.get_balance(acc_id) == 0.0
def test_create_with_initial_balance(self, service):
acc_id = service.create_account("Багатій", 10000)
assert service.get_balance(acc_id) == 10000.0
class TestGetBalance:
def test_existing_account(self, service, funded_accounts):
id1, _ = funded_accounts
assert service.get_balance(id1) == 1000.0
def test_nonexistent_account(self, service):
with pytest.raises(AccountNotFoundError):
service.get_balance(99999)
class TestTransfer:
def test_successful_transfer(self, service, funded_accounts):
id1, id2 = funded_accounts
service.transfer(id1, id2, 300)
assert service.get_balance(id1) == 700.0
assert service.get_balance(id2) == 800.0
def test_transfer_all_money(self, service, funded_accounts):
id1, id2 = funded_accounts
service.transfer(id1, id2, 1000)
assert service.get_balance(id1) == 0.0
assert service.get_balance(id2) == 1500.0
def test_insufficient_funds(self, service, funded_accounts):
id1, id2 = funded_accounts
with pytest.raises(InsufficientFundsError):
service.transfer(id1, id2, 2000)
# Баланси залишились незмінними
assert service.get_balance(id1) == 1000.0
assert service.get_balance(id2) == 500.0
def test_negative_amount(self, service, funded_accounts):
id1, id2 = funded_accounts
with pytest.raises(ValueError, match="додатною"):
service.transfer(id1, id2, -100)
def test_zero_amount(self, service, funded_accounts):
id1, id2 = funded_accounts
with pytest.raises(ValueError):
service.transfer(id1, id2, 0)
def test_nonexistent_sender(self, service, funded_accounts):
_, id2 = funded_accounts
with pytest.raises(AccountNotFoundError):
service.transfer(99999, id2, 100)
def test_nonexistent_receiver(self, service, funded_accounts):
id1, _ = funded_accounts
with pytest.raises(AccountNotFoundError):
service.transfer(id1, 99999, 100)
def test_multiple_transfers(self, service, funded_accounts):
id1, id2 = funded_accounts
service.transfer(id1, id2, 100) # Олена: 900, Тарас: 600
service.transfer(id2, id1, 50) # Олена: 950, Тарас: 550
assert service.get_balance(id1) == 950.0
assert service.get_balance(id2) == 550.0
Запуск тестів¶
#~/sources/pp2 via 🐍 v3.13.5 (env) took 8s
❯ pytest -v
=============================================================================================== test session starts ================================================================================================
platform linux -- Python 3.13.5, pytest-9.0.2, pluggy-1.6.0 -- /home/kurotych/sources/pp2/env/bin/python3
cachedir: .pytest_cache
rootdir: /home/kurotych/sources/pp2
collected 12 items
test_account_service.py::TestCreateAccount::test_create_with_default_balance PASSED [ 8%]
test_account_service.py::TestCreateAccount::test_create_with_initial_balance PASSED [ 16%]
test_account_service.py::TestGetBalance::test_existing_account PASSED [ 25%]
test_account_service.py::TestGetBalance::test_nonexistent_account PASSED [ 33%]
test_account_service.py::TestTransfer::test_successful_transfer PASSED [ 41%]
test_account_service.py::TestTransfer::test_transfer_all_money PASSED [ 50%]
test_account_service.py::TestTransfer::test_insufficient_funds PASSED [ 58%]
test_account_service.py::TestTransfer::test_negative_amount PASSED [ 66%]
test_account_service.py::TestTransfer::test_zero_amount PASSED [ 75%]
test_account_service.py::TestTransfer::test_nonexistent_sender PASSED [ 83%]
test_account_service.py::TestTransfer::test_nonexistent_receiver PASSED [ 91%]
test_account_service.py::TestTransfer::test_multiple_transfers PASSED [100%]
================================================================================================ 12 passed in 0.07s ================================================================================================ Активуйте venv, якщо ще не активоване
source env/bin/activate
# Запуск усіх тестів
pytest -v
# Очікуваний результат:
# test_account_service.py::TestCreateAccount::test_create_with_default_balance PASSED
# test_account_service.py::TestCreateAccount::test_create_with_initial_balance PASSED
# test_account_service.py::TestGetBalance::test_existing_account PASSED
# test_account_service.py::TestGetBalance::test_nonexistent_account PASSED
# test_account_service.py::TestTransfer::test_successful_transfer PASSED
# test_account_service.py::TestTransfer::test_transfer_all_money PASSED
# test_account_service.py::TestTransfer::test_insufficient_funds PASSED
# test_account_service.py::TestTransfer::test_negative_amount PASSED
# test_account_service.py::TestTransfer::test_zero_amount PASSED
# test_account_service.py::TestTransfer::test_nonexistent_sender PASSED
# test_account_service.py::TestTransfer::test_nonexistent_receiver PASSED
# test_account_service.py::TestTransfer::test_multiple_transfers PASSED
# ========================= 12 passed =========================
Корисні посилання¶
Домашнє завдання¶
Прочитати документацію pytest: Fixtures
Знайшли помилку чи бажаєте додати інформацію, щоб покращити курс? Створіть issue на GitHub