Все потоки
Поиск
Написать публикацию
Обновить
12
1

Программист, техлид

Отправить сообщение

Затрудняюсь сказать, почему вам не дают ставить оценки. Возможно, у вас исчерпался лимит голосов за публикации (нажмите на свою аватарку, и в верхней части всплывающего окна увидите свои лимиты).

В любом случае, признателен вам за поддержку моей статьи: доброе словое - это уже немало.

Спасибо за такой развернутый комментарий, приятно видеть ваш интерес к поднятой мной теме.

По существу поднятых вами вопросов:

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

Все сказанное мной не является какими-то догмами. Я лишь озвучиваю выводы, сделанные мной по ситуациям из личной практики (ну, и в какой-то мере подсмотренные у других), в которых применение тех или иных методик достаточно часто оказывалось эффективным.

Во-первых, связь "минимально возможного уровеня" теста и дублирования сама по себе звучит сомнительно и вызывает массу вопросов из серии "что хотел сказать автор"

Я имел в виду, что тест всегда тестирует одну единственную ветку логики. Она как правило предполагает более-менее уникальный набор входных и выходных данных, поэтому и инициализация по большей части уникальна, а дублирование — минимально. Настолько минимально, что в большинстве случаев от его устранения бывает целесообразнее отказаться.

С другой стороны, это не догма, и, если это оправданно, никто не запрещает рефакторить и тесты. Но я хочу особо подчеркнуть, что рефакторинг я не считаю изменением тестов, поскольку рефакторинг по определению не предполагает изменения внешнего поведения системы.

Но как может инициализация (а так же очистка после) быть не общей? SetUp/TearDown и их наследники же не просто так в xUnit-е поддерживаются, нет?

Выше я уже упомянул, что необходимость в общей инициализации в моей личной практике возникает редко. Тут я лишь добавлю, что настолько редко, что в моих проектах set-up и tear-down практически никогда не встречаются.

Рефакторинг. Как бы там не плясали вокруг проблему "хрупкости" (по-моему, сильно преувеличенной, но это отдельный разговор), но если меняется дизайн, то может поменяться контекст работы компоненты. Следовательно,. как минимум придётся изменить "инициализацию".

Про рефакторинг писал выше. Если у нас меняются требования, это уже по определению не рефакторинг.

А на самом деле может много чего поменяться — на уровне контракта этой компоненты. Как тут без изменения тестов? Старые выкидываем, пишем новые с листа?..

А как быть с квинтэссенцией всего это бардака и тем, ради чего всё это (якобы) вообще затевалось — с изменениями требований к системе в целом…

Да, именно так. Поскольку в рамках TDD тесты являются отражением требований, то когда какие-то требования устаревают, мы просто удаляем соответствующие тесты. Если добавляются новые требования, запускаем новый цикл разработки Red-Green-Refactor, в результате которого у нас появляются новые тесты.

А ещё вы очень лихо спихнули (только не понятно на кого) проблему оптимизации, которая непосредственно затрагивает TDD

Оптимизация — это одна из трех причин изменения кода наряду с рефакторингом и изменением функционала. Оптимизация не предполагает ни изменения требований, ни применения TDD.

При оптимизации мы хотим, чтобы поведение системы оставалось таким же, но изменялись какие-то ее нефункциональные характеристики, например быстродействие. Поэтому набор тестов при этом не меняется. Отсюда два следствия: (1) оптимизация не дает оснований для применения TDD и (2) мы можем использовать тесты, полученные впроцессе разработки, чтобы сделать процесс оптимизации контролируемым, убеждаясь, что оптимизация не нарушает функционал системы.

Ах, да — чуть не забыл, возможно, самую главную причину для изменения тестов — разработчика. Он меняется! Меняется его понимание того, что он разрабатывает.

Очень точно подмечено. Мы меняемся, и меняется наше понимание, в том числе, и требований к системе. И вот тут пора вспомнить о догмах.

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

Если честно, я вообще не могу представить, как вам удаётся не менять тесты?

Совсем не менять — не удается. Просто это надобится очень редко. Примеры случаев, когда надобится, я как раз и описал в этом ответе.

вы действительно практикуете TDD в "промышленной" (или как то назвать лучше) разработке? Давно?

Да, практикую. Около 4 лет. Поначалу у меня были сомнения в эффективности этого подхода, поскольку в отсутствии опыта разработка действительно ощутимо замедляется. Но это быстро проходит, и в среднесрочной перспективе с TDD получается несколько быстрее, чем без него.

За это время я довольно хорошо изучил эту методику сам и познакомил с ней коллег. Некоторых даже смог заразить идеей.

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

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

О, паттерны — это отдельная история. По собственному опыту могу сказать, что самая большая печаль наступает, когда пытаешься во что бы то ни стало уложить решение в какой-нибудь паттерн. Такой этап я проходил. И в том или ином виде (не обязательно в виде классических паттернов типа GoF — это могут быть, например, и поиски какого-то универсального алгоритма на все случаи жизни, нормализация базы данных, применение шаблонных алгоритмов, использования сторонних библиотек и т. д.), думаю, многие программисты сталкивались с таким соблазном.

Паттерны полезны, когда они используются как инструмент для решения определенной задачи. И тогда они будут способствовать лучешму пониманию кода.

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

Я придерживаюсь несколько иного подхода.

Вначале я описываю в коде решение задачи на самом верхнем уровне, «крупными мазками». Такое решение пишу в форме вызова несуществующих приватных методов. Затем пишу реализацию для этих методов. Да, бывает, что некоторые из них требуют еще одного уровня абстракции, но в большинстве случаев уровень вложенности вызовов также не превышает двух, редко трех.

Переиспользование же при моем подходе не является основным критерием для создания или извлечения метода. Вместо этого я слежу за тем, чтобы каждый метод выполнял только одну задачу (см. принцип единственной ответственности), а его сигнатура однозначно указывала бы на то, какая именно эта задача.

Что делать с кодом, который действительно нужно переиспользовать, каждый раз требует индивидуального решения. Если это код переиспользуется внутри класса, то это действительно можно решить извлечением приватного метода. Если решаемая методом задача будет востребована внешними классами, тогда речь чаще идет об извлечении нового служебного класса либо о переносе метода в уже существующий, поскольку чаще всего (но не всегда!) в такой ситуации функционал переиспользуемого метода не соответствует предназначению исходного класса (того, в который мы пишем реализацию прямо сейчас).

Цитата из моего комментария:

Тест изначально создаётся на минимально возможном уровне, что минимизирует вероятность дублирования.

Нет, дублироавние не устраняю, потому что оно не возникает.

Дело в том, что TDD заведомо подразумевает, что тест покрывает одну конкретную ветку логики (то есть соответствует одному минимальному требованию). У каждого теста свои входные данные и свои проверяемые условия. И каждый проверяемый юнит проектируется так, чтобы он выполнял одну и только одну задачу. Так что рефакторить тут обычно нечего.

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

И тесты и код, реализующий логику приложения, — это, разумеется, не одно и то же. И требования к ним совершенно разные. Например, на тесты не пишутся тесты.

Я привел упрощённый пример. Он демонстрирует, что не всегда формулировка требований будет короче, чем тест.

Что касается граничных условий, ещё раз подчеркну, что всеобъемлющее тестовое покрытие не является целью TDD. Если вам нужны тесты для дополнительных проверок (то есть такие, которые будут зелёными изначально), вы их пишете, но не в рамках TDD.

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

вопрос автору статьи, кстати @iv660 подразумевает ли рефакторинг и рефакторинг ранее написанного теста? )

Ещё раз подчеркну основную мысль статьи, что целью TDD не является тестовое покрытие. Поэтому созданные в рамках TDD тесты лично я никогда иначе как для исправления найденных в них ошибок не изменяю.

Тест изначально создаётся на минимально возможном уровне, что минимизирует вероятность дублирования.

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

Если коротко подытожить: дорабатывать тесты можно и полезно, но это не является целью TDD.

Для TDD итерации крупноваты. Тесты слишком большие и не имеют однозначного соответствия требованиям.

Для соответствия требованиям я бы предложил следующий набор тестов:

  • IncorrectPasswordIsLessThan8CharactersLong

  • IncorrectPasswordIsMoreThan22CharactersLong

  • IncorrectPasswordContainsNonLatinLetters

  • IncorrectPasswordContainsNoSpecialCharacters

  • WeakPasswordContainsNoLetters

  • WeakPasswordIs8CharactersLong

  • MediumPasswordContainsNoNumerics

  • MediumPasswordIsLessThan10CharactersLong

  • MediumPasswordContainsOnlyOneNumericAtTheEnd

  • PasswordIsStrong

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

Код по сравнению с текстом слишком многословен, то есть имеет больший объем, причем объем кода тестов (в строках и знаках) обычно в разы больше объема покрываемого ими кода приложения (правда сложность - цикломатическая и т.п. - кода тестов обычно меньше).

Давайте сравним.

Спецификация метода sum(a, b)

Описание

Метод sum(a, b) принимает два целых числа и возвращает их сумму.

Параметры

  • a (int): Первое целое число.

  • b (int): Второе целое число.

Возвращаемое значение

  • (int): Сумма двух целых чисел a и b.

Предусловия

  • Параметры a и b должны быть целыми числами.

Постусловия

  • Метод возвращает целое число, которое является результатом сложения a и b.

Исключения

  • Если a или b не являются целыми числами, метод должен выбрасывать исключение TypeError.

Тесты (достаточные для разработки этого метода в соответствии с принципами TDD и требованиями спецификации)

Function test_sum_with_two_integers()
    result = sum(3, 5)
    assert result == 8

Function test_sum_with_non_integer_a()
    try
        sum(3.5, 5)
    expect TypeError

Function test_sum_with_non_integer_b()
    try
        sum(3, "5")
    expect TypeError

Я имел в виду под интерфейсами очень мелкие единицы — например, отдельный метод публичного API. Они изолированы и покрывают минимальное число требований. Поэтому менять контракт чаще всего просто не имеет смысла: проще и безопаснее заменить его новым, а старый удалить, когда он теряет актуальность.

Ключевой момент в том, что интерфейс отделен от реализации. Добавление нового метода не требует больших усилий и обычно не ломает существующую систему. В реальных проектах редко бывает так, что и реализацию интерфейса, и клиентский код пишет один и тот же разработчик. Поэтому «пока от него никто не зависит» — ситуация скорее исключительная.

Конечно, «чаще всего» не значит «всегда». Если изменения действительно минимальны и есть стопроцентная уверенность, что интерфейс еще нигде не используется (или что все места легко скорректировать), то изменить его допустимо. Но в общем случае добавление нового метода куда безопаснее, чем переписывание старого — именно поэтому я и делаю на это акцент.

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

Если же этот подход не годится, тогда вообще все равно, что писать сначала — тест или метод заглушку.

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

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

Например сейчас я могу задекларировать в IDE функцию/метод с сигнатурой и выбросить исключение "Not Implemented", нажать комбинацию клавиш и мне сгенерируется тест сьют, нажать пару раз tab и получить тест вызывающий нереализованную функцию.

Это всё ещё TDD или нет? Если нет - добро пожаловать в догматики и ваше дело проиграно. А если да, то вы нарушили свои же утверждения - пропустив ненужную фазу.

Очень хорошая иллюстрация и замечание по поводу слепого следования догмам.

Я считаю, что описанный вами подход вполне укладывается в концепцию TDD. Почему? Да потому что тест был написан до начала работы на реализацией. Это означает, что теперь вы не забудете, что этот метод у вас не реализован.

С другой стороны, в данной ситуации не вижу существенной разницы: сначала объявлять метод, а потом писать/генерировать тест, или наоборот. То есть следование догме в данном случае ситуацию никак не ухудшило бы. Более того, если бы вы стачала написали тест, вызывающий несуществующий метод, то вы могли бы на этом и остановиться, считая фазу Red завершенной. Необходимости объявлять метод и кидать в нем исключение уже не было бы. То есть, и тут вы получаете некоторую, пусть и минимальную, но экономию времени.

Отлично сформулировано. Готов подписаться под каждым словом.

К комментарию выше мне остается только добавить, что далеко не всегда описать требуемое поведение словами будет проще и дешевле, чем оформить это поведение в виде теста.

Код-ревью — тоже важная штука.

Я не рассматривал его в статье по той причине, что он работает на более крупном уровне, чем TDD.

Об этом я также упоминал в ответе на комментарий по поводу Agile.

Да, именно поэтому взаимодействие между компонентами удобно организовывать через ограниченное количество точек взаимодействия (A. K. A. интерфейсов).

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

А вот с этим утвреждением я полностью согласен.

Но мы говорим не о «специально спроектированной архитектуре», а об архитектуре, допускающей достаточную степень гибкости.

Тесты — не самоцель (каковую мысль я также пытался донести в своей статье).

Если у вас возникают проблемы со встраиванием тестов, это индикатор того, что с архитектурой не все в порядке. Если у вас возникают проблемы со встраиванием тестов, то у вас возникнут проблемы и со встраиванием нового функционала, и изменением имеющегося, и с рефакторингом.

Согласен: ядро TDD — именно цикл red → green → refactor. И я как раз описываю его как «тест → код → рефакторинг», а не «код → тест → …». Судя по вашей последовательности действий, это ответ скорее на вот этот комментарий: https://habr.com/ru/articles/933090/comments/#comment_28730776.

По поводу «рефакторинга, который делает тесты красными». По определению это не рефакторинг. Рефакторинг — это изменение внутренней структуры без изменения внешнего поведения, поэтому он не должен переводить зеленые тесты в красные. Если тест «покраснел», значит вы либо сломали поведение, либо начали вводить новое — и это уже новая red-фаза, которой должен предшествовать добавленный/измененный тест. См. Фаулер: https://martinfowler.com/books/refactoring.html

Кент Бек действительно показывает микрошаги, но он не смешивает фазы: красный — зафиксировать требование, зеленый — реализовать, рефакторинг — улучшить форму, сохраняя зеленый. Иногда большие изменения идут как цепочка R-G-R-G (несколько циклов), но «второе R» тут — это новый красный шаг, а не рефакторинг, меняющий поведение.

Итого: спор не о важности RGR, а о терминологии. В моем тезисе речь ровно о дисциплине: поведение задается тестом (red), код доводит до соответствия (green), улучшения формы делаются на зеленом (refactor).

На практике тесты приходится менять крайне редко. Если вы пишете тесты так, чтобы они отражали требования к системе, и в процессе работы над реализацией вам захотелось изменить тест, задумайтесь - а действительно ли дело в тесте? Чаще всего оказывается, что вы неправильно пишете реализацию.

Информация

В рейтинге
1 549-й
Откуда
Россия
Зарегистрирован
Активность

Специализация

Бэкенд разработчик
Ведущий