TDD в большом существующем проекте
На этом канале уважают #TDD, и я про него уже писал. Но это все были теоретические выкладки, и меня постоянно спрашивали - "приведи пример из практики", или "а как внедрять TDD в существующий проект?".
Чтож, сейчас в FastStream ведется работа над большой фичей (PR-2867) - поддержкой многоброкерности. И вот, одним из аспектов этой фичи является поддержка тестирования кросс-брокерных взаимодействий. Фича большая, сложная, поэтому она идеально иллюстрирует TDD-flow.
Итак, наша проблема: есть 2 брокера, которые публикуют сообщения друг другу.
broker1 = RabbitBroker() broker2 = RabbitBroker()
@broker1.subscriber("queue-1")
#broker2 публикует сообщения в queue-2 (broker1)@broker2.publisher("queue-2") async def handler1(msg): ...
@broker1.subscriber("queue-2") async def handler2(msg): ...
Проблема в том, что текущая реализация TestBroker не поддерживает кросс-брокерные взаимодействия - т.е. TestBroker(broker2) не знает про подписчиков broker1, и выкидывает SubscriberNotFound ошибку (хотя в реальности очередь слушается другим брокером).
К слову, эту проблему я отловил, когда писал тесты на многоброкерность
Итак, решение, которое мы хотим видеть - тестовый брокер должен знать обо всех подписчиках всех брокеров одного типа. Что-то типа такого:
#TestRabbitBroker должен знать обо всемasync with TestRabbitBroker(broker1, broker2) as (br1, br2): ...
Это очень большое и сложное изменение, которое будет гораздо проще разбить на шаги. Каждый шаг будем проверять на существующих тестах (они не должны падать) - это наши чекпоинты. А в конце - новый тест должен проходить.
Итак, наши шаги:
1️⃣ Внутри TestBroker меняю self.broker на self.brokers: list[...]:
class TestBroker: def init(self, broker): self.brokers = [broker] # было: self.broker = broker
Публичный API тот же, поведение то же - но внутри уже множественность. Запускаем существующие тесты - они должны проходить. Коммитим.
2️⃣ Учу TestRabbitBroker(broker1, broker2) оборачивать оба брокера независимо - пока что эквивалент двух раздельных TestRabbitBroker(broker1) as br1, TestRabbitBroker(broker2) as br2. Никакого шеринга подписчиков, просто чтобы новый синтаксис заработал и as (br1, br2) корректно распаковывался.
class TestBroker: def init(self, *brokers): self.brokers = list(brokers)
Тесты не поломались, значит все хорошо. Коммитим.
3️⃣ Теперь TestRabbitBroker(broker1, broker2) действительно учитывает подписчиков всех включённых брокеров - broker2.publish доезжает до подписчиков broker1. И теперь наш новый тест должен проходить:
async with TestRabbitBroker(broker1, broker2) as (br1, br2): await br1.publish("hello", "queue-1") handler2.mock.assert_called_once_with("processed: hello") # ✅
- наконец зелёный.
А потом уже Claude натянет изменения на другие брокеры по аналогии
Что важно: на каждом из шагов все существующие тесты продолжали проходить. Я ни разу не сидел в состоянии "я переписал половину ядра, все красное, но еще чуть-чуть - и все заработает". Каждый коммит - самостоятельный, откатываемый, не разносит чужие пайплайны. А если бы я не сквошил, это еще и ревьюить было бы просто😂
Так вот - формула, ради которой это все затевалось:
Если ты упёрся в "слишком большое изменение, чтобы сделать за раз" - это значит "пора резать на шаги, между которыми тесты проходят".
В эпоху, когда LLM пишут код тоннами, ценность тестов не падает, а только растёт. Тесты - это контракт поведения. Без них ни ты, ни нейронка не знаете, что значит "работает". Поэтому я и топлю за TDD. Пусть лучше нейронка пишет реализацию по моим тестам - меня не сильно волнует, что внутри, если все мои контракты на ожидаемое поведение выполняются.