Перейти к содержимому

Практика 13. Практическая работа 13. Репозиторий и Unit of Work

Цель

  • Закрепить паттерн Repository как абстракцию над источником данных.
  • Научиться писать несколько взаимозаменяемых реализаций одного интерфейса репозитория (в памяти, имитация БД, мок для тестов).
  • Освоить паттерн Unit of Work с операциями commit и rollback.
  • Отработать подмену реализации хранилища и тестирование бизнес-логики с использованием мока, без поднятия настоящей базы данных.

Краткая теория

Repository скрывает работу с хранилищем за интерфейсом, который выглядит как коллекция объектов в памяти. Бизнес-логика зависит от абстракции (ABC/Protocol), а не от конкретного класса, поэтому источник данных (память, БД, файл, API) можно подменять без правки логики.

Поскольку все реализации подчиняются одному контракту, для тестов удобно сделать мок — заглушку с контролируемым поведением, которая возвращает заранее заданные данные и фиксирует факт вызова (save, delete).

Unit of Work («единица работы») накапливает изменения в рамках одной бизнес-операции (новые, изменённые, удалённые объекты) и применяет их единым блоком: либо всё (commit), либо ничего (rollback). По сути это объект, управляющий транзакцией поверх одного или нескольких репозиториев.

from abc import ABC, abstractmethod
from typing import Optional, List
class Repository(ABC):
@abstractmethod
def find_by_id(self, item_id: int) -> Optional[object]: ...
@abstractmethod
def save(self, item) -> object: ...
@abstractmethod
def delete(self, item_id: int) -> None: ...

Подробности — в лекции 14 («Репозиторий, Unit of Work и Dependency Injection»).


Задания

Задание 1. Интерфейс репозитория и реализация в памяти

Опишите модель и абстрактный репозиторий, затем реализацию в памяти.

Требования:

  1. Модель Product (dataclass): id: Optional[int], name: str, price: float, category: str, stock: int.
  2. Абстрактный класс ProductRepository(ABC) с методами:
    • find_by_id(product_id: int) -> Optional[Product]
    • find_all() -> List[Product]
    • save(product: Product) -> Product
    • delete(product_id: int) -> None
    • exists(product_id: int) -> bool
  3. InMemoryProductRepository(ProductRepository) — хранение в dict, автогенерация id при save, если product.id is None.

Скелет:

class ProductRepository(ABC):
@abstractmethod
def find_by_id(self, product_id: int) -> Optional[Product]: ...
# ... остальные методы
class InMemoryProductRepository(ProductRepository):
def __init__(self):
self._storage: dict[int, Product] = {}
self._next_id = 1
# реализуйте все методы интерфейса

Покажите в if __name__ == "__main__" сохранение трёх продуктов, поиск по id и вывод find_all().


Задание 2. Имитация БД и подмена реализации

Реализуйте тот же интерфейс поверх «базы данных» (имитация) и докажите взаимозаменяемость.

Требования:

  1. DatabaseProductRepository(ProductRepository):
    • конструктор принимает connection_string: str;
    • каждый метод печатает соответствующий «SQL» (SELECT, INSERT, UPDATE, DELETE) и работает с внутренним dict, имитируя таблицу;
    • save различает INSERT (нет id) и UPDATE (id задан).
  2. Сервис ProductService, который зависит только от ProductRepository (внедрение через конструктор):
    • add_product(name, price, category, stock) -> Product;
    • get_catalog() -> List[Product].
  3. Функция run_service(repo: ProductRepository), которая принимает любую реализацию и выполняет один и тот же сценарий.

Требование к проверке: вызовите run_service сначала с InMemoryProductRepository, затем с DatabaseProductRepository — бизнес-код не должен меняться. Подмена реализации происходит в одной точке.


Задание 3. Мок репозитория и тесты бизнес-логики

Напишите мок и протестируйте сервис без настоящего хранилища.

Требования:

  1. FakeProductRepository(ProductRepository):
    • конструктор принимает preset: List[Product] = None;
    • хранит элементы в dict, заполняется из preset;
    • атрибут self.saved: List[Product] — список всех сохранённых объектов;
    • атрибут self.deleted: List[int] — список удалённых id.
  2. Тесты на pytest (или функции assert), которые проверяют:
    • после service.add_product(...) объект попал в repo.saved;
    • get_catalog() возвращает элементы из preset;
    • сервис ни разу не обращается к настоящей БД (используется только мок).

Скелет теста:

def test_add_product_saves_to_repository():
repo = FakeProductRepository()
service = ProductService(repo)
service.add_product("Мышь", 1000, "Электроника", 5)
assert len(repo.saved) == 1
assert repo.saved[0].name == "Мышь"

Мок подменяет хранилище, поэтому тест не зависит от внешних ресурсов и выполняется мгновенно.


Задание 4. Unit of Work с commit/rollback

Реализуйте единицу работы поверх репозитория продуктов.

Требования:

  1. Абстрактный UnitOfWork(ABC) с методами commit() и rollback().
  2. ProductUnitOfWork(UnitOfWork):
    • конструктор принимает repository: ProductRepository;
    • register_new(product), register_modified(product), register_deleted(product_id) — накапливают изменения в трёх списках;
    • commit() применяет изменения к репозиторию (save/delete); при любом исключении вызывает rollback() и пробрасывает ошибку;
    • rollback() очищает накопленные изменения (ничего не применяется).
  3. Сценарий-демонстрация: зарегистрируйте новый, изменённый и удаляемый объекты, затем commit(); проверьте, что изменения попали в репозиторий.

Требование к проверке отката: смоделируйте сбой (например, репозиторий с save, бросающим исключение на определённом объекте) и убедитесь, что после неудачного commit накопленные списки очищены, а исключение проброшено.


Задание 5*. Unit of Work как контекстный менеджер

Расширьте ProductUnitOfWork поддержкой with.

Требования:

  1. Реализуйте __enter__ (возвращает self) и __exit__:
    • при отсутствии исключения — автоматический commit();
    • при исключении — автоматический rollback() и проброс ошибки.
  2. Покажите два сценария:
    • успешный блок with uow: — изменения сохранены;
    • блок с raise внутри — изменения откачены, хранилище не тронуто.
  3. Напишите тест с FakeProductRepository, проверяющий, что при ошибке внутри with список repo.saved остался пустым.

Пример использования:

with ProductUnitOfWork(repo) as uow:
uow.register_new(Product(None, "Ноутбук", 50000, "Электроника", 3))
uow.register_deleted(7)
# при выходе из блока без ошибок — commit() вызван автоматически

Критерии оценки

  • Задание 1 (интерфейс + InMemory) — 20%: корректный ABC, рабочая реализация в памяти, автогенерация id.
  • Задание 2 (имитация БД + подмена) — 20%: общий интерфейс, сервис зависит от абстракции, доказана взаимозаменяемость реализаций.
  • Задание 3 (мок + тесты) — 20%: мок фиксирует вызовы, тесты проходят без обращения к БД.
  • Задание 4 (Unit of Work) — 25%: накопление изменений, корректные commit/rollback, обработка сбоя с откатом.
  • Задание 5* (контекстный менеджер) — 15%: автоматические commit/rollback через with, тест отката.
  • Чистота кода, docstrings и осмысленные имена учитываются в каждом пункте.

Максимум без задания 5* — 85%, со звёздочкой — 100%.


Вопросы для самопроверки

  1. Какую задачу решает паттерн Repository и почему бизнес-логике лучше зависеть от его интерфейса, а не от конкретной реализации?
  2. Чем реализация репозитория в памяти удобна для прототипов и тестов?
  3. Почему InMemoryProductRepository и DatabaseProductRepository взаимозаменяемы в сервисе? В какой точке выбирается реализация?
  4. Что отличает мок от обычной реализации репозитория и какие данные он обычно фиксирует?
  5. Зачем мок хранит списки saved и deleted? Что они позволяют проверить в тесте?
  6. Какие три категории изменений накапливает Unit of Work и в каком порядке их разумно применять при commit?
  7. Что произойдёт в commit, если при сохранении одного из объектов возникнет исключение?
  8. Чем rollback отличается от простого «ничего не делать» и как он связан с понятием транзакции?
  9. Какие преимущества даёт реализация Unit of Work через контекстный менеджер (with) по сравнению с ручными вызовами commit/rollback?