DISCLAIMER: Эта заметка подразумевает наличие у читателя базовых знаний о юнит тестах, в чем автор этих строк не сомневается, а также базовых знаний о проектирование по контракту, которые можно пополнить начиная отсюда.
На одном из выступлений, посвященных проектированию по контракту, один из моих коллег задал вполне резонный вопрос о связи контрактов и юнит тестов. Постусловия в контракте класса, как и юнит тесты говорят о гарантиях класса перед его клиентами, а поскольку юнит тесты являются в этом вопросе более мощным механизмом (сложные постусловия выразить в виде контрактов не всегда просто, а иногда и невозможно), то возникает вопрос о необходимости постусловий.
Итак, давайте вкратце рассмотрим, в каком именно месте находится пересечение контрактов и юнит тестов, и постараемся ответить на вопрос: являются ли постусловия избыточными при наличии юнит тестов?
Проблема любого кода заключается в том, что он не самодостаточен. Конечно же, глядя на код сразу же можно увидеть неоптимальность решения, найти банальные глупости или некорректное использование идиом языка программирования, но очень сложно ответить на главный вопрос: делает ли этот код то, ради чего он был написан или нет?
Проблема заключается в том, что код сам по себе не является корректным или не корректным, понятие корректности применимо только в связке: код – спецификация. (Подробнее об этом можно почитать в статье: Проектирование по контракту. Корректность ПО.)
Обычно эта проблема решается с помощью дублирования информации в комментариях, которые очень быстро устаревают и начинают противоречить самому коду; спецификация может быть даже формальной и располагаться на какой-нибудь wiki-странице; она может выражаться в виде утверждений, а также для этих целей могут использоваться юнит тесты.
Последний способ мы рассмотрим позднее, а пока перейдем к предпоследнему: использованию контрактов.
Контракты предназначены для задания спецификации непосредственно в коде в форме утверждений: предусловий, постусловий, инвариантов и утверждений. Сейчас нас не интересуют все тонкости этих вопросов, и на данный момент нам будет достаточно рассмотреть лишь предусловия и постусловия.
Предусловия метода класса B говорят о контракте клиента: что должны выполнить клиенты класса B, чтобы класс B приступил к выполнению своих обязанностей. Постусловия же методов класса B говорят о контракте класса B перед его клиентами: что гарантирует выполнить класс B, если его клиенты выполнили свою часть контракта.
Примером контракта может служить контракт метода Add интерфейса IList: если клиент интерфейса IList передаст не нулевой объект (предусловие), то он будет добавлен в данную коллекцию и количество элементов (Count) увеличится на 1 (постусловие).
Юнит тесты являются весьма популярной ныне техникой, при умелом использовании которой разработчик может получить ряд плюшек, начиная от упрощения процесса рефакторинга, заканчивая улучшением дизайна приложения за счет повышения модульности и ослабления связности.
Еще одним свойством юнит тестов является то, что они по своей сути описывают предполагаемое поведение классов или модулей, что вполне может рассматриваться как их спецификация. Тесты являются замечательной отправной точкой для нового члена команды, который хочет разобраться с работой конкретного класса, ведь с их помощью он увидит способ использования класса, необходимые входные данные и предполагаемые результаты.
Именно последний аспект юнит тестов так сильно напоминает предусловия и постусловия контракта класса. На самом деле, сходство, безусловно, есть, но есть и важные отличия.
Контракты – декларативны: они описывают гарантии класса перед его клиентами на высоком уровне, но они ничего не говорят, как эти гарантии обеспечиваются. Юнит тесты – императивны: они описывают множество шагов, которые должен выполнить класс или метод, чтобы получить необходимые результаты.
Контракты – описывают гарантии класса перед его клиентами, а юнит тесты обеспечивают выполнение этих гарантий.
«Падающий» юнит тест, как и нарушение постусловия, является багом в коде класса, а значит, клиенты наших классов никогда не должны столкнуться с нарушением предусловия. Разработчик класса никак не может гарантировать выполнение предусловий своими клиентами, но он может постараться выполнить свою часть контракта и обеспечить выполнение постусловий. Исключение, возникающее при нарушении постусловия – это такая себе страховка для клиента, которой он никогда не должен воспользоваться. Если метод по какой-то причине не может выполнить свою работу, то он должен четко сигнализировать своим клиентам с помощью соответствующего типа исключения.
Контракты, в отличие от юнит тестов всегда доступны клиентам, и с их помощью клиент может значительно быстрее понять, что ожидать от класса или метода. Даже когда речь идет о внутренней разработке контракты являются более предпочтительным способом описания намерений кода, в то время как на долю юнит тестов останется гарантия их выполнения.
Необходимость в дополнительных библиотеках для контрактного программирования связана еще и с тем, что системы типов большинства современных языков не столь четко выражают намерения программиста, как хотелось бы. Можно себе только представить насколько уменьшилось бы количество проверок аргументов, будь в нашем распоряжении non-nullable reference типы как в языке Eiffel или в функциональных языках.
Система типов является первым и самым важным средством передачи намерений, однако выразить свои намерения бывает довольно сложно, особенно когда речь касается интерфейсов. Интерфейс моделирует некоторую абстракцию, и каждый его метод обладает определенной семантикой, понять которую можно с помощью его имени, набора аргументов и документации. Другими, не менее важными источниками спецификации интерфейсов могут служить контракты, и, в некотором роде, и юнит тесты.
Давайте рассмотрим метод Add интерфейса IList, которые были однажды участниками одной из заметок:
Глядя лишь на имя метода, его параметры, а также на документацию нельзя четко ответить на вопрос о том, какое постусловие метода, т.е. что может ожидать вызывающий код и что должен обеспечить класс, реализующий этот контракт. Обязательно ли должен быть добавлен элемент и только один, или нет. Глядя на контракт – это очевидно, а вот без него понять это очень сложно.
Конечно, в этом вопросе могут помочь юнит тесты поставщика интерфейса, но проблема здесь заключается в том, что может существовать десятки реализаций интерфейса от разных производителей, что делает юнит тесты не лучшим источником спецификации.
Контракты интерфейсов являются особенно полезными, поскольку основной способ понимания того, для чего служит тот или иной метод класса не работает для интерфейсов. Чтобы понять назначение (семантику) метода класса мы используем reverse engineering, однако этот процесс осложняется для интерфейсов, поскольку для этого нам нужно проанализировать все возможные реализации.
Контракты же дают дополнительную информацию, которую сможет использовать клиент интерфейса, и, что не менее важно, класс, реализующий этот интерфейс.
Контракты и юнит тесты не являются конкурентами друг другу, даже несмотря на то, что и то и другое может использоваться в качестве выражения спецификации. У юнит тестов существует масса забот, в реальной системе их будет довольно много и выкусывать из них элементы спецификации возможно, но не так и просто. Любой инструмент следует использовать по назначению, и наши два героя не исключение.
Контракты – описывают абстракцию и ничего не говорят о том, как она устроена. Юнит тесты, в свою очередь, гарантируют, что реализация соответствует этому описанию и что гарантии, описанные в контракте, всегда выполняются.
На одном из выступлений, посвященных проектированию по контракту, один из моих коллег задал вполне резонный вопрос о связи контрактов и юнит тестов. Постусловия в контракте класса, как и юнит тесты говорят о гарантиях класса перед его клиентами, а поскольку юнит тесты являются в этом вопросе более мощным механизмом (сложные постусловия выразить в виде контрактов не всегда просто, а иногда и невозможно), то возникает вопрос о необходимости постусловий.
Итак, давайте вкратце рассмотрим, в каком именно месте находится пересечение контрактов и юнит тестов, и постараемся ответить на вопрос: являются ли постусловия избыточными при наличии юнит тестов?
Контракты
Проблема любого кода заключается в том, что он не самодостаточен. Конечно же, глядя на код сразу же можно увидеть неоптимальность решения, найти банальные глупости или некорректное использование идиом языка программирования, но очень сложно ответить на главный вопрос: делает ли этот код то, ради чего он был написан или нет?
Проблема заключается в том, что код сам по себе не является корректным или не корректным, понятие корректности применимо только в связке: код – спецификация. (Подробнее об этом можно почитать в статье: Проектирование по контракту. Корректность ПО.)
Обычно эта проблема решается с помощью дублирования информации в комментариях, которые очень быстро устаревают и начинают противоречить самому коду; спецификация может быть даже формальной и располагаться на какой-нибудь wiki-странице; она может выражаться в виде утверждений, а также для этих целей могут использоваться юнит тесты.
Последний способ мы рассмотрим позднее, а пока перейдем к предпоследнему: использованию контрактов.
Контракты предназначены для задания спецификации непосредственно в коде в форме утверждений: предусловий, постусловий, инвариантов и утверждений. Сейчас нас не интересуют все тонкости этих вопросов, и на данный момент нам будет достаточно рассмотреть лишь предусловия и постусловия.
Предусловия метода класса 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<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, однако этот процесс осложняется для интерфейсов, поскольку для этого нам нужно проанализировать все возможные реализации.
Контракты же дают дополнительную информацию, которую сможет использовать клиент интерфейса, и, что не менее важно, класс, реализующий этот интерфейс.
Заключение
Контракты и юнит тесты не являются конкурентами друг другу, даже несмотря на то, что и то и другое может использоваться в качестве выражения спецификации. У юнит тестов существует масса забот, в реальной системе их будет довольно много и выкусывать из них элементы спецификации возможно, но не так и просто. Любой инструмент следует использовать по назначению, и наши два героя не исключение.
Контракты – описывают абстракцию и ничего не говорят о том, как она устроена. Юнит тесты, в свою очередь, гарантируют, что реализация соответствует этому описанию и что гарантии, описанные в контракте, всегда выполняются.