среда, 25 ноября 2009 г.

Суррогатные объекты (Mock Objects)

Статья незакончена! Опубликована как предварительный материал к лекции по Mock-объектам.


Вольный пересказ статьи Mocks Aren't Stubs by Martin Fowler
См. также XUnit Test Patterns by Gerard Meszaros

Пример


Для иллюстрации будем использовать следующий пример: выполнение заказа со склада. Заказ (Order) включает всего один товар (для простоты товары будут кодироваться символами типа #article1) с указанием количества (amount). Склад (warehouse) знает про наличие товаров (inventory). Когда мы просим заказ "выполниться", возможны два сценария: (1) если на складе имеется достаточное количество указанного товара, то заказ помечается как выполненный (filled) и со склада списывается соответствующее количество этого товара; (2) если нужного количества нет, заказ остается невыполненным, а со складом ничего не происходит.

Обычные тесты

OrderStateTests >>
setUp
warehouse := Warehouse new.
warehouse
add: 50 of: #article1.

testOrderIsFilledIfEnoughInWarehouse
| order |
order := Order on: 50 of: #article1.
order fillBy: warehouse.
self assert: order isFilled.
self assert: (warehouse inventoryOf: #article1) isZero.

testOrderDoesNotRemoveIfNotEnough
| order |
order := Order on: 51 of: #article1.
order fillBy: warehouse.
self deny: order isFilled.
self assert: (warehouse inventoryOf: #article1) = 50.

Тесты в xUnit (SUnit в данном случае) обычно, как и в данном случае, можно разбить на четыре стадии: установка, выполнение, проверка, демонтаж.
Установка в данном примере выполняется в методе setUp, а также в самих тестах --- созданием заказа. Выполнение состоит в передаче сообщения #fillBy: созданном заказу. С помощью assert-ов выполняется проверка. А стадия демонтажа явно не представлена (на самом деле, она выполняется "за кадром" сборщиком мусора).

На стадии установки мы конфигурируем два объекта: склад (warehouse) и заказ (order). Можно заметить, что с точки зрения теста эти объекты не равнозначны.

Один из них --- заказ --- собственно и подвергается тестированию (точнее, один из аспектов его функциональности --- метод выполнения). Такие --- центральные для теста --- объекты будем называть тестируемой системой. А для краткости будем использовать английскую аббревиатуру SUT (от System Under Test).

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

Что в этих тестах не так?


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

Еще одно замечание: класс Warehouse должен существовать и правильно работать (по крайней мере, в отношении хранения остатков по продуктам и их списания), для того, чтобы тест работал. То есть, тест может сломаться не только потому, что неправильно реализована тестируемая система, но и из-за ошибок в сотрудниках. Как следствие, данный тест не может точно показать, где именно кроется ошибка. И это не единственный недостаток зависимости тестов от используемых в них сотрудников. Но другой --- весьма немаловажный, надо заметить, --- аспект рассмотрим чуть позже...

Тесты с использованием суррогатных объектов



С использованием SMock то же поведение можно описать следующим образом. Рассмотрим сначала "неудачное" списание.

OrderInteractionTests >>
testFillingDoesNotRemoveIfNotEnoughInStock
| order warehouse mock |
order := Order on: 101 of: #article1.
mock := SMock.Mock new.
(mock expect: #has:of:)
with: 101;
with: #article1;
returns: false.
warehouse := mock proxy.
order fillIn: warehouse.
mock verify.
self deny: order isFilled

Теперь вместо настоящего склада мы используем "заместителя" (proxy), полученного из суррогатного объекта ("мок-объекта" или просто "мока" в дальнейшем). Моки позвволяют проверить взаимодействие тестируемой системы с сотрудниками. Для этого сначала моку указывается, какие сообщения он должен будет получить --- описываются ожидания (expectations). А после использования мока (через его представителя) в реальных вычислениях, можно проверить, были ли эти ожидания выполнены.

Ожидания добавляются в мок через посылку сообщения #expect:. В параметре передается селектор сообщения. В ответ мок возвращает специальный объект, представляющий информацию об ожидаемом сообщении. Ему можно указать, например, значения параметров (с помощью последовательных сообщений #with:), возвращаемый результат (#returns:) и др.

В рассмотренном примере: mock ожидает сообщение #has:of: с первым параметром равным 101, и вторым параметром равным #article. В ответ на получение этого сообщения мок должен вернуть значение false.

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

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

Рассмотрим второй тест:

OrderInteractionTests >>
testFillingRemovesInventoryIfInStock
| order warehouse mock |
order := Order on: 100 of: #article1.
mock := SMock.Mock new.
(mock expect: #has:of:)
with: 100;
with: #article1;
returns: true.
(mock expect: #remove:of:)
with: 100;
with: #article1.
warehouse := mock proxy.
order fillIn: warehouse.
mock verify.
self assert: order isFilled

Здесь мок должен получить два сообщения: #has:of: и #remove:of:. В обоих случаях ожидаются два аргумента: 100 и #article, соответственно. На #has:of: мок должен ответить true.