Аннотация
Мок-объекты — это расширение практики разработки через тестирование (Test-Driven Development, TDD), которое способствует качественному объектно-ориентированному проектированию, направляя выявление целостной системы типов в кодовой базе. Однако они оказались не настолько полезными в качестве техники изоляции тестов от сторонних библиотек, как принято считать. В этой статье на развернутом примере описан процесс использования мок-объектов, а также приведены лучшие и худшие практики, выявленные в ходе использования этого подхода. Также представлен jMock — Java-фреймворк, в котором воплощен наш коллективный опыт.

1. Введение
Название «мок-объекты» не совсем точное. По сути, это техника для определения типов в системе, основанная на ролях, которые играют объекты.
В [10] мы представили концепцию мок-объектов как технику, призванную поддерживать разработку через тестирование. Мы утверждали, что ее использование способствует созданию лучше структурированных тестов и, что важнее, улучшает код предметной области благодаря сохранению инкапсуляции, сокращению зависимостей и прояснению взаимодействий между классами. В данной статье мы описываем, как усовершенствовали и скорректировали эту технику, основываясь на накопленном с тех пор опыте. В частности, теперь мы понимаем, что главное преимущество мок-объектов заключается в том, что мы изначально назвали «выявлением интерфейсов». Опираясь на этот опыт, мы также изменили реализацию нашего фреймворка, добавив поддержку динамического создания мок-объектов.
В оставшейся части этого раздела мы изложим наше понимание разработки через тестирование и принципов хорошего объектно-ориентированного программирования, а затем введем понятие мок-объекта. Далее в статье мы представим подход разработки через потребности (Need-Driven Development) в том виде, как он выражается с помощью мок-объектов, и продемонстрируем рабочий пример. Затем мы обсудим наш опыт разработки с использованием мок-объектов и опишем, как применили его в jMock — нашем фреймворке для работы с мок-объектами.
1.1. Разработка через тестирование
При разработке через тестирование программисты пишут тесты, называемые программистскими тестами, для некоторой единицы кода до того, как написать сам этот код [1]. Написание тестов — это акт проектирования, который определяет каждое требование в виде исполняемого примера, работоспособность которого можно продемонстрировать. По мере роста кодовой базы программисты выполняют ее рефакторинг [4], улу��шая ее структуру путем устранения дублирования и прояснения назначения кода. Эти рефакторинги можно проводить уверенно, поскольку подход «сначала тест» по определению гарантирует очень высокую степень покрытия тестами, чтобы выявлять ошибки.
Это преобразует проектирование из процесса изобретения, при котором разработчик усиленно размышляет над тем, что должна делать некоторая единица кода, а затем реализует ее, — в процесс выявления, при котором разработчик небольшими шагами наращивает функциональность, а затем извлекает структуру из работающего кода.
Использование TDD имеет множество преимуществ, но наиболее существенное из них заключается в том, что она направляет мышление программиста на проектирование кода с точки зрения его предполагаемого использования, а не его реализации. TDD также обычно приводит к созданию более простых решений, поскольку фокусируется на актуальных требованиях, а не на попытках предусмотреть будущие, и потому что акцент на рефакторинге позволяет разработчикам устранять недостатки проектирования по мере углубления понимания предметной области.
1.2. Объектно-ориентированное программирование
Исполняемая объектно-ориентированная (ОО) программа представляет собой сеть объектов, которые взаимодействуют, обмениваясь сообщениями. Как пишут Бек и Каннингем [2], «ни один объект — не остров. Все объекты существуют в отношениях с другими, полагаясь на них в получении услуг и управлении». Видимое поведение каждого объекта определяется тем, как он отправляет сообщения и возвращает результаты в ответ на получаемые сообщения.

Преимущество ОО заключается в том, что она определяет единицу модульности с внутренней связностью и минимальной связанностью с остальной системой. Это упрощает модификацию программного обеспечения за счет изменения того, как объекты компонуются в приложение. Чтобы достичь такой гибкости на практике, объекты в хорошо спроектированной системе должны отправлять сообщения лишь своим непосредственным соседям, что также известно как закон Деметры [15].
Обратите внимание, что в число непосредственных соседей объекта не входят объекты, ссылки на которые получены в результате вызова другого объекта. Программистам следует избегать написания кода, который выглядит так:
dog.getBody().getTail().wag();что в просторечии называют «крушением поезда». Это плохо, поскольку одна эта строка зависит от интерфейсов и подразумеваемой структуры трех различных объектов. Такой подход вплетает структурные зависимости между несвязанными объектами по всей кодовой базе. Решение этой проблемы описывается эвристическим принципом «Говори, а не спрашивай» [7], поэтому мы переписываем наш пример следующим образом:
dog.expressHappiness();и позволяем реализации dog решать, что это означает.
Если объект взаимодействует только со своими непосредственными соседями, мы можем описать его через услуги, которые он предоставляет, и услуги, которые он получает от этих соседей. Эти необходимые услуги мы называем исходящими интерфейсами, поскольку именно через них объект осуществляет вызовы других объектов.
1.3. Разработка объектно-ориентированных программ через тестирование
Если мы сосредоточимся на внешних взаимодействиях объекта, то сможем протестировать его, вызывая одну из его услуг и отслеживая результирующие взаимодействия с его соседями. Если мы программируем по принципу «сначала тест», мы можем описать эти тесты через исходящие интерфейсы (которые, возможно, еще не существуют), поскольку именно это позволяет нам определить, успешно ли выполнено действие.
Например, мы считаем, что вызов dog.expressHappiness() выполнен успешно, е��ли его реализация привела к вызову body.wagTail(). Это проектное решение, которое мы принимаем при разработке объекта dog, определяет, как реализовать одну из его услуг (обратите внимание, что мы по-прежнему избегаем «крушения поезда», не запрашивая у body сведения о реализации tail).
Если DogBody еще не имеет метода wagTail(), то тест выявляет новое требование, которое должен выполнить этот объект. Мы не хотим тут же останавливаться и реализовывать эту новую функциональность, поскольку это отвлекло бы нас от текущей задачи, а также потому, что реализация wagTail() может запустить непредсказуемо длинную цепочку последующих реализаций. Вместо этого мы предоставляем подставную реализацию объекта DogBody, которая эмулирует наличие этого метода. Теперь мы можем настроить этот подставной объект так, чтобы отслеживать, был ли фактически вызван метод wagTail() при тестировании expressHappiness().
Таким образом, мы тестируем объект, замещая его соседей объектами, которые проверяют, что их вызывают ожидаемым образом, и предоставляют стабы для любого поведения, требуемого вызывающим кодом. Эти объекты-заменители называются мок-объектами. Саму же технику TDD с использованием таких объектов мы также называем мок-объектами.
2. Мок-объекты и разработка через потребности
Мок-объекты меняют фокус TDD с осмысления изменений состояния объекта на осмысление его взаимодействий с другими объектами. Мы используем мок-объекты, чтобы писать тестируемый код так, будто у него уже есть все необходимое от его окружения. Этот процесс показывает нам, каким должно быть окружение объекта, чтобы мы могли затем его реализовать.
2.1. Разработка через потребности
Ключевой принцип бережливой разработки заключается в том, что ценность должна «вытягиваться» в мир потребностью, а не «выталкиваться» реализацией: «Эффект “вытягивания” заключается в том, что производство основано не на прогнозе; принятие обязательств откладывается до появления реальной потребности, которая указывает, чего на самом деле хочет клиент» [16].
Именно так протекает процесс программирования с мок-объектами. Тестируя объект изолированно, программист вынужден продумывать взаимодействия объекта с его коллабораторами на абстрактном уровне, возможно, еще до того, как эти коллабораторы будут созданы. TDD с использованием мок-объектов направляет проектирование интерфейсов, исходя из услуг, которые требуются объекту, а не только тех, которые он сам предоставляет. Результатом этого процесса становится система узких интерфейсов, каждый из которых определяет конкретную роль во взаимодействии объектов, а не широких интерфейсов, описывающих всю функциональность, предоставляемую классом. Такой подход мы называем разработкой через потребности.

Так, на рис. 2 показано тестирование объекта A. Чтобы удовлетворить потребности A, мы выявляем, что ему требуется услуга S. В процессе тестирования A мы мокируем ответственности S, не определяя ее конкретную реализацию.

Как только мы реализуем A так, чтобы он удовлетворял всем требованиям, мы можем переключиться и реализовать объект, выполняющий роль S. На рис. 3 он обозначен как объект B. Затем в ходе этого процесса мы выявим услуги, требуемые для B, и снова замокируем их, пока не завершим реализацию B.
Мы будем продолжать этот процесс, пока не достигнем слоя, который предоставляет реальную функциональность с использованием среды исполнения системы или внешних библиотек.
В результате наше приложение будет организовано в виде композиции объектов, общающихся через узкие ролевые интерфейсы (рис. 4). Или, как сказал один автор: «Каждый по способностям, каждому по потребностям!» [11].

Наш опыт показывает, что создаваемые таким образом системы характеризуются предельно плоскими иерархиями классов. Это позволяет избежать хорошо известных проблем, таких как проблема хрупкого базового класса [12], затрудняющих понимание и изменение системы.
Этот п��оцесс напоминает традиционную нисходящую разработку, при которой программист начинает с самого высокого уровня абстракции и затем, слой за слоем, детализирует систему. Цель состоит в том, чтобы каждый слой кода был написан на связном языке, определенном понятиями следующего уровня абстракции. Этого трудно достичь на практике, поскольку наиболее важные решения приходится принимать на раннем этапе. Кроме того, трудно избежать дублирования между компонентами нижнего уровня. TDD смягчает эту проблему, включая в процесс этап рефакторинга.
Восходящая разработка сопряжена с иными рисками. Каждому из авторов доводилось разрабатывать вспомогательный класс изолированно, в рамках более крупной задачи, а затем обнаруживать, что результат оказался непригодным из-за ошибочного понимания требований.
Мы считаем, что разработка через потребности помогает концентрироваться на актуальных требованиях и создавать связные объекты.
3. Рабочий пример
Для иллюстрации техники мок-объектов разберем конкретный пример. Представим компонент, кэширующий результаты выборки по ключу из фреймворка загрузки объектов. Загруженные экземпляры становятся невалидными по истечении заданного времени, поэтому периодически возникает необходимость выполнять их принудительное обновление.
При написании программистских тестов с мок-объектами мы используем единую структуру, предложенную в [10]:
Создайте тестовую фикстуру, включая все необходимые мок-объекты.
Задайте ожидания и стабы на мок-объектах.
Вызовите тестируемый метод.
Проверьте выполнение ожиданий и утвердите все постусловия.
Так тесты легче читать.
3.1. Загрузчик объектов
Наш первый программистский тест должен проверить простой успешный сценарий — загрузку и возврат объектов, отсутствующих в кэше. В этом случае мы ожидаем, что загрузчик будет вызван ровно один раз для каждого ключа, а также нам необходимо убедиться, что из кэша возвращается корректное значение. Используя фреймворк jMock, подробно описанный ниже, мы можем написать для этого тест JUnit [9] (для краткости мы опустили создание экземпляров. KEY и VALUE — это константы тестового случая, а не часть фреймворка jMock).
public class TimedCacheTest {
public void testLoadsObjectThatIsNotCached() {
// ожидаем, что метод load будет вызван
// ровно один раз с ключом KEY
// и вернет указанное значение VALUE
mockLoader.expect(once())
.method("load").with( eq(KEY) )
.will(returnValue(VALUE));
mockLoader.expect(once())
.method("load").with( eq(KEY2) )
.will(returnValue(VALUE2));
assertSame( "should be first object",
VALUE, cache.lookup(KEY) );
assertSame( "should be second object",
VALUE2, cache.lookup(KEY2) );
mockLoader.verify();
}
}jMock использует механизм рефлексии для сопоставления методов по их именам и параметрам. Синтаксис jMock для определения ожиданий может показаться непривычным. Первое ожидание эквивалентно следующему коду:
expectation = mockLoader.expect(once());
expectation.method("load");
expectation.with( eq(KEY) );
expectation.will(returnValue(VALUE));Мы объединяем эти вызовы в цепочку для повышения компактности и читаемости тестов; этот прием будет рассмотрен позже.
Тест предполагает, что Cache связан с неким объектом, выполняющим роль загрузчика.
TimedCache cache = new TimedCache (
(ObjectLoader)mockLoader
);Согласно тесту, чтобы получить искомое значение, ObjectLoader должен быть вызван ровно один раз для каждого ключа. В конце теста мы вызываем у мок-объекта метод verify(), чтобы убедиться, что все ожидания выполнены. Реализация, удовлетворяющая этому тесту, выглядит так:
public class TimedCache {
private ObjectLoader loader;
// конструктор
public Object lookup(Object key) {
return loader.load(key);
}
}3.1.1. Выявление нового интерфейса
На самом деле тест проверяет, что Cache правильно взаимодействует с любым объектом, реализующим интерфейс ObjectLoader; тесты для самого загрузчика объектов будут расположены в другом месте. Чтобы написать этот тест, все, что нам нужно, — это пустой интерфейс ObjectLoader, чтобы можно было сконструировать мок-объект. Чтобы тест прошел успешно, все, что нам нужно, — это метод load(), который может принимать ключ. Таким образом, мы выявили потребность в типе:
public interface ObjectLoader {
Object load(Object theKey);
}Мы свели к минимуму зависимость нашего кэша от любого конкретного фреймворка загрузки объектов, который мы в итоге будем использовать. Преимущество фреймворка мок-объектов также в том, что он позволяет избежать сложностей, связанных с настройкой окружения или изменчивыми данными на этом уровне тестирования. Это позволяет сосредоточиться на проектировании взаимодействий между объектами, а не на обеспечении работоспособности тестовой инфраструктуры.
3.2. Введение кэширования
Следующий тестовый случай: запросить значение дважды и убедиться, что оно не загружается повторно. Мы ожидаем, что загрузчик будет вызван ровно один раз для заданного ключа, после чего будет возвращено найденное значение. Вот наш второй тест:
public void testCachedObjectsAreNotReloaded() {
mockLoader.expect(once())
.method("load").with( eq(KEY) )
.will( returnValue(VALUE) );
assertSame( "loaded object",
VALUE, cache.lookup(KEY) );
assertSame( "cached object",
VALUE, cache.lookup(KEY) );
}Мы опустили вызовы метода verify(), которые в реальности обрабатываются автоматически классом MockObjectTestCase. Естественно, этот тест завершается неудачей с сообщением:
DynamicMockError: mockObjectLoader: no match found
Invoked: load(<key>)
in:
expected once and has been invoked:
load( eq(<key>) ), returns <value>
Сообщение говорит нам о том, что метод load() с ключом был вызван второй, непредусмотренный раз. Строки после “in:” описывают взаимодействия с ObjectLoader, которые мы ожидали увидеть в ходе теста. Чтобы тест проходил, можно добавить к Cache хэш-таблицу; именно хэш-таблица, а не простое поле для значения, нам и нужна, чтобы первый тест по-прежнему проходил успешно.
public Object lookup(Object key) {
Object value = cachedValues.get(key);
if (value == null) {
value = loader.load(key);
cachedValues.put(key, value);
}
return value;
}Конечно, это означает, что мы не можем загружать значения null, и мы будем рассматривать это как требование. В таком случае мы также добавили бы тесты, показывающие, что происходит, если значение отсутствует.
3.2.1. Тестирование взаимодействий
Сосредоточившись на взаимодействиях между объектами, а не на их состоянии, мы можем продемонстрировать, что кэшу не требуется повторно обращаться к загрузчику после того, как значение было извлечено. Хотя метод lookup() вызывается дважды, тест не будет считаться пройденным, если load() будет вызван более одного раза.
Еще одно преимущество в том, что тест даст сбой непосредственно в момент возникновения ошибки, а не по окончании своего выполнения. Трассировка стека приведет нас к методу load() внутри второго вызова lookup(), а сообщение об ошибке точно укажет, что произошло и что должно было произойти.
3.3. Введение фактора времени
У нас есть требование к поведению, зависящему от времени. Мы не хотим, чтобы в программистских тестах использовалось системное время, так как это приводит к недетерминированным сбоям и выдерживание пауз замедляет их выполнение. Поэтому мы вводим объект Clock, который возвращает объекты Timestamp. Пока мы не хотим вдаваться в детали того, когда объект станет считаться устаревшим, поэтому мы поручаем это решение объекту ReloadPolicy.
Это требование меняет предпосылку предыдущего теста на попадание в кэш, поэтому мы его адаптируем и переименуем его. Теперь тест должен запрашивать значение, а затем повторно запрашивать его в пределах времени его жизни. Мы ожидаем получить временную метку дважды — один раз при первом запросе и один раз при втором; ожидаем, что загрузчик будет вызван ровно один раз для заданного ключа и вернет найденное значение; а также ожидаем, что будет выполнено сравнение двух временных меток, чтобы убедиться, что кэш остается валидным.
Для краткости мы опустим создание объектов временных меток loadTime и fetchTime. Обновленный тест выглядит так:
public void testReturnsCachedObjectWithinTimeout() {
mockClock.expect(atLeastOnce())
.method("getCurrentTime").withNoArguments()
.will( returnValues(loadTime, fetchTime) );
mockLoader.expect(once())
.method("load").with( eq(KEY) )
.will( returnValue(VALUE) );
mockReloadPolicy.expect(atLeastOnce())
.method("shouldReload")
.with( eq(loadTime), eq(fetchTime) )
.will( returnValue(false) );
assertSame( "should be loaded object",
VALUE, cache.lookup(KEY) );
assertSame( "should be cached object",
VALUE, cache.lookup(KEY) );
}Итак, учитывая требования к времени жизни, мы передаем Clock и ReloadPolicy в конструктор.
TimedCache cache = new TimedCache(
(ObjectLoader)mockLoader,
(Clock)mockClock,
(ReloadPolicy)mockReloadPolicy
);Естественно, этот тест завершается неудачей с сообщением:
AssertionFailedError:
mockClock: expected method was not invoked:
expected at least once:
getCurrentTime(no arguments),
returns <loadTime>, then returns <fetchTime>
Ошибка была обнаружена во время выполнения verify() и показывает, что в Cache необходимо добавить поведение, связанное с учетом времени. Следующее изменение в TimedCache будет чуть более объемным. Мы добавим простой класс TimestampedValue для хранения пары «временная метка/значение», а метод loadObject() теперь будет загружать запрашиваемый объект и помещать его в cachedValues вместе с текущим временем в виде TimestampedValue.
private class TimestampedValue {
public final Object value;
public final Timestamp loadTime;
}
public Object lookup(Object theKey) {
TimestampedValue found =
(TimestampedValue) cachedValues.get(theKey);
if( found == null ||
reloadPolicy.shouldReload(
found.loadTime, clock.getCurrentTime() )
{
found = loadObject(theKey);
}
return found.value;
}3.3.1. Программирование через композицию
Как можно заметить, все, что нужно TimedCache, передается ему извне — либо через конструктор, либо через вызов метода. Такой подход в значительной степени навязывается программисту необходимостью подменять соседствующие объекты их мок-реализациями. Мы считаем это достоинством подхода, поскольку он подталкивает к проектированию небольших, узконаправленных объектов, взаимодействующих только с известными коллабораторами. Он также побуждает программиста создавать типы для представления абстрактных понятий в системе, таких как ReloadPolicy, что обеспечивает более четкое разделение ответственности в коде.
3.3.2. Программирование в абстракциях
Теперь тест также проверяет, что Cache получает текущее время дважды — по одному разу для каждого запроса — и корректно передает эти значения в ReloadPolicy. Пока нам не нужно определять, что такое «время» в нашей системе или когда значение устаревает; нас интересует лишь основная логика метода. Все, что связано с внешним представлением времени, абстрагировано в интерфейсы, которые мы еще не реализовали, — точно так же, как мы абстрагировали инфраструктуру загрузки объектов. Этот код обрабатывает временные метки как абстрактные типы, поэтому мы можем использовать заглушки. Это позволяет нам сосредоточиться исключительно на корректной реализации основного поведения кэширования.
3.4. Введение порядка вызовов
Нам также важно, чтобы временная метка для объекта не присваивалась до его загрузки в кэш. Иными словами, мы ожидаем, что текущее время будет получено только после загрузки объекта. Мы можем доработать тест, чтобы проверить соблюдение этого порядка вызовов.
public void testReturnsCachedObjectWithinTimeout() {
mockLoader.expect(once())
.method("load").with( eq(KEY) )
.will( returnValue(VALUE) );
mockClock.expect(atLeastOnce())
.after(mockLoader,"load")
.method("getCurrentTime").withNoArguments()
.will( returnValues(loadTime, fetchTime) );
mockReloadPolicy.expect(atLeastOnce())
.method("shouldReload")
.with( eq(loadTime), eq(fetchTime) )
.will( returnValue(false) );
assertSame( "should be loaded object",
VALUE, cache.lookup(KEY) );
assertSame( "should be cached object",
VALUE, cache.lookup(KEY) );
}Выражение after() выполняет сопоставление по идентификатору вызова — в данном случае, вызова другого объекта. Этот идентификатор может быть задан с помощью выражения id(); по умолчанию, как здесь, в качестве него используется имя метода. Поскольку наша реализация loadObject() получает текущее время и сохраняет его в переменную до загрузки объекта, тест завершается неудачей и мы получаем сообщение об ошибке:
DynamicMockError: mockClock: no match found
Invoked: getCurrentTime()
in:
expected at least once:
getCurrentTime(no arguments),
after load on mockObjectLoader,
returns <loadTime>, then returns <fetchTime>
Это сообщение говорит нам, что был зафиксирован вызов getCurrentTime(), но ожидался вызов getCurrentTime(), который происходит после вызова load(), — а это не одно и то же. Мы исправляем реализацию, переместив обращение к Clock.
3.4.1. Разные уровни точности
Теперь наш тест определяет дополнительное отношение, указывая, что Clock не должен опрашиваться до тех пор, пока объект не будет загружен. Это возможно благодаря тому, что мы тестируем взаимодействия между объектами, а не их конечное состояние, поэтому мы можем фиксировать события в момент их возникновения. Тот факт, что мы используем мок-реализации всех соседствующих объектов, означает, что теперь у нас есть возможность добавлять такие дополнительны�� утверждения.
С другой стороны, нам не важно, будет ли ReloadPolicy вызван более одного раза, — главное, чтобы вызовы происходили с правильными параметрами, так как он всегда возвращает одинаковый результат. Это означает, что мы можем смягчить требование к его вызову с «ровно один раз» на «хотя бы один раз». Аналогично, в jMock можно смягчить требования к параметрам, передаваемым в мок-объект, с помощью техники, которую мы называем «ограничениями»; она будет описана ниже.
3.5. Введение таймаута
Наконец, мы хотим проверить, что устаревшее значение действительно будет обновлено загрузчиком. В этом случае мы ожидаем, что загрузчик будет вызван дважды с одним и тем же ключом и вернет два разных объекта. При этом повторную загрузку запросит ReloadPolicy. Мы также ожидаем, что Clock вернет дополнительную временную метку для этой повторной загрузки.
public void testReloadsCachedObjectAfterTimeout() {
mockClock.expect(times(3))
.method("getCurrentTime").withNoArguments()
.will( returnValues(loadTime, fetchTime,
reloadTime) );
mockLoader.expect(times(2))
.method("load").with( eq(KEY) )
.will( returnValues(VALUE, NEW_VALUE) );
mockReloadPolicy.expect(atLeastOnce())
.method("shouldReload")
.with( eq(loadTime), eq(fetchTime) )
.will( returnValue(true) );
assertSame( "should be loaded object",
VALUE, cache.lookup(KEY) );
assertSame( "should be reloaded object",
NEW_VALUE, cache.lookup(KEY) );
}Существующая реализация проходит этот тест. В таком случае мы можем поставить эксперимент, намеренно сломав код, чтобы убедиться, что успех обусловлен корректностью кода, а не неполнотой теста.
Как и прежде, этот тест проверяет работу таймаута, не требуя реального ожидания, поскольку мы абстрагировали из Cache всю логику, связанную с учетом времени. Мы можем спровоцировать принудительную повторную загрузку, возвращая другое значение из ReloadPolicy.
3.6. Написание тестов в обратном порядке
На практике мы заметили, что пишем тесты в другом порядке — том, который повторяет ход наших мыслей в процессе TDD.
Определить тестируемый объект и написать вызов метода с необходимыми параметрами.
Сформулировать ожидания, которые описывают услуги, требуемые объектом от остальной системы.
Представить эти услуги в виде мок-объектов.
Создать остальное окружение, в котором будет выполняться тест.
Определить все постусловия.
Выполнить проверку мок-объектов.
В результате следования принципу «Говори, а не спрашивай» у нас часто не оказывается никаких постусловий, которые нужно было бы проверять (шаг 5). Это может удивить программистов, которые не задумываются о том, как их объекты взаимодействуют.
Эти шаги показаны в примере ниже:
public void testReturnsNullIfLoaderNotReady() {
Mock mockLoader = mock(ObjectLoader.class); // 3
mockLoader.expect(never()) // 2
.method("load").with( eq(KEY) )
mockLoader.stub() // 4
.method("isReady").withNoArguments()
.will( returnValue(false) );
TimedCache cache =
new TimedCache((ObjectLoader)mockLoader); // 4
Object result = cache.lookup(KEY); // 1
assertNull( "should not kave a KEY", // 5
result );
mockLoader.verify(); // 6
}Особенно важно убедиться, что вы точно понимаете, какой объект тестируете и какова его роль (шаг 1) — по нашим наблюдениям, именно здесь чаще всего возникает путаница, когда у людей возникают трудности с написанием тестов. Как только это проясняется, продолжить с шага 2 уже несложно.
3.7. Выводы
Разбор этого примера показал, как программисты могут вести процесс выявления ролей объектов, сосредоточиваясь на взаимодействиях между ними, а не на их состоянии. Написание тестов задает основу для размышлений о функциональности, а мок-объекты предоставляют инструментарий для проверки этих отношений и имитации ответов.
Программисты могут полностью сосредоточиться на непосредственной задаче, полагая, что необходимая инфраструктура будет доступна, поскольку ее можно выстроить позднее. Требование передавать мок-объекты в целевой код приводит к использованию объектно-ориентированного стиля, в большей мере основанного на композиции, чем на наследовании. Все это способствует созданию архитектурных решений с четким разделением ответственности и высокой модульностью.
Мок-объекты также позволяют программистам делать свои тесты ровно настолько точными, насколько это необходимо. В приведенном примере показаны и более строгое утверждение о том, что один вызов должен следовать за другим, и менее строгое — о том, что вызов может быть совершен более одного раза. Фреймворк jMock Constraint будет обсуждаться ниже.
Единственный недостаток этого примера в том, что требования для самого TimedCache не исходили от клиента более высокого уровня, как это обычно бывает.
4. Мок-объекты на практике
В совокупности авторы этой статьи работали с мок-объектами в самых разных проектах на протяжении более 5 лет. Мы также общались с другими разработчиками, использовавшими эту технику. Самый долгий проект длился 4 года, а самая большая команда насчитывала 15 разработчиков. Мы использовали ее с Java, C#, Ruby, Python и JavaScript в приложениях самого разного масштаба — от корпоративных систем до решений для портативных устройств.
Мок-объекты помогают в проектировании, но они не заменяют опытных разработчиков. Наш опыт показывает, что тесты, основанные на моках, быстро становятся слишком сложными, если система плохо спроектирована. Использование мок-объектов усугубляет проблемы вроде высокой связанности и неверного распределения ответственностей. Одним из ответов на подобные трудности может быть отказ от использования мок-объектов, однако мы считаем, что лучше воспринимать это как стимул к улучшению структуры системы. В этом разделе описаны некоторые правила, которые мы считаем полезными.
4.1. Мокируйте только те типы, которыми вы владеете
Мок-объекты — это техника проектирования, поэтому программисты должны создавать моки лишь для тех типов, которые они могут изменять. В противном случае они не смогут скорректировать код в соответствии с требованиями, которые возникают в процессе разработки. Не следует создавать моки для стабильных, неизменяемых типов — например, тех, что определены средой выполнения или внешними библиотеками. Вместо этого стоит писать небольшие обертки, которые реализуют абстракции приложения поверх базовой инфраструктуры. Эти обертки изначально определяются в рамках тестов, управляемых потребностями.
Мы убедились, что это важное правило помогает разработчикам понять технику мок-объектов. Оно подчеркивает первичную роль проектирования в использовании мок-объектов, которую нередко затмевает их использование для тестирования взаимодействий со сторонними библиотеками.
4.2. Не используйте геттеры
Мы впервые пришли к этой технике, когда Джон Нолан поставил перед собой задачу писать код без геттеров. Геттеры обнажают реализацию, что приводит к повышению связности между объектами и способствует неверному распределению ответственностей между модулями. Отказ от геттеров заставляет смещать акцент с состояния объекта на его поведение, что характерно для ответственностно-ориентированного проектирования (Responsibility-Driven Design).
4.3. Явно указывайте на то, что не должно происходить
Тест — это спецификация требуемого поведения, и зачастую его читают спустя долгое время после того, как он был написан. Некоторые условия остаются неясными, если их просто опустить в тесте. Спецификация, согласно которой метод не должен вызываться, отличается от спецификации, которая вообще не упоминает этот метод. Во втором случае сторонним читателям неясно, является ли вызов метода ошибкой. Мы часто пишем тесты, которые явно указывают, что определенные методы не должны вызываться, — даже когда это не обязательно, — просто чтобы сделать наши намерения понятными.
4.4. Конкретизируйте в тесте как можно меньше
При тестировании с мок-объектами важно найти правильный баланс между точной спецификацией требуемого поведения модуля и гибкостью теста, который позволяет кодовой базе с легкостью эволюционировать. Один из рисков TDD состоит в том, что тесты становятся «хрупкими», то есть начинают давать сбой, когда программист вносит в код приложения несвязанные изменения. Это происходит, если тест излишне детализирован и проверяет артефакты реализации, а не выражение каких-либо требований к объекту. Набор тестов, содержащий много хрупких тестов, замедляет разработку и препятствует рефакторингу.
Решение этой проблемы заключается в том, чтобы пересмотреть код и определить, следует ли ослабить требования или же проблема в ошибочной структуре объекта, которую необходимо изменить. Перефразируя Эйнштейна, спецификация должна быть настолько точной, насколько это возможно, но не точнее.
4.5. Не используйте моки для тестирования изолированных объектов
Если объект не имеет отношений с другими объектами в системе, его не нужно тестировать с помощью мок-объектов. Тест для такого объекта должен лишь проверять значения, возвращаемые его методами. Как правило, эти объекты хранят данные, выполняют независимые вычисления или представляют собой элементарные значения. Хотя это утверждение может показаться очевидным, нам встречались разработчики, которые пытались использовать мок-объекты там, где в них не было реальной необходимости.
4.6. Не добавляйте поведение
Мок-объекты по своей сути остаются стабами и не должны вносить дополнительной сложности в тестовое окружение; их поведение должно быть очевидным [10]. Мы считаем, что стремление добавить реальное поведение мок-объекту, как правило, является симптомом неверного распределения ответственностей.
Типичным примером может быть ситуация, когда один мок-объект вынужден анализировать свои входные данные, чтобы вернуть другой мок-объект — например, разбирая сообщение о событии. Это создает риск того, что в итоге будет протестирована тестовая инфраструктура, а не целевой код.
Этой проблемы удается избежать в jMock, поскольку его инфраструктура сопоставления вызовов позволяет тесту задать ожидаемое поведение. Например:
mock.expect(once())
.method("retrieve").with(eq(KEY1))
.willReturn(VALUE1);
mock.expect(once())
.method("retrieve").with(eq(KEY2))
.willReturn(VALUE2);4.7. Мокируйте только ближайших соседей
Объект, которому в своей реализации приходится взаимодействовать с целой сетью других объектов, скорее всего, окажется хрупким, поскольку имеет слишком много зависимостей. Один из симптомов этой проблемы — тесты, которые сложно настраивать и читать, поскольку в них приходится конструировать аналогичную сеть мок-объектов. Юнит-тесты работают лучше всего, когда они сосредоточены на тестировании одного элемента за раз и задают ожидания только для объектов, являющихся их ближайшими соседями.
Решение может заключаться в том, чтобы проверить, тот ли объект вы тестируете, либо ввести связующую роль между объектом и его окружением.
4.8. Слишком много моков
Похожая проблема возникает, когда тесту приходится преодолевать слишком много мок-объектов на пути к целевому коду, даже если все они — ближайшие соседи. И в этом случае тесты, скорее всего, будет сложно настраивать и читать. Решением также может стать правильное перераспределение ответственностей или введение промежуточной роли. Кроме того, возможно, что тестируемый объект слишком велик и его следует разбить на более мелкие объекты с более четким функционалом, которые будет проще тестировать.
4.9. Создание новых объектов
Невозможно протестировать взаимодействие с объектом, который создается внутри целевого кода, включая вызовы его конструктора. Единственное решение — вмешаться в процесс создания объекта, либо передав ему экземпляр извне, либо обернув вызов new. Мы нашли несколько полезных подходов к решению этой проблемы. Чтобы передать готовый экземпляр, программист может добавить параметр в конструктор или в соответствующий метод тестируемого объекта — в зависимости от отношений между ними. Чтобы обернуть создание экземпляра, тест может либо передать объект-фабрику, либо добавить фабричный метод в тестируемый объект.
Преимущество объекта-фабрики в том, что тест может задавать ожидания относительно аргументов, используемых для создания нового экземпляра. Недостаток — в том, что это требует создания нового типа. Объект-фабрика нередко представляет полезное понятие в предметной области, такое как Clock в нашем примере.
Фабричный метод просто возвращает новый экземпляр нужного типа, но может быть переопределен в подклассе тестируемого объекта таким образом, чтобы возвращать мок-реализацию. Это прагматичное решение, менее тяжеловесное, чем создание отдельного типа-фабрики, и оно может служить эффективной временной реализацией.
Некоторые разработчики предлагают использовать такие техники, как аспектно-ориентированное программирование или манипуляцию загрузчиками классов, для подмены реальных объектов. Это полезно для устранения внешних зависимостей, но не помогает улучшить структуру кодовой базы.
5. Ошибочные представления о мок-объектах
То, что мы сейчас подразумеваем под термином «мок-объекты», часто не совпадает с пониманием других людей и даже с тем, что подразумевали под ним раньше мы сами. В частности:
5.1. Моки — это просто стабы
Стабы — это заглушки, фиктивные реализации рабочего кода, возвращающие заранее подготовленные результаты. Мок-объекты выступают в роли стабов, но также содержат утверждения, отслеживающие взаимодействие целевого объекта с его соседями.
5.2. Мок-объекты следует использовать только на границах системы
Мы же придерживаемся противоположного мнения: мок-объекты наиболее полезны, когда их применяют для управления проектированием тестируемого кода. Это означает, что они наиболее ценны внутри системы, где интерфейсы можно изменять. Моки и стабы, безусловно, могут быть полезны для тестирования взаимодействий со сторонним кодом, особенно для избежания зависимостей в тестах, но, на наш взгляд, это вторичный аспект данной техники.
5.3. Собирайте состояние в ходе теста и проверяйте его по окончании
Некоторые реализации фиксируют значения при вызове методов мок-объекта, а затем проверяют их в конце теста. Частным случаем этого подхода является паттерн Самошунтирование [3], при котором тестовый класс сам выступает в роли мока.
public class TimedClassTest
implements ObjectLoader
{
final Object RESULT = new Object();
final Object KEY = new Object();
int loadCallCount = 0;
Object lookupKey;
// метод ObjectLoader
public Object lookup(Object key) {
loadCallCount++;
lookupKey = key;
return LOOKUP_RESULT;
}
public testReturnsCachedObjectWithinTimeout() {
// настройка остальной части теста...
assertSame( "loaded object",
RESULT, cache.lookup(KEY) );
assertSame( "cached object",
RESULT, cache.lookup(KEY) );
assertEquals("lookup key", KEY, lookupKey);
assertEquals("load call count",
1, loadCallCount);
}
}Такой подход прост и самодостаточен, но имеет два очевидных недостатка. Во-первых, любые сбои обнаруживаются после возникновения ошибки, а не в момент ее совершения, тогда как размещение утверждений непосредственно в моке приводит к падению теста в точке, где происходит лишний вызов load(). По нашему опыту, мгновенные сбои понять и исправить проще, чем post-hoc утверждения. Во-вторых, этот подход размазывает реализацию проверок по тестовому коду, повышая когнитивную нагрузку. Однако наше главное возражение заключается в том, что такой подход не фокусируется на взаимодействиях между тестируемым объектом и его соседями, а именно это, на наш взгляд, является ключом к написанию хорошо компонуемого, ортогонального кода. Как отмечает сам автор, Самошунтирование, скорее всего, будет временной заглушкой, поскольку плохо масштабируется.
5.4. Тестирование с помощью мок-объектов дублирует код
В некоторых случаях использование мок-объектов задает поведение, буквально повторяющее логику целевого кода, что делает тесты хрупкими. Это особенно часто встречается в тестах, которые мокируют сторонние библиотеки. Проблема здесь в том, что мок-объекты используются не для управления проектированием, а для работы с чужими проектными решениями. В определенном смысле, мок-объекты должны отражать сценарий для целевого кода, но только потому, что проектирование этого кода должно задаваться тестом. Сложная настройка моков для теста — верный признак того, что в системе отсутствует необходимый объект.
5.5. Мок-объекты препятствуют рефакторингу, вызывая одновременный сбой множества тестов
Некоторые программисты предпочитают тестировать целые кластеры объектов, чтобы иметь возможность проводить рефакторинг кода внутри этого кластера, не меняя тесты. Однако у такого подхода есть свои недостатки, поскольку каждый тест начинает зависеть от большего числа объектов, чем при тестировании, основанном на мок-объектах. Во-первых, изменение основного класса из-за нового требования может повлечь за собой правки во множестве тестов, особенно тестовых данных, которые не так легко поддаются рефакторингу, как код. Во-вторых, поиск ошибки при падении теста может усложниться, потому что связь между тестами и проблемным кодом становится менее прямой; в худшем случае может даже потребоваться отладчик. По нашему опыту, ошибки в тестах, основанных на мок-объектах, более конкретны и очевидны, что сокращает цикл внесения изменений в код.
5.6. Использование строк для указания имен методов делает тесты хрупкими
Наши динамические мок-фреймворки выполняют поиск методов по их именам, используя строки. Эти строки не распознаются и не обновляются инструментами рефакторинга в средах разработки при переименовании мокируемого метода, из-за чего соответствующие тесты ломаются. Некоторые программисты полагают, что необходимость постоянно чинить тесты будет слишком сильно замедлять процесс рефакторинга. На практике, в кодовой базе, разработанной с применением мок-объектов, типы, как правило, используются более локально, поэтому ломается меньше тестов, чем можно было бы ожидать, и эти сбои происходят предсказуемо, так что требуемые правки очевидны. Определенные дополнительные трудозатраты остаются, но мы считаем, что они являются оправданной платой за существенно возросшую гибкость описания ожиданий.
6. jMock: инструмент разработки через потребности
jMock — это фреймворк с открытым исходным кодом, который предоставляет удобный и выразительный API для мокирования интерфейсов, определения ожидаемых вызовов и создания стабов для вызываемого поведения. jMock воплощает уроки, которые мы вынесли за последние несколько лет использования мок-объектов в процессе разработки через тестирование.
Разработка через тестирование, особенно в сочетании с парным программированием [18], обладает особым ритмом, который дает обратную связь и поддерживает мотивацию. Этот ритм нарушается, когда программистам приходится прерывать написание теста для создания вспомогательного кода.
У первой библиотеки мок-объектов была эта проблема: программисты, выявившие интерфейс в процессе написания теста, были вынуждены останавливаться, чтобы создать его мок-реализацию. API jMock использует динамическую генерацию кода для создания мок-реализаций на лету во время выполнения и делает все возможное (в рамках ограничений языка Java), чтобы помочь программистам при написании и, впоследствии, чтении этих ожиданий.
Основной точкой входа в API jMock является MockObjectTestCase — класс, расширяющий TestCase из JUnit и обеспечивающий поддержку использования мок-объектов. MockObjectTestCase предоставляет методы, которые облегчают чтение ожиданий и помогают программисту избегать ошибок за счет автоматической проверки мок-объектов по завершении теста.
Мок-объекты создаются с помощью метода mock(…), который принимает объект Class, представляющий мокируемый интерфейс, и возвращает мок-объект, реализующий этот интерфейс. Затем этот мок-объект можно привести к мокируемому типу и передать в тестируемый код предметной области.
class TimedCacheTest
extends MockObjectTestCase
{
Mock mockLoader = mock(ObjectLoader.class);
TimedCache cache = new TimedCache (
(ObjectLoader)mockLoader );
...
}Мок-объект, возвращаемый методом mock(…), предоставляет методы для настройки ожиданий.
jMock специально спроектирован для написания тестов, которые выполняются и читаются как форма документации. Большая часть его API — это удобочитаемый синтаксический сахар для определения ожиданий. Такая цель привела к созданию API, весьма нестандартного по сравнению с типичными Java-решениями, поскольку он пытается реализовать встроенный предметно-ориентированный язык [6] внутри Java. В частности, этот API намеренно нарушает закон Деметры и избегает глагольных названий методов в повелительном наклонении.
Ожидание задается через серию выражений. Первое выражение указывает, хотим ли создать ожидание вызова или стаб. jMock рассматривает стаб как вырожденную форму ожидания, которое не обязано фактически выполняться. Однако различие между стабами и ожиданиями настолько важно для программиста, что jMock делает это различие очевидным в тестовом коде.
Последующие выражения определяют, какие вызовы методов мока будут проверяться данным ожиданием (задавая правила сопоставления), определяют поведение стаба для соответствующих методов и, при необходимости, идентифицируют само ожидание, чтобы на него можно было ссылаться в правилах сопоставления последующих ожиданий. Ожидание содержит несколько правил сопоставления и считается выполненным, если вызов удовлетворяет всем этим правилам.
Каждое выражение в составе ожидания представлено в тестовом коде вызовом метода API. Каждый такой метод возвращает ссылку на интерфейс, которая позволяет программисту задать следующее выражение, которое, в свою очередь, возвращает еще один интерфейс для следующего выражения, и т.д. Цепочка вызовов, полностью определяющая ожидание, запускается вызовом expect() или stub() на самом моке.
mock.expect(ожидание)
.method(имя метода)
.with(ограничения для аргументов)
.after(идентификатор предыдущего вызова)
.match(другое правило сопоставления)
.will(поведение стаба)
.id(идентификатор этого вызова);
mock.stub().method(имя метода)...Имена методов, связанных в цепочку при настройке ожидания, делают это ожидание понятным. Такой API в стиле цепочки вызовов гарантирует, что все ожидания будут описаны в единой последовательности: ожидание или стаб, имя метода, аргументы, порядок выполнения и прочие правила сопоставления, поведение стаба, идентификатор. Благодаря этому становится проще работать с тестами, написанными разными людьми.
При использовании в среде разработки с автодополнением этот API действует как «мастер», проводящий программиста по всем шагам настройки ожидания.
6.2. Гибкие и точные спецификации
Чтобы избежать проблем избыточной детализации, описанных выше, jMock позволяет программисту определять ожидаемые вызовы методов в виде ограничений, которым они должны удовлетворять, а не в виде конкретных значений. Ограничения используются для проверки значений аргументов и даже имен методов. Это позволяет программисту не принимать во внимание те аспекты взаимодействий объекта, которые не относятся к тестируемой функциональности.
Ограничения обычно используются для определения допустимых значений аргументов. Например, можно проверить, что строка содержит ожидаемую подстроку, игнорируя несущественные детали форматирования и пунктуации. И хотя чаще всего аргументы сравниваются с ожидаемыми значениями, ограничения четко определяют, проводится ли сравнение на эквивалентность (метод equals) или идентичность (оператор ==). Также распространен случай, когда параметры полностью игнорируются — для этого используется ограничение IS_ANYTHING.
Ограничения создаются «сахарными» методами класса MockObjectTestCase. Метод with интерфейса построителя ожиданий определяет ограничения для аргументов. Приведенное ниже ожидание указывает, что метод pipeFile должен быть вызван один раз с двумя аргументами: первый должен быть эквивалентен ожидаемому fileName, а второй должен быть объектом mockPipeline.
mock.expect(once())
.method("pipeFile")
.with(eq(fileName),same(mockPipeline))
.will( returnValue(fileContent) );Часто бывает полезно сопоставлять не только значения параметров. Например, нередко возникает необходимость задать правила для целого подмножества методов объекта, таких как геттеры свойств JavaBean. В таких случаях jMock позволяет программисту задать ограничение на имена методов. В сочетании с механизмом создания результатов по умолчанию это позволяет не принимать во внимание не связанные с тестом аспекты интерфейса объекта и сосредоточиться только на тех, которые важны для конкретного теста.
mock.stub().method(startingWith("get"))
.withNoArguments()
.will(returnADefaultValue);jMock позволяет пользователю задавать более сложные правила сопоставления, такие как ограничения на порядок вызовов одного мок-объекта или даже на порядок вызовов между разными моками. В целом, ограничения на порядок не являются обязательными, и их следует использовать с осторожностью, поскольку они могут сделать тесты слишком хрупкими. jMock минимизирует этот риск, позволяя пользователю определять частичный порядок между отдельными вызовами. Мы уже демонстрировали задание порядка вызовов в разобранном выше примере.
jMock обязывает пользователей задавать ограничения для аргументов, чтобы тесты можно было легко читать как документацию. Мы обнаружили, что пользователи ценят получаемую ясность, несмотря на то, что приходится писать больше кода, поскольку это помогает избегать трудноуловимых ошибок.
6.3. Расширяемость
Хотя jMock предоставляет обширную библиотеку ограничений и правил сопоставления, он не в состоянии охватить каждый сценарий, который может понадобиться программисту. На самом деле, создание ограничений, специфичных для вашей предметной области, повышает понятность ваших тестов. Поэтому правила сопоставления и ограничения являются расширяемыми. Программисты могут определять собственные правила или ограничения, которые естественным образом встраиваются в синтаксис jMock.
Например, объекты, генерирующие события, каждый раз при возникновении события создают новый объект-событие. Чтобы сопоставить событие с определенным объектом, мы можем написать пользовательское ограничение, которое сравнивает источник события с ожидаемым источником:
mock.expect(once())
.method("actionPerformed")
.with(anActionEventFrom(quitButton));jMock изначально создан для поддержки разработки через потребности. В силу этого его API вряд ли будет столь же полезен в других сценариях. Пользователи просили нас доработать jMock, чтобы помочь им с интеграционным тестированием, процедурным программированием, избеганием рефакторинга плохо спроектированного кода и мокированием конкретных классов, но мы вежливо отклоняли такие просьбы. Ни один API не может решить все задачи всех пользователей, однако jMock содержит множество полезных конструкций для тестирования в целом, независимо от того, практикуете ли вы разработку через потребности. Поэтому jMock имеет многоуровневую архитектуру: API jMock — это «синтаксический сахар», реализованный поверх базового объектно-ориентированного фреймворка, который можно использовать для создания других тестовых API. Описание этих базовых API выходит за рамки данной статьи, но его можно найти на сайте jMock.
6.4. Запрограммирован на неудачу
Mock спроектирован так, чтобы генерировать информативные сообщения, что упрощает диагностику причины сбоя теста. Мок-объекты получают осмысленные имена, чтобы программист мог легко соотнести сообщения об ошибке с реализацией тестового и целевого кода. Базовые объекты, из которых компонуются ожидания, могут предоставлять описания, которые в совокупности формируют понятное сообщение об ошибке.
По умолчанию мок-объект получает имя на основе типа, который он мокирует. Однако зачастую удобнее использовать имя, описывающее роль этого мока в тесте. В таком случае имя можно задать явно, передав его в конструктор мок-объекта:
namedMock = mock(MockedType.class,"namedMock");Мы также открыли для себя ряд других техник тестирования, способствующих формированию понятных сообщений об ошибках, таких как Самоописываемые значения и Объекты-заглушки. Самоописываемое значение — это такое значение, которое поясняет свою роль в тесте при выводе в составе сообщения об ошибке. Например, строка, используемая в качестве имени файла, должна иметь значение типа "OPENED-FILE-NAME", а не реалистичное имя вроде "invoice.xml". Объект-заглушка — это объект, который передается между объектами, но не вызывается в ходе теста. Тест использует объект-заглушку, чтобы задавать ожидания или утверждения, проверяющие, что тестируемый объект правильно взаимодействует со своими соседями. API jMock включает вспомогательные методы для создания самоописываемых объектов-заглушек.
Timestamp loadTime =
(Timestamp)newDummy(Timestamp.class,"loadTime");Объекты-заглушки позволяют программисту откладывать проектные решения о том, как будет определен тип и как будут создаваться его экземпляры.
7. Смежные работы
Ответственностно-ориентированное проектирование [19] считается полезным подходом к проектированию объектно-ориентированного программного обеспечения. Разработка через потребности — это техника ответственностно-ориентированного проектирования через опережающее тестирование. Мок-объекты помогают пользователю выявлять и проектировать роли и ответственности в процессе написания тестов.
Оригинальная библиотека mockobjects.com [10] предоставляла низкоуровневые инструменты для определения и проверки ожиданий в рукописных моках. Необходимость отвлекаться на создание мок-реализации интерфейсов нарушала естественный ритм цикла разработки через тестирование и приводила к дополнительной работе при изменении интерфейсов. Чтобы смягчить эту проблему, проект предоставлял мок-реализации множества распространенных интерфейсов JDK и J2EE. Однако этот подход оказался непрактичным для полноценной реализации и был сфокусирован на использовании мок-объектов для тестирования, а не для проектирования.
MockMaker [14] автоматически генерировал исходный код для мок-объектов из заданных пользователем определений интерфейсов на этапе сборки. Это способствовало применению мок-объектов в качестве инструмента проектирования и сводило к минимуму сбои в ритме программирования. Недостатком такого подхода было то, что он усложнял процесс сборки, а сгенерированные мок-объекты было трудно настраивать.
EasyMock [5] генерирует мок-объекты во время выполнения с помощью динамической генерации кода. Он предоставляет API в стиле «запись-воспроизведение». Тестовый код определяет ожидаемые вызовы, выполняя их на мок-объекте, пока тот находится в «режиме записи», а затем переводит мок-объект в «режим воспроизведения» перед вызовом тестируемого объекта. После этого мок-объект удостоверяется, что получает те же вызовы с теми же аргументами, которые были записаны. Такой подход давал API, который быстро осваивался новичками и хорошо работал с инструментами рефакторинга. Однако такая простота в определении ожиданий часто приводила к созданию избыточно детализированных, хрупких тестов.
DynaMock [13] также генерирует мок-объекты во время выполнения. Его API спроектирован с расчетом на то, чтобы его можно было читать как спецификацию требуемого поведения. Однако этот API негибок и его сложно расширять пользователям.
Некоторые проекты используют аспектно-ориентированное программирование [8] или манипуляцию байт-кодом, чтобы в ходе теста перенаправлять вызовы с объектов приложения на мок-объекты. Такой подход может быть полезен, если нужно протестировать код, работающий с негибкими сторонними API. Однако это всего лишь техника тестирования, и она лишает процесс проектирования полезной обратной связи.
8. Дальнейшая работа
Мы планируем усовершенствовать API jMock для более эффективной работы с автоматическими инструментами рефакторинга и автодополнением кода, сохранив при этом гибкость и выразительность текущего API.
Мы также планируем портировать jMock на другие языки, включая C# и динамические языки, такие как Ruby и Python. Значительная часть усилий при разработке jMock была посвящена исследованию того, как создать удобный предметно-ориентированный язык в Java. Основной задачей наших усилий по портированию станет сохранение выразительности API при поддержке идиом каждого конкретного языка.
Проблема техники мок-объектов — в сопровождении тестов и проверке согласованности между ними. Тест, использующий мок-объект, проверяет, что тестируемый объект совершает ожидаемую последовательность исходящих вызовов. Однако он не гарантирует, что все объекты, использующие один и тот же интерфейс, делают это единообразно или что их использование согласуется с классами, реализующими этот интерфейс. В данный момент эта проблема решается с помощью интеграционного тестирования и сквозных приемочных тестов. Так удается выявлять интеграционные ошибки, но определить их причину бывает сложно. Сейчас мы работаем над API для проверки согласованности между клиентами и реализациями интерфейса путем явного описания протоколов взаимодействия объектов.
9. Заключение
С момента публикации нашей предыдущей статьи на эту тему, мы убедились, что наши основные идеи по-прежнему применимы в ежедневной практике в рамках множества проектов. Наше понимание техники углубилось, и теперь мы гораздо сильнее склоняемся к использованию мок-объектов именно для проектирования, а не просто для тестирования. Теперь мы осознаем их роль в качественном проектировании на основе требований, а также их технические ограничения. Мы воплотили наш опыт в jMock — фреймворке мок-объектов нового поколения, который, как мы считаем, дает нам ту выразительность, которая необходима для поддержки разработки через потребности.
10. Благодарности
Мы благодарим Мартина Фаулера, Джона Фуллера, Ника Хайнса, Дэна Норта, Ребекку Парсонс, Имперский колледж Лондона, наших коллег в ThoughtWorks, а также участников eXtreme Tuesday Club.
11. Источники
[1] Astels D. Test-Driven Development: A Practical Guide. Upper Saddle River: Prentice-Hall, 2003.
[2] Beck K., Cunningham W. A Laboratory For Teaching Object-Oriented Thinking // SIGPLAN Notices (OOPLSA’89), vol. 24, № 10, October 1989.
[3] Feathers M. The “Self-Shunt” Unit Testing Pattern (2001), онлайн по адресу: http://www.objectmentor.com/resources/articles/SelfShunPtrn.pdf.
[4] Фаулер М., Бек К., Брант Д., Опдайк У., Робертс Д. Рефакторинг: улучшение проекта существующего кода. СПб.: ООО «Диалектика», 2019.
[5] Freese T. EasyMock (2003), онлайн по адресу: http://www.easymock.org.
[6] Hudak P. Building Domain-Specific Embedded Languages // ACM Computing Surveys, vol. 28, № 4, December 1996.
[7] Hunt A., Thomas D. Tell, Don’t Ask (1998), онлайн по адресу: http://www.pragmaticprogrammer.com/ppllc/papers/1998_05.html.
[8] Kiczales G. et al. Aspect-Oriented Programming // Akşit T., Matsuoka S. (eds.) ECOOP ’97 — Object-Oriented Programming 11th European Conference, Jyväskylä, Finland, June 9–13, 1997, Proceedings. Berlin: Springer, 1997.
[9] JUnit (2004), онлайн по адресу: http://www.junit.org.
[10] Маккиннон Т., Фриман С., Крейг Ф. Эндотестирование: юнит-тестирование с мок-объектами // https://habr.com/ru/articles/948982/.
[11] Маркс К. Критика готской программы, 1874.
[12] Mikhajlov L., Sekerinski E. A Study of the Fragile Base Class Problem // Jul E. (ed.) ECOOP ’98 — Object-Oriented Programming 12th European Conference, Brussels, Belgium, July 1998, Proceedings. Berlin: Springer, 1998.
[13] Massol V., Husted T. JUnit in Action. Greenwich: Manning, 2003.
[14] Moore I., Cooke M. MockMaker (2004), онлайн по адресу: http://www.mockmaker.org.
[15] Либерхер К., Холланд И. Обеспечение хорошего стиля объектно-ориентированных программ // https://habr.com/ru/articles/975956/.
[16] Poppendieck M. Principles of Lean Thinking // OOPSLA Onward!, November 2002.
[17] Sun Microsystems. Java Messaging Service, доступно онлайн: http://java.sun.com/products/jms.
[18] Williams L., Kessler R. Pair Programming Illuminated. Reading: Addison-Wesley, 2002.
[19] Wirfs-Brock R., McKean A. Object Design: Roles, Responsibilities, and Collaborations. Reading: Addison-Wesley, 2002.
