четверг, 28 июня 2012 г.

Контракты vs Юнит тесты

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

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

Итак, давайте вкратце рассмотрим, в каком именно месте находится пересечение контрактов и юнит тестов, и постараемся ответить на вопрос: являются ли постусловия избыточными при наличии юнит тестов?

Контракты

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

Проблема заключается в том, что код сам по себе не является корректным или не корректным, понятие корректности применимо только в связке: код – спецификация. (Подробнее об этом можно почитать в статье: Проектирование по контракту. Корректность ПО.)

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

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

image

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

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

Примером контракта может служить контракт метода Add интерфейса IList: если клиент интерфейса IList передаст не нулевой объект (предусловие), то он будет добавлен в данную коллекцию и количество элементов (Count) увеличится на 1 (постусловие).

Юнит тесты

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

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

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

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

Контракты – описывают гарантии класса перед его клиентами, а юнит тесты доказывают, что эти гарантии обеспечиваются.

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

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

Практический пример. Контракты в интерфейсах

Необходимость в дополнительных библиотеках для контрактного программирования связана еще и с тем, что системы типов большинства современных языков не столь четко выражают намерения программиста, как хотелось бы. Можно себе только представить насколько уменьшилось бы количество проверок аргументов, будь в нашем распоряжении non-nullable reference типы как в языке Eiffel или в функциональных языках.

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

Давайте рассмотрим метод Add интерфейса IList, которые были однажды участниками одной из заметок:

// Объявление очень упрощено
[ContractClass(typeof(IListContract<>))]
public interface IList
<T>
{
   
/// <summary>
    /// Adds an item to the ICollection&lt;T>.
    /// </summary>
    void
Add(T item);

   
int Count { get
; }
   
// Не нужные члены удалены
}

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

[ContractClassFor(typeof(IList<>))]
internal abstract class IListContract<T> : IList
<T>
{
   
void IList
<T>.Add(T item)
    {
        
Contract.Ensures(this.Count == (Contract.OldValue<int>(this
.Count) + 1),
            
"Count == Contract.OldValue(Count) + 1"
);
    }

}

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

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

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

Заключение

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

Контракты – описывают абстракцию и ничего не говорят о том, как она устроена. Юнит тесты, в свою очередь, гарантируют, что реализация соответствует этому описанию и что гарантии, описанные в контракте, всегда выполняются.

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

  1. Еще главной фишкой контрактов в том, что они могут "работать" в compile-time. Если компилятор (и логическая система вывода) смог доказать, то runtime проверка будет не нужна.

    ОтветитьУдалить
  2. Ну да. К сожалению на данный момент это скорее теоретическая возможность, поскольку анализатор из Code Contract просто не справляется с нормального размера проектом. Да и ложных срабатываний получается слишком много.

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

    В общем, это очень классная штука, но сейчас к ней нужно относится с разумным прагматизмом.

    ОтветитьУдалить
  3. Позанудствую :). Назвать абстрактный класс с "I"bla-bla-bla - не комильфо ни разу. По сути. Мне кажется, что тесты и контракты - работают на разных уровнях. Тесты не работают в рантайм. Поэтому их можно делать довольно сложные и трудоемкие. Если то же сделать в постусловиях - просадку в производительности легко получить. По поводу декларативности - согласен. Вижу до фига тестов, из которых ну сложно понять, что же планировалось. В общем, мое мнение - контракты должны быть короткими и не тяжелыми (т.е. проверить входные условия, посмотреть, что вернем значение в правильном диапазоне и.т.д). Поэтому мне кажется, у них области не пересекаются.

    ОтветитьУдалить
  4. @eugene: занудство - это хорошо;)

    >> Назвать абстрактный класс с "I" bla-bla-bla - не комильфо ни разу.

    Ну, вообще-то это класс далеко не мой, а наших мелкомягких товарищей, подсмотренный в mscorlib.Contracts.dll. Он внутренний, поэтому это не страшно. Да, и я знаю, что он нарушает идиомы именования, но, он вполне отвечает своему назначению: Contracts For IList interface. Назвать его по другому будет не очень удобно.

    По поводу всего остального: +100 500. Контракты должны быть достаточно простыми, пусть и в ущерб полноте. Тесты же могут быть значительно более сложные и покрывать те части спецификации, которые с помощью контрактов выразить слишком сложно и/или дорого.

    ОтветитьУдалить
  5. Мне кажется, статья несколько не закончена. Не хватает примера юнит тестов для метода, покрытого контрактами. Как перехватывать Contract Exception?

    ОтветитьУдалить
  6. Сори за задержку с ответом.

    @Monsignor: я в статье несколько раз упоминал, что нарушение контракта - это баг в коде.

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

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

    ОтветитьУдалить
  7. Последний абзац гласит:

    Контракты – описывают абстракцию и ничего не говорят о том, как она устроена. Юнит тесты, в свою очередь, гарантируют, что реализация соответствует этому описанию и что гарантии, описанные в контракте, всегда выполняются.

    Означает ли это, что юнит тесты должны быть только "позитивными", то есть передавать в метод только те аргументы, которые приведут к успешному прохождению предусловий\инвариантов?

    Но что если мы внесли в код некоторые изменения, не обновив при этом постусловия всех затронутых (возможно неявно) методов? Так как "негативных" тестов у нас нет, а статический анализатор отключён из-за его сырости, то проблема проявится только в рантайме и приведёт к Contract.Failure. Получается, что один из основных бонусов юнит тестов - предотвращение регрессии, в данном случае отсутствует.

    ОтветитьУдалить
  8. В юнит тестах не нужно проверять нарушений предусловий, но стремится "нарушить" постусловия и инварианты - очень даже стоит.

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

    Так, например, для HashSet-а может проверяться, что в обоих случаях постусловие метода Add (что Count >= OldCount) выполняется как при наличие добавляемого элемента, так и при его отсутствии.

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

    ОтветитьУдалить
  10. Собственно последний абзац говорит о том, что юнит-тесты должны писать именно разработчики. Так как только разработчик может проверить выполнение контракта, который подразумевается в модуле.

    ОтветитьУдалить
  11. @Мурад: конечно, нарушение контракта должны обеспечиваться модульными тестами и эта обязанность лежит на разработчике.

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

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

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