Светский разговор об управляемой тестами выпечке

в 8:58, , рубрики: smalltalk, tdd, ооп, Программирование, метки: , ,

Что-то вроде предисловия

Статья «Как два программиста хлеб пекли» сначала мне показалась просто шуткой — настолько абсурдно выглядят попытки выстроить какой-то «дизайн», основываясь на тех «требованиях», которые выдвигает «менеджер». Но в каждой шутке есть доля правды… В общем, возник вопрос к самому себе: а как в данной ситуации сработает тот подход, которого я стараюсь придерживаться в своей практике? То, что выросло при попытке дать ответ, собственно, и представлено далее.

TDD + Smalltalk


Собственно, суть используемого мной подхода вынесена в заголовок, но, думаю, здесь требуются некоторые пояснения.
Я сторонник «чистой» TDD (TDD — управляемая тестами разработка, отсюда и женский род). «Чистота» в данном случае означает, что на определенных этапах разработки программного обеспечения, а именно сразу после получения функциональных требований и до очередного релиза (то есть на этапах анализа требований, проектирования и построения программного кода) разработчик действует в полном соответствии с данной методологией, не отклоняясь от нее.

«Чистота» TDD обеспечивается за счет сочетания «классического стиля» создания тестов (основанном на анализе состояния) и «TDD с суррогатными объектами (mock-ами)» (основанном на анализе взаимодействия разрабатываемой подсистемы с другими объектами). Моки позволяют проектировать систему «сверху-вниз» (осуществлять функциональную декомпозицию, создавая «пустой каркас» системы), а классическая TDD затем обеспечивает проработку реализации «снизу-вверх». И, по моему опыту, данный подход очень хорошо (по крайней мере, лучше всех известных мне мейнстримовых альтернатив) сочетается с использованием среды Smalltalk. Здесь я не буду вдаваться в подробности, оставив их для следующих статей, а просто предложу посмотреть, как данный подход отработает на этом, несколько странном, но от этого-то и чем-то интересном примере.

Шаг 1. «Ребята, нам нужно, чтобы делался хлеб»

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

Пока единственное, что мы знаем: на выходе система выдает «хлеб». Поскольку никакая функциональность в имеющейся «постановке» с хлебом не связана, возникает желание просто проверить выходной объект на принадлежность соответствующему классу (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	
  • После этого продолжаем (именно продолжаем) выполнение теста и убеждаемся, что все прошло без ошибок. Первый тест реализован.

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

Шаг 2. «Нам нужно, чтобы хлеб не просто делался, а выпекался в печке»

Фиксируем поступившие крохи новых знаний о нашей системе в тесте. Что мы выяснили? Только то, что пекарь в процессе выпечки взаимодействует с печью. Чтобы это зафиксировать, нам пригодятся суррогатные объекты (ведь именно для таких вещей и предназначены). Я пользуюсь фреймворком Mocketry. С ним и у меня получился такой код:

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

Продолжаем выполнение — тест зеленеет от правильности. Все (оба) теста теперь зеленые. Переходим к рефакторингу… А рефакторить-то, вроде бы, и нечего (что вполне объяснимо, ведь кода мы почти не пишем — спасибо ПМ-у за наше счастливое программирование).

Результат, полученный после этой итерации, как и после предыдущей, выглядит несколько сомнительно: сам пекарь, выходит, практически ничего не делает. Но то, что получилось — самое простое решение в заданных условиях. Короче, все вопросы — к ПМ-у :)

Шаг 3. «Нам нужно, чтобы печки были разных видов»

Опять же: зачем нужно — история умалчивает. Но раз уж приняли условия игры, играем: для каждого нужного типа печки можно создать и реализовать по тест. Впрочем, тут же выясняется, что собственно реализовывать-то при такой «постановке» ничего и не придется! Убедимся в этом на примере газовой печки (единственная, которая нам в дальнейшем понадобится):

GasOvenTests >> testProducesBread
   | oven |
	oven := GasOven new.
	oven produceBread should be a kind of: Bread

Но, логично пронаследовав газовую печку от Oven, где #produceBread уже реализован, тест получаем сразу зеленым. Вообще, это плохой симптом: мы похоже, написали бессмысленный тест. Обвинения в адрес манагера становятся общим местом, пропускаю их… :) Возможно, в реально задаче с разными типами печей связана какая-то функциональность, но в данном случае она покрыта таким мраком, что фантазировать нет смысла.

Шаг 4. «Нам нужно, чтобы газовая печь не могла печь без газа»

Опять вопросов больше, чем ответов, придется додумывать. Наиболее простое, но, вроде бы, соответствующее данной формулировке решение у меня выглядит так:

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

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

Шаг 5. «Нам нужно, чтобы печки могли выпекать ещё и пирожки (отдельно — с мясом, отдельно — с капустой), и торты»

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

  1. Эти продукты отличаются какими-то своими свойствами (точнее, поведением) — но про это мы ничего не знаем, поэтому данный вариант нам ничего в данной ситуации не дает. Отбрасываем.
  2. Продукты отличаются способом изготовления. Этот вариант более продуктивен с точки зрения знаний о системе: для создания продукта нужно указать способ его изготовления. Зафиксируем это в тесте.

Как назовем способ изготовления? По-моему, это рецепт…

testUsesOvenToProduceByRecipe
	| product |
	[ :oven | 
	baker oven: oven.
	[ product := baker cookWith: #recipe ] should strictly satisfy: [ (oven produce: #recipe) willReturn: #product ].
	product should be: #product ] runScenario

Здесь мы зафиксировали следующее:

  • Просьбу к пекарю что-нибудь приготовить сопровождаем рецептом
  • Этот же рецепт пекарь передает печке (в реальности, скорее всего, это делается каким-то другим способом, но мы об этом сейчас ничего не знаем — так что, делаем как проще)
  • То, что получает от печки, пекарь выдает как конечный результат
  • Связь между рецептом и конечным продуктом, к сожалению, остается «за кадром» — просто потому, что про это (пока?) ничего не ясно.

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

Шаг 6. «Нам нужно, чтобы хлеб, пирожки и торты выпекались по разным рецептам»

Это мы, вроде бы, уже сделали… ну, как могли.

Шаг 7. «Нам нужно, чтобы в печи можно было обжигать кирпичи»

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

В общем, вроде бы и все…

Результат

Что же у нас получилось? Шесть классов… и не очень много (даже прямо скажем — просто мало) функциональности… Но лично я за это склонен «благодарить» нашего менеджера.

Baker
Bread
Oven
	ElectricOven
	GasOven
GasProducer

Интересно будет услышать ваше мнение и о результате, и о процессе…

Автор: chaetal

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js