- PVSM.RU - https://www.pvsm.ru -
Статья «Как два программиста хлеб пекли» [1] сначала мне показалась просто шуткой — настолько абсурдно выглядят попытки выстроить какой-то «дизайн», основываясь на тех «требованиях», которые выдвигает «менеджер». Но в каждой шутке есть доля правды… В общем, возник вопрос к самому себе: а как в данной ситуации сработает тот подход, которого я стараюсь придерживаться в своей практике? То, что выросло при попытке дать ответ, собственно, и представлено далее.
Собственно, суть используемого мной подхода вынесена в заголовок, но, думаю, здесь требуются некоторые пояснения.
Я сторонник «чистой» TDD (TDD — управляемая тестами разработка, отсюда и женский род). «Чистота» в данном случае означает, что на определенных этапах разработки программного обеспечения, а именно сразу после получения функциональных требований и до очередного релиза (то есть на этапах анализа требований, проектирования и построения программного кода) разработчик действует в полном соответствии с данной методологией, не отклоняясь от нее.
«Чистота» TDD обеспечивается за счет сочетания «классического стиля» создания тестов (основанном на анализе состояния) и «TDD с суррогатными объектами (mock-ами)» (основанном на анализе взаимодействия разрабатываемой подсистемы с другими объектами). Моки позволяют проектировать систему «сверху-вниз» (осуществлять функциональную декомпозицию, создавая «пустой каркас» системы), а классическая TDD затем обеспечивает проработку реализации «снизу-вверх». И, по моему опыту, данный подход очень хорошо (по крайней мере, лучше всех известных мне мейнстримовых альтернатив) сочетается с использованием среды Smalltalk. Здесь я не буду вдаваться в подробности, оставив их для следующих статей, а просто предложу посмотреть, как данный подход отработает на этом, несколько странном, но от этого-то и чем-то интересном примере.
Создаем первый тест, параллельно придумывая необходимую терминологию (при желании, наверное, можно назвать это метафорой).
Пока единственное, что мы знаем: на выходе система выдает «хлеб». Поскольку никакая функциональность в имеющейся «постановке» с хлебом не связана, возникает желание просто проверить выходной объект на принадлежность соответствующему классу (Bread
).
А кто делает хлеб? Пекарь, наверное… Соответственно, в тесте мы фиксируем следующее функциональное требование, которое удалось вытащить из имеющейся «постановки» задачи: пекаря можно попросить сделать хлеб, в ответ на что он должен выдать нам объект соответствующего класса. Это требование относится к функциональности пекаря, класс для первого теста называем (пока) просто BakerTests
, и в нем создаем тест:
BakerTests >> testProducesBread
| baker product |
baker := Baker new.
product := baker produceBread.
product class should be: Bread
Реализация теста настолько же тривиальна, насколько он сам.
Baker
и Bread
. Кстати, Smalltalk-система сама укажет нам на необходимость это сделать при компиляции теста, поинтересовавшись, как следует понимать неизвестные ей имена Baker
и Bread
. Система также выскажет недоумение насчет идентификатора produceBread
, но мы пока просто заверим ее, что мы контроллируем ситуацию, и он имеет право на существование (являясь именем сообщения).produceBread
не определен в классе Baker
(только что во время компиляции мы обещали об этом позаботиться), о чем Smalltalk не преминет нам сообщить, показав окно отладчика. И прямо в отладчике мы попросим систему создать этот метод в нужном классе (Baker
) и тут же зададим его реализацию:
Baker >> produceBread
^ Bread new
Подводя итоги этой итерации, можно отметить для себя, что к хлебу не предъявляется абсолютно никаких требований, назначение соответствующего класса не ясно и, наверняка имеет смысл попытать по этому поводу менеджера: именно поэтому мы пока вынуждены ограничиться таким тривиальным тестом, да и похоже, что разработку мы начали не с самого верхнего уровня абстракции, а это часто в дальнейшем приводит к проблемам. Тем не менее, в рамках нашего примера будем считать, что первая итерация на этом завершена.
Фиксируем поступившие крохи новых знаний о нашей системе в тесте. Что мы выяснили? Только то, что пекарь в процессе выпечки взаимодействует с печью. Чтобы это зафиксировать, нам пригодятся суррогатные объекты (ведь именно для таких вещей и предназначены). Я пользуюсь фреймворком Mocketry [2]. С ним и у меня получился такой код:
BakerTests >> testUsesOvenToProduceBread
| product |
[ :oven |
baker oven: oven.
[ product := baker produceBread ] should strictly satisfy:
[ (oven produceBread) willReturn: #bread ].
product should be: #bread ] runScenario
Здесь мы сделали следующее:
#runScenario
внешнему блоку (кому-то может быть ближе термин «замыкание»).oven
— это суррогатный объект (Mocketry автоматически инициализирует параметры блока сценария соответствующим образом).#produceBread
(во втором вложенном блоке, переданном как аргумент сообщения #satisfy:
). По сути, это — первое условие теста. #bread
), который в дальнейшем должен стать результатом исходного запроса на выпечку хлеба. Идентичность этих объектов является вторым условием теста. Причем здесь нас не интересует природа этого результирующего объекта, а важна только его идентичность тому объекту, который выдала печь. Поэтому в этой роли мы используем, по сути, простую строковую константу: #bread
.
Замечу также, что здесь представлена слегка отрефакторенная версия теста: мы уже избавились от дублирования, связанного с созданием в обоих тестах пекаря, «вытащив» его в переменную экземпляра и проинициализировали в методе #setUp
, который автоматически вызывается перед запуском каждого теста:
BakerTests >> setUp
super setUp.
baker := Baker new.
Отмечу, что в процессе написания теста пришлось принять следующее решение: пекарю заранее известно, какой печкой он пользуется — она становится частью его состояния. Это решение на самом деле не очень важное, поскольку при необходимости изменить это будет достаточно легко: если печка становится известна только в момент выполнения работы, добавим параметр в produceBread
; а если она должна быть получена откуда-то еще, введем объект, который в нужный момент будет выдавать нам нужную печь.
Чтобы реализовать этот тест, слегка переделываем метод #produceBread
в пекаре:
Baker >> produceBread
^ oven produceBread
В процессе компиляции этого метода система интересуется, что такое oven. В ответ объясняем, что это мы хотим создать переменную экземпляра. После этого, запустив тест, видим сообщение отладчика и понимаем, что недовольство системы связано с отсутствием необходимого сеттера. Создаем его прямо из отладчика, не прерывая выполнения теста:
Baker >> oven: anOven
oven := Oven new
Тут же, во время компиляции, создаем класс Oven
.
Продолжив выполнять тест, увидим, что он успешно отрабатывает. Но запустив все тесты в нашей системе (а их уже два), видим, что сломался первый. Если причина не очевидна заранее, мы ее легко выясняем из диагностического сообщения или проанализировав состояние системы по текущему стеку в отладчике: печка-то не задана. Что ж, обеспечим печку по-умолчанию (здесь закладываюсь, что как в Squeak и Pharo, для Object
уже предусмотрен вызов метода #initialize
при создании экземпляра — в других Smalltalk-средах это очень — да, на самом деле, очень — просто это реализовать самим):
Baker >> initialize
super initialize.
oven := Oven new.
Запускаем тест — система сообщает, что метод #produceBread
в классе Oven
не реализован. Реализуем тут же:
Oven >> produceBread
^ Bread new
Продолжаем выполнение — тест зеленеет от правильности. Все (оба) теста теперь зеленые. Переходим к рефакторингу… А рефакторить-то, вроде бы, и нечего (что вполне объяснимо, ведь кода мы почти не пишем — спасибо ПМ-у за наше счастливое программирование).
Результат, полученный после этой итерации, как и после предыдущей, выглядит несколько сомнительно: сам пекарь, выходит, практически ничего не делает. Но то, что получилось — самое простое решение в заданных условиях. Короче, все вопросы — к ПМ-у :)
Опять же: зачем нужно — история умалчивает. Но раз уж приняли условия игры, играем: для каждого нужного типа печки можно создать и реализовать по тест. Впрочем, тут же выясняется, что собственно реализовывать-то при такой «постановке» ничего и не придется! Убедимся в этом на примере газовой печки (единственная, которая нам в дальнейшем понадобится):
GasOvenTests >> testProducesBread
| oven |
oven := GasOven new.
oven produceBread should be a kind of: Bread
Но, логично пронаследовав газовую печку от Oven
, где #produceBread
уже реализован, тест получаем сразу зеленым. Вообще, это плохой симптом: мы похоже, написали бессмысленный тест. Обвинения в адрес манагера становятся общим местом, пропускаю их… :) Возможно, в реально задаче с разными типами печей связана какая-то функциональность, но в данном случае она покрыта таким мраком, что фантазировать нет смысла.
Опять вопросов больше, чем ответов, придется додумывать. Наиболее простое, но, вроде бы, соответствующее данной формулировке решение у меня выглядит так:
GasOvenTests >> testConsumesGasToProduceBread
[ :gasProducer |
oven gasProducer: gasProducer.
[ oven produceBread ] should strictly satisfy: [ gasProducer consume ] ] runScenario
GasOven
>> produceBread
gasProducer consume.
^ super produceBread
>> gasProducer: gasProducer
gasProducer := aGasProducer
Едва ли печка меняет источник газа по своему усмотрению? А как именно происходит потребление, пока не ясно — поэтому просто сообщаем источнику о самом факте потребления.
Решение по своей сути получается абсолютно идентичным предыдущему, что легко объяснимо — задачи ставятся все в том же стиле, и соответственно решаем их похожими, однажды уже сработавшими способами (ведь проблем-то не выявлено).
Как и в прошлый раз, сломался один тест (источник газа не задан по умолчанию), чиним:
GasOven >> initialize
super initialize.
gasProducer := GasProducer new.
GasProducer >> consume
— да, этот метод (надеюсь, пока) оставляем пустым, так как никаких конкретных требований к нему задано не было.
Опять туман: что значит, выпекать пирожки? чем они отличаются от хлеба? а от торта? Я увидел два возможных варианта:
Как назовем способ изготовления? По-моему, это рецепт…
testUsesOvenToProduceByRecipe
| product |
[ :oven |
baker oven: oven.
[ product := baker cookWith: #recipe ] should strictly satisfy: [ (oven produce: #recipe) willReturn: #product ].
product should be: #product ] runScenario
Здесь мы зафиксировали следующее:
Можно сделать еще несколько итераций, «накидав» тестов по различным видам рецептов… но для этого хотелось бы что-то знать о том, как это должно работать. Можно, конечно, пофантазировать, но времени жалко… Поэтому переходим к следующему пункту.
Это мы, вроде бы, уже сделали… ну, как могли.
Если считать, что кирпич может выпекаться пекарем по рецепту (а почему нет? никакой противоречащей этому информации нам не поступало), то делать опять ничего не надо… ну, разве что добавить еще один тест в коллекцию несделанных нами (пока) тестов к различного рода рецептам.
В общем, вроде бы и все…
Что же у нас получилось? Шесть классов… и не очень много (даже прямо скажем — просто мало) функциональности… Но лично я за это склонен «благодарить» нашего менеджера.
Baker
Bread
Oven
ElectricOven
GasOven
GasProducer
Интересно будет услышать ваше мнение и о результате, и о процессе…
Автор: chaetal
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/programmirovanie/26852
Ссылки в тексте:
[1] «Как два программиста хлеб пекли»: http://habrahabr.ru/post/153225/
[2] Mocketry: http://www.squeaksource.com/Mocketry.html
[3] Источник: http://habrahabr.ru/post/168993/
Нажмите здесь для печати.