Идеология TDD

Идеология TDD

Пастухов Никита

Основная цель TDD – максимальная фокусировка

Вся идеология TDD заключается в том, чтобы повысить продуктивность разработчика путем максимальной фокусировки на узкой проблеме. В процессе реализации задачи ты не должен отвлекаться на сайд-правки функционала вне скоупа.

"Мне вот нужно написать метод repository.get(), но надо предусмотреть, что ничего не найдется, а еще пойду зарегистрирую реализацию в IOC, а вот тут у юзера поля не хватает ..." – нет, не надо! Очень к месту тут будут стикеры под рукой, куда ты записываешь такие мысли – когда закончишь с текущим тестом, вернешься к списку, чтобы взять следующую задачку.

Строго один тест

"Красный тест" – это именно что ОДИН тест. Мы не пишем сразу все N тестов на фичу, а потом пытаемся подогнать реализацию под ожидаемое поведение – мы пишем строго один тест, который можем сделать зеленым за минимальное число шагов. 

Реализация исходя из тестов

Тут надо понимать, что требования к поведению на момент написания теста уже есть, а вот на реализацию – нет. Допустим, мы пишем метод репозитория пользователя на получение модели: логично, что этот метод будет возвращать некий объект юзера (что он будет содержать - опеределяется местами использования).

Помните, что реализацию мы пишем, начиная с теста? Вот и тест мы пишем начиная с assert – ведь именно там и заключаются ожидания, что мы предъявляем – полученный юзер именно тот, что мы просили

def test_get_user() -> None:
    ...
    assert result_user == User(...)

Раз User – это сущность, то проверять мы его будем по id:

assert result_user == User(id=user_id)

Теперь пофантазируем, как будет выглядеть act:

def test_get_user() -> None:
    ...
    result_user = repository.get(user_id)
    assert result_user == User(id=user_id)

Ну и теперь докинем arrange стадию

def test_get_user() -> None:
    repository = UserRepository()
    user_id = uuid4()
    repository.add(User(id=user_id))

    result_user = repository.get(user_id)

    assert result_user == User(id=user_id)

Отлично! – мы уже сформировали какие-то ожидания на интерфейс репозитория исходя из нашего сценария использования. Теперь по этому интерфейсу можно смело писать реализацию!

Минимальные шаги

Реализация логики, на которую уже написан тест, происходит микроскопическими шагами. Сначал код должен "скомпилироваться" – мы просто объявляем все классы и методы, что используются в тесте. Реализация может быть пустой, наша задача – чтобы тест запустился и стал "красным".

Когда мы увидели "красный" тест – мы должны сделать его "зеленым" за минимальный шаг. Т.е. буквально написать самую тупую реализацию, что придет нам в голову.

class UserRepository:
    def add(self, user: User) -> None:
        pass

    def get(self, user_id: UUID) -> User:
        return User(id=user_id)

Тест стал "зеленым"! Теперь переходим к рефакторингу. Основная цель рефакторинга – устранение дублирования. Причем дублированием считается повторение кода не только внутри реализации, но и между тестами и реализацией. В нашем случае дублированием является создание модельки пользователя в arrange фазе теста и внутри метода

def get(self, user_id: UUID) -> User:
    return User(id=user_id)  # дублирование

Надо это исправлять!

class UserRepository:
    def add(self, user: User) -> None:
        self.user = user

    def get(self, user_id: UUID) -> User:
        return self.user

Отлично! Дублирование убрано, тест зеленый – можем идти дальше

Триангуляция

Так получилось, что наш зеленый тест на repo.get(...) еще не гарантирует, что реализация верная. Для ситуаций, когда не ясно, как должна выглядеть "верная" реализация, в TDD используется триангуляция. Мы просто докидываем требования, которые конфликтуют с существующими тестам и пытаемся сделать так, чтобы все они выполнялись. "Докидывание" тестов может заключаться как в добавлении новых ограничений в существующий тест, так и в добавлении новых тестов.

Например, мы можем переписать тест следующим образом:

def test_get_user() -> None:
    repository = UserRepository()
    user_id = uuid4()
    repository.add(User(id=user_id))
    another_user_id = uuid4()
    repository.add(User(id=another_user_id))

    result_user = repository.get(user_id)
    another_result_user = repository.get(another_user_id)

    assert result_user == User(id=user_id)
    assert another_result_user == User(id=another_user_id)


Очевидно, что такой тест вступает в конфликт с существующей реализацией, поэтому изменим ее соответствующе:

class UserRepository:
    def __init__(self) -> None:
        self.users = {}

    def add(self, user: User) -> None:
        self.users[user.id] = user

    def get(self, user_id: UUID) -> User:
        return self.users[user_id]

Тесты зеленые, код lgtm. На этом цикл "красный – зеленый – рефакторинг" в отношении happy way использования репозитория подходит к концу.

Повторяем

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

что делать, если get ищет пользователя, которого нет?

И здесь мы точно также проходим цикл "красный – зеленый – рефакторинг", попутно формируя ожидания от интерфейса разрабатываемого объекта: должен ли он выкидывать ошибку при таком сценарии или возвращать None?

Я бы предложил написать вот такой тест

def test_get_missed_user() -> None:
    repository = UserRepository()
    user_id = uuid4()

    result_user = repository.get(user_id)

    assert result_user is None

И поправить реализацию соответсвующе:

class UserRepository:
    def get(self, user_id: UUID) -> User | None:
        return self.users.get(user_id)

Теперь оба наших теста зеленые, а значит, мы уверен, что целых два сценария взаимодействия с системой работают в соответствии с нашими ожиданиями.

Прошу заметить, что оба написанных теста никак не опираются на детали реализации. Они опираются только на интерфейсы (которые мы же и придумали) и наши ожидания от поведения. Никаким mock.assert_called_once() для внутренних объектов реализации тут не место – такие проверки делают тесты хрупкими.


А дальше – мы уже переходим к реализации реального репозитория, без фейковых объектов. И все такими же маленькими шагами: выделим интерфейс, напишем пустую реализацию, реализуем happy way, реализуем обработку исключения...

Вот так – шаг за шагом – мы и наращивает функционал нашего проекта.

Регулируем скорость

А теперь самое классное в TDD – вас никто не обязывает идти вот такими медленными шагами. Вы можете регулировать скорость в зависимости от своей уверенности – писать сразу тесты с триангуляцией, писать не самую тупую реализацию, а уже готовые фейковые объекты, выделять интерфейсы заранее и регистрировать их в IOC – и тд, и тп.

ПРОСТО НАПИШИ СНАЧАЛА ТЕСТ!

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

Но опция перехода на "пониженную передачу" всегда остается при тебе. И она дико выручает, когда реализация совсем не ясна на берегу. Тут мы уже используем и триангуляцию, и примитивные реализации, и опираемся на уже зеленые тесты как промежуточные вехи – в общем, кайфуем от страховки по полной!

Report Page