четверг, 5 апреля 2012 г.

Что значат для вас юнит-тесты?

С технической точки зрения юнит-тесты – это очень простой инструмент, основанный на паре несложных концепций: (1) тестируемый класс, (2) набор тестовых методов, завернутых в некоторый класс и (3) набор методов, с помощью которых можно удостовериться в том, что состояние тестового класса соответствует (или не соответствует) некоторому значению.

Это очень простая штуковина, которая может кардинальным образом повлиять на процесс разработки в целом. С одной стороны существует TDD (“test-first approach), при котором тесты «драйвят» не только процессом кодирования, но и процессом проектирования (т.е. дизайном системы). С другой стороны существуют разработчики с противоположной точкой зрения, которые считают юнит-тесты пустой тратой времени, потому что они не приносят никакой ценности пользователю.

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

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

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

Для меня юнит-тесты – это, прежде всего, «лакмусовая бумажка» хорошего дизайна. Хороший дизайн обладает такими замечательными характеристиками, как слабая связность и четкие отношения между классами и их клиентами. Если написать юнит-тест сложно, невозможно или он получается слишком большим и запутанным, то зачастую это говорит о проблемах не столько в самом тесте, сколько в дизайне тестируемого кода.

Свойство 1. Юнит-тесты положительно влияют на модульность и дизайн системы.

При дизайне системы я часто задаю себе вопрос: «Ок, это отличная идея, но как мы это дело будем тестить?». Если класс зависит от множества других классов, напрямую использует внешние ресурсы или берет на себя слишком много ответственности, то тестировать такой класс будет невероятно сложно. Я не большой фанат дизайна ради дизайна или дизайна только ради тестируемости. Но как показывает практика так далеко заходить и не нужно: хороший дизайн системы достаточно слабосвязный, чтобы покрыть тестами большую часть ключевых частей системы (еще о влиянии юнит-тестов на дизайн и архитектуру можно почитать в заметке “Идеальная архитектура”).

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

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

Свойство 2. Юнит-тесты – это отличный источник спецификации системы.

В одном из подкастов Кент Бек (Kent Beck), папа JUnit-а, TDD и экстремального программирования, дал следующую характеристику юнит-теста: каждый юнит-тест должен рассказывать историю о тестируемом классе. Юнит-тест – это полезнейший источник спецификации (формальной или неформальной), а не просто набор Assert-ов. Зачастую именно чтение юнит-тестов может помочь понять граничные условия и бизнес-правила, применимые для класса или подсистемы.

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

Свойство 3. Юнит-тесты позволяют повторно использовать потраченные программистом усилия.

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

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

Свойство 4. Юнит-тесты экономят наше время.

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

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

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

Заключение

Я не являюсь ярым фанатом юнит-тестов, я не поклонник TDD, и не сторонник 100% покрытия. Если понять тесты может только их автор, а для получения достойного покрытия было натыкано столько абстракций, что тут сам черт ногу сломит, то нам с вами не по пути. Для меня тесты – это, прежде всего возможность взглянуть на модульность системы под другим углом, рассмотреть классы с точки зрения их входов и выходов, проанализировать граничные условия и понять, что должен делать класс, а что нет. Как и любой инструмент его проще использовать неправильно, а для правильного использования нужен опыт и здравый смысл; тесты – это хорошая штука, только нужно научиться их готовить.

Дополнительные ссылки

1. SERadio Episode 167: The History of JUnit and the Future of Testing with Kent Beck

2. Kent Beck. Development Testing

Это два просто потрясающих подкаста с участием Кента Бека, в котором он рассказывает о культуре тестирования разработчиком (development testing), o JUnit, о Continuous Testing и о многом другом. Очень рекомендую!

12 комментариев:

  1. По поводу TDD. Я не очень понимаю твою позицию, во многом взгляды похожи, но чувствую, что есть расхождения в понимании терминологии. TDD - test driven development.

    Я тоже люблю проектировать систему с карандошом в руках, что не мешает мне затем сесть и прежде, чем наполнить пустые методы кодом - написать на них тесты. Что имеет несомненные плюсы -
    + уменьшается вероятность ложноположительных тестов,

    + будет написано ровно то, что нужно, может меньше, но точно не больше =)

    + помимо бумажки я сразу "попробую" своё API т.к. вынужден буду его использовать в тестах

    TDD это не означает кидаться и педалить, не делая предварительный дизайн, вопреки расхожему мнению. Интересно откуда это пошло. Но test-fist имеет огромные плюсы.

    ОтветитьУдалить
  2. Я знал, что именно мое отношение к TDD может вызвать вопросы.

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

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

    ОтветитьУдалить
  3. Совсем недавно писал очень похожий пост: http://eax.me/unit-testing/ Добавил в конце ссылку на вашу заметку.

    ОтветитьУдалить
  4. @afiskon: да, мысли и правда у нас похожие. Спасибо за интересную ссылку.

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

    Взамен юнит тесты требуют времени на разработку, и еще больше времени на поддержку. Из-за этого они могут стать серьезным увеличением бюджета любого проекта и нужно понимать, готовы ли мы идти на это, стоит ли оно того. Поэтому покрытие ради красивых цифр (тем более 100%) - это глупость. Можно сделать 100% покрытие и не получить никакого выхлопа от этих тестов, только головную боль от поддержки, а можно правильно покрыть 5-10% критичной функциональности и иметь уверенность в завтрашнем дне. Как при разработке любого продукта мы считаем ROI фич и выбираем первостепенные, так и при написании unit тестов, я считаю, нужно руководствоваться здравым смыслом.

    То же самое касается и TDD. Кому нравится - используйте, но это не панацея. Test last и грамотное, но далеко не 100% покрытие имеют такое же право на существование, как и все остальное.

    ОтветитьУдалить
  6. @Александр: спасибо за дополнение.

    ОтветитьУдалить
  7. Саша и Сергей,

    Да это очевидно, что TDD не панацея, но я убеждён, что верная его трактовка is just better, чем test-last. Могу подискутировать при желании на G+ например.

    ОтветитьУдалить
  8. Согласен, что в Г+ это будет более уместным это обсудить.

    Кстати, я предпочитаю не test-last, а тесты в процессе разработки.

    ОтветитьУдалить
  9. Неоднократно уже слышу и читаю... Тестируем методы... Методы тестировать - выхлопа нет. Тестировать надо поведение. Т.е нормально написать на один метод два теста WhenBla-Bla-Bla_ThenBla-Bla-Bla и аналогичный, но с другими параметрами. Это нормально. Тест фиксирует некоторое поведение класса. И название теста отражает, что мы фиксируем. Всегда умиляли тесты - FooTest(), где Foo - название функции, а внутри лесенкой разные значения для тестирования этой функции. Я сейчас не говорю про Pex. Поэтому полностью согласен с коллегой - покрытие тестами - вещь ни о чем... Важно - покрывать тестами поведение и фиксировать это спроектированное поведение. Сколько это будет в процентах - совершенно не важно. И еще - test first, test last or test in time - также не важно. Если есть понимание для чего тесты - их напишешь в любом случае. Однако, соглашусь что при подходе test first - без тестов не останешься по-любому...

    ОтветитьУдалить
  10. @Евгений: согласен.
    Единственное исключение, когда TestFoo вполне приемлем - это при использовании параметризованных тестов, когда TestFoo возвращает результат и все corner case-ы проверяются с помощью разных входных данных в виде:

    TestCase("data", Result = "expectedResult")
    string TestFoo(string data) {}

    ОтветитьУдалить
  11. Этот комментарий был удален автором.

    ОтветитьУдалить
  12. Мне тесты писать НЕ комфортно. Код в моих руках словно глина. Написание тестов -все равно что делать форму и тут же ее разбивать. А вот когда я результатом удовлетворен, я пишу тесты.

    ОтветитьУдалить