Monday, September 22, 2008

Unit-тестирование в аутсорсинге

Unit-тестирование в оффшорной аутсорсовой модели разработки ПО имеет достаточно интересную специфику. С одной стороны, почти все программисты и даже некоторые менеджеры знают, что написание unit-тестов – это полезная практика, которая помогает создавать более стабильный, качественный и модифицируемый код. С другой стороны лишь небольшое количество проектов имеют хотя бы какие-нибудь unit-тесты, а те проекты, на которых unit-тесты живут и развиваются – вообще единичные случаи. Сразу скажу, что я буду говорить в основном о компаниях города Харькова, т.к. знаю я по большинству лишь харьковский рынок, но мне кажется, что какие-то результаты анализа можно аппроксимировать на всю отрасль в целом.

Прежде всего, нужно определиться, зачем вообще нужно unit-тестирование:

  • Unit-тестирование дает уверенность, что код работает как нужно хотя бы при положительном тестировании, то есть, когда мы проверяем его лишь с точки зрения правильного результата.
  • Оно также позволяет проверить граничные и неправильные входные данные, которые могут попасть на вход определенному участку кода (отрицательное тестирование) и, соответственно, поставить дополнительную защиту в таких случаях (проверка NullReferenceException и прочее).
  • Наверно, самый основной пункт – более безопасное изменение функциональности и рефакторинг: unit-тесты можно сравнить с парашютом, который спасает программиста, если тот в своем желании что-то исправить зайдет слишком далеко и сломает другие части системы.
  • Unit-тесты защищают приложение от переоткрытия старых дефектов, когда уже были однажды исправлены. Для этого перед тем, как делать исправление, нужно написать unit-тест, который воспроизводит ошибку, потом исправить ее и запустить тест заново. Во-первых, это быстрая проверка исправления, во-вторых, постоянный запуск теста в будущем даст возможность быть уверенным, что ошибка не воспроизведется.
  • Unit-тесты также снижают время на тестирование и отладку приложения, т.к. очень много дефектов вылавливается автоматически самими тестами, а не QA.
  • В это сложно поверить, но unit-тесты поощряют отделение интерфейса от реализации, использование паттернов проектирования, уменьшают связность кода (coupling) и, в целом, улучшают код. Связано это прежде всего с тем, что для того, чтобы протестировать метод, часто нужно сделать дополнительные операции – создать заглушки или моки, передать их в нужный класс/метод. Иногда тестируемый метод настолько велик, что его стоит разделить на несколько более мелких и т.д.
  • Ну, и последнее: написание unit-тестов позволяет глубже понять тестируемый код и, соответственно, сделать его более качественным.

Все, что я перечислил, не является чем-то принципиально новым. Многие западные программисты уже давно взяли unit-тесты себе на вооружение. Концепция unit-тестирования и рефакторинга существует уже больше 10 лет. Количество unit test и mock фреймворков постоянно растет, создаются целые методологии и практики по использованию unit-тестов в разработке программного обеспечения (одна из практик XP, Test Driven Development, рефакторинг, CI и т.д.). Есть куча англоязычных форумов, блогов, статей, посвященных этому вопросу. В то же время, когда смотришь на разработку ПО в нашей стране, создается впечатление, что мы застряли в каменном веке :) Однако, такому положению дел есть достаточно простое объяснение: несмотря на то, что полезность unit-тестов трудно оспорить, выгодны они далеко не всегда.

Есть как минимум несколько отрицательных сторон unit-тестирования:

  • Написание unit-тестов занимает время. Согласно различным исследованиям, написание unit-тестов увеличивает процесс написания кода в среднем в 2 раза. Часто этого времени нет или это время дорого.
  • Unit-тесты требуют постоянной поддержки, что также увеличивает время разработки приложения. Здесь особых цифр нет, но, думаю, это увеличивает время еще процентов на 5-10.
  • Не весь код легко протестировать при помощи unit-тестов (например, UI). Создание поддержки таких тестов часто требует колоссальных трудозатрат.
  • Ну, и наконец, программисты ленивы, а написание тестов – это достаточно рутинная задача, поэтому программисты зачастую не любят/хотят писать unit-тесты.

Почему же тогда в некоторых случаях unit-тесты помогают в процессе разработки, а в некоторых – мешают ей? И самое главное: в каких случаях их стоит писать, а в каких – нет? Это была присказка, дальше идут во многом мои размышления, вам виднее, соглашаться с ними или нет.

Если проанализировать существующее положение дел, то можно увидеть, что unit-тесты в большинстве своем используются либо в компаниях-лидерах разработки ПО, где с охотой применяются различные нововведения в индустрии, либо в компаниях, которые разрабатывают свое ПО в течение многих лет. И это, в принципе, понятно. Несмотря на то, что unit-тестирование – уже не молодая техника, успешно и более-менее массово ее начали применять лишь недавно, с появлением гибких методологий и развития теоретической базы: появления концепции рефакторинга кода, подходов к рефакторингу и целых методологий написания кода на базе unit-тестирования, например, TDD. Рефакторинг без unit-тестов становится очень опасным занятием, поэтому они часто идут в паре. Кроме того, unit-тесты особенно полезны в тех случаях, когда код достаточно часто меняется, а не просто сидит сиднем, становясь со временем legacy-кодом, в который все боятся лезть. А вот это как раз больше подход к разработке больших приложений в течение многих лет, когда выходят новые версии, содержащие какие-нибудь усовершенствования и новые фичи. В таких приложениях качество – превыше всего и unit-тесты позволяют его достичь.

В то же время оффшорная аутсорсовая разработка – это зачастую разработка не таких долгостроев, а даже если это и долгострой, то клиента очень часто больше волнуют бюджет и сроки, и в меньшей степени – качество (а иначе, зачем он тогда отдал разработку в оффшор, а не сделал заказ у себя на родине, где и качество будет выше, и цена в разы больше). Такому клиенту будет сложно «продать» идею unit-тестирования, ведь часто бывает, что клиент даже обычное тестирование не хочет проводить, считая, что это могут сделать и программисты. И так сроки и бюджет поджимают, не до абстрактных преимуществ какой-то новомодной практики. Хотя, справедливости ради нужно сказать, что для многих типов приложений unit-тесты – это действительно избыточность. Если вы пишете небольшое приложение, в котором нет серьезных участков кода, требующих частых изменений, unit-тесты вам, возможно, и не нужны. Основная мощь unit-тестов – в их возможности быстро одернуть программиста, когда он внес в приложение код, разрушающий существующий функционал, а это очень часто ведет к тому, что дефект уходит в production, так как полное функциональное тестирование в конце жизненного цикла разработки на проектах бывает далеко не всегда.

На своем личном опыте я могу сказать, что из около 10 компаний города Харькова, о которых я что-то знаю, unit-тестирование хотя бы в каком-то виде есть лишь где-то на 15-20% проектов, а в полной мере оно используется и того реже.

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

  • В динамически меняющемся приложении (например, если разработка идет по agile-методологии с постоянно меняющимися требованиями) unit-тесты могут, за счет увеличения времени разработки, существенно уменьшить время на отладку, тестирование и багфиксинг. В целом, затраты на последующую отладку зачастую компенсируют накладные расходы во время разработки.
  • В крупных или критичных к качеству приложениях unit-тесты могут существенно повысить качество результата и процесса в целом. Если у вас приложение такого типа, не раздумывайте долго – unit-тестирование вам необходимо.
  • Необходимо не забывать, что покрытие тестами 100% кода не защитит вас от ошибок, но лишь уменьшит их, поэтому 100%-ное покрытие не имеет особого смысла. В идеале unit-тестами должен быть покрыт лишь сложный и часто меняющийся код, в котором вероятность появления дефектов при изменении или рефакторинге выше, чем в остальном коде.
  • Для того чтобы уменьшить время на переделку тестов, лучше сразу же хорошо продумать архитектуру и дизайн приложения. Этот совет хорош не только с точки зрения unit-тестов, но и просто с точки зрения разработки ПО.
  • Для того чтобы уменьшить время на само написание тестов, лучше сразу стараться пользоваться различными паттернами проектирования, выделять интерфейсы, уменьшать связность кода. Опять же, совет касается не только и столько unit-тестирования, сколько разработки в целом.
  • Для избежания появления уже однажды исправленных дефектов следует писать unit-тесты на дефект перед тем, как исправлять его.
  • Для более систематического написания тестов можно применить какой-нибудь систематический подход к написанию unit-тестов, например, TDD. Это сложно и требует перестройки мозгов программистов, но практики TDD уверяют, что это дает отличные результаты в дальнейшем.
  • Внедрять unit-тесты, конечно же, проще на новом коде, на существующем же legacy-коде можно ввести следующее правило: не делать изменения в нем до тех пор, пока не будет написано несколько небольших тестов, проверяющих правильность его работы. Это даст возможность не только со временем покрыть тестами старый код, но еще и обезопасить себя от возможных последствий своих изменений.
  • Во многих случаях для написания unit-теста на метод необходимо серьезно извратиться, чтобы поставить заглушки на все внешние методы, которые вызывает тестируемый метод. Здесь можно посоветовать использовать любой из существующих mock фреймворков, например, Rhino Mocks, Moq или TypeMock.Net. Положа ногу на сердце, я бы рекомендовал Rhino Mocks, но иногда нужна мощь TypeMock.Net, или простота Moq – выбирайте, что вам больше по душе и по карману. Более подробно про моки и заглушки я расскажу в следующий раз.
  • Если вы понимаете, что на вашем проекте unit-тестирование нужно как воздух, а клиент не хочет и слушать об этом, вооружитесь аналитикой и опишите клиенту все «за» и «против» unit-тестов. Написание базовых тестов на основные классы и методы не займет много времени, но поможет вам сэкономить кучу времени в дальнейшем и, что более важно, поможет сделать продует более качественным и надежным.
  • Как я уже говорил, не все программисты любят писать тесты. Их тоже нужно расшевелить, как и клиента, показать достоинства, сделав обязанность писать тесты не каторгой, а приятной рутиной, которая позволяет уберечь себя в будущем от серьезных проблем.
  • Ну, и наконец, даже если вам сильно понравилась идея unit-тестирования, не забывайте, что, возможно, оно вам и не нужно. Действуйте прагматично и рационально. Не нужно бегать за всеми нововведениями в мире разработки ПО – это чревато.

А у вас на проекте используются unit-тесты? Какие практики вы используете, какие шишки набили?

8 comments:

  1. >> Для того чтобы уменьшить время на переделку тестов, лучше сразу же хорошо продумать архитектуру и дизайн приложения.
    >> Этот совет хорош не только с точки зрения unit-тестов, но и просто с точки зрения разработки ПО.

    ИМХО это ошибочно, никто и никогда не сможет все продумать, если гдето есть человек который считает что они может все продумать, это просто означает что люди которым это потом имплеменить, просто несчасные.

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

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

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

    ReplyDelete
  2. Да, Миш, согласен с тобой. Я тоже придерживаюсь мнения, что разработку нужно вести итеративно и не предлагаю "лочить" архитектуру, тем более что, как показывает практика, это часто просто невозможно, т.к. разработка начинается без полных требований. Я не сторонник водопада, я люблю гибкие методологии. По большей части я имел ввиду продумывание архитектуры и дизайна системы хотя бы на том уровне, который тебе доступен вначале работы над системой. В том числе это касается интерфейсов, паттернов, ДИ, слабой связности и т.д. А если сначала все взять, навалять абы как, авось потом зарефакторим, как делают некоторые начинающие программисты, то, на мой взгляд, ничего хорошего из этого не получится. В разработке ПО все должен быть определенный прагматизм и здравый смысл, а истина, как известно, посередине - нужно лишь ее найти.

    Спасибо за советы :) Как насчет остального текста? Согласен?

    ReplyDelete
  3. >>Согласен?

    та в общем то да

    с мокингом у тебя чето не то... а что касаеться нетехнических выводов, то с ними сложно быть согласным или не согласным, проще просто быть ;).

    ReplyDelete
  4. На днях допишу пост про моки - будет возможность прокомментировать :)

    ReplyDelete
  5. К оценке распространенности юнит-тестов, помимо есть/нет, я бы еще добавил случай, когда юнит тесты есть, но мягко говоря, кривоваты.

    Например я участвовал в 4-5 проектах, где юнит-тесты присуствовали, но проку с них было как с козла молока. Потому как это были либо примитивнейшие тесты вида: записали в базу Васю Пупкина - проверили, что с таким id в базе действительно Вася Пупкин (т.е. 50% теста проверяет, что корректно работает ADO.NET). Либо из-за плохой архитектуры и высокой связанности юнит-тесты превращались в, по сути, интеграционные и, чтобы проверить какую-нибудь отдельную фичу, приходится инциализировать и запускать добрых 2/3 компонентов системы. В результате написание и поддержка тестов превращалась в изрядный геморрой. А в конечном итоге на юнит-тесты почти всегда забивали, отмазываясь нехваткой времени :)

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

    Из трудностей - при дизайне в принципе не думали о unit тестах, поэтому код подлежащий тестированию предварительно приходится переколбашивать. Кроме того, тяжело тестировать код, тесно связанный с какой-то внешней инфраструктурой например, кастомные реализации стандартных сервисов WWF, или код, связанный с загрузкой сборок и reflection (когда нужно сделать что-нибудь вроде Assembly.Load() никакой мок не поможет).

    Из плюсов - проект недавно начался, кода пока еще немного, и сам заказчик тоже хочет их активно использовать, так что он нормально относится к тому времени, которое я на них трачу.

    ReplyDelete
  6. Спасибо за коммент. Согласен, что такое тоже бывает :)

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

    >> Кроме того, тяжело тестировать код, тесно связанный с какой-то внешней инфраструктурой например, кастомные реализации стандартных сервисов WWF, или код, связанный с загрузкой сборок и reflection
    Нужно выносить тончайший кусок кода в непосредственно слой доступа к внешней инфраструктуре и ее мочить. Тогда остальная логика приложения может быть легко покрыта любыми тестами. Динамическую загрузку сборок не тестировал, но что-то подсказывает, что можно вынести тот же слой доступа к этой загруженной сборке в отдельный слой - и его тоже можно будет замочить.

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

    ReplyDelete
  7. "Нужно выносить тончайший кусок кода в непосредственно слой доступа к внешней инфраструктуре и ее мочить." - например, есть кастомная реализация Wokflow Loader Service, т.е. сервис, который создает дерево activities по XML представлению workflow. Наша кастомная реализация наследуется от DefaultWorkflowLoaderService (который идет в комплекте с WWF), она выполняет некоторые действия, после чего вызывает базовую реализацию. Посему, для того, чтобы проверить наш CustomLoaderService нам нужно: 1) xoml файл, содержащий workflow - я включаю такие файлы в ресурсы сборки с юнит-тестами 2) workflow runtime с тестируемым сервисом, потому как реализация из DefaultWorkflowLoaderService без запущенной workflow runtime работать не будет.

    В результате самый простой тест выглядит примерно так (используется NMock):

    [TestMethod]
    public void NoExternalTypesPositiveTest()
    {
    using (WorkflowRuntime workflowRuntime = new WorkflowRuntime())
    {
    PrepareEnvironment(); //добавляем необходимые сервисы

    Stub.On... //тут настраиваем стабы и моки для collaborators тестируемого сервиса

    //считываем определение workflow и скармливаем его в workflow runtime
    XmlReader reader = TestWorflowDefinitionsFactory.GetSimpleWorkflowWithNoExternalTypes();
    if (reader == null)
    Assert.Fail("Can't find workflow definition for the test.");

    WorkflowInstance instance = workflowRuntime.CreateWorkflow(reader);

    //verify if created workflow is the same as original one
    //todo: to think how to automate or simplify this process
    Activity rootAcivity = instance.GetWorkflowDefinition(); //получаем root activity свежесозданного workflow

    //а тут идет долгий нудный код, проверющий, что созданный workflow в точности соотвествует исходному XML

    _mocks.VerifyAllExpectationsHaveBeenMet();
    }
    }
    }

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

    "А проект большой, если не секрет?" - пока нет. Но, учитывая, что сейчас на нем работает 6 программистов, и собираются брать еще 2, то через полгодика существенно разрастется.

    ReplyDelete
  8. С WWF все намного сложнее. Можно было бы вынести бизнес логику WF и тестировать ее БЕЗ WF runtime, полностью имитировав ее выполнение. Но в вашем случае вы хотите протестировать кастомизированный сервис, который может работать лишь при наличии инфраструктуры, поэтому в таком случае помочь чем-то будет сложно - я тоже мучался со стандартной инфраструктурой, правда, EF - это болезненно.

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

    ReplyDelete