DISCLAIMER: данную статью можно рассматривать, как дополнительные материалы к моему выступлению на MS SWIT с темой “Design for Testability: mocks, stubs, refactoring”.
В предыдущей заметке мы более внимательно рассмотрели принцип инверсии зависимостей, но при этом вопросы о том, как же добиться тестируемого дизайна и не наплодить бесконечное количество интерфейсов, остались открытыми.
В этой заметке я хочу показать типичный пример выделения интерфейсов для получения тестируемого дизайна и рассмотреть разницу между хорошим дизайном и тестируемым дизайном.
Тестируемый дизайн по Мартину
Давайте рассмотрим пример, приведенный все в той же книге Роберта Мартина «Принципы, паттерны и методики гибкой разработки».
Итак, перед нами стоит следующая задача: мы занимаемся разработкой с нуля модуля расчета заработной платы. И в результате небольшой сессии проектирования мы пришли к следующему дизайну:
Рисунок 1 – Исходный дизайн модуля расчета заработной платы
«Класс Payroll использует класс EmployeeDatabase для получения объекта Employee, затем просит Employee вычислить свою зарплату, передает величину зарплаты объекту CheckWriter, чтобы тот выписал чек, и напоследок передает сумму платежа объекту Employee и записывает объект обратно в базу данных.
Предположим, что весь этот код еще не написан. Пока это всего лишь диаграмма, нарисованная на доске в ходе эскизного проектирования. Теперь необходимо написать тесты, описывающие поведение объекта Payroll.»
(Р. Мартин, М. Мартин «Принципы, паттерны и методики гибкой разработки», стр.69-71)
Поскольку написать модульные тесты для такого решения практически невозможно, то Роберт Мартин предлагает внести следующие изменения в дизайн: выделить интерфейс для каждой зависимости класса Payroll и протащить их через конструктор.
Рисунок 2 – «Тестируемый» дизайн модуля расчета заработной платы
Теперь мы сможем «спокойно» покрыть тестами данный модуль. Достаточно «замокать» зависимости класса Payroll:
[Test]
public void TestPayroll()
{
MockEmployeeDatabase db = new MockEmployeeDatabase();
MockCheckWriter w = new MockCheckWriter();
Payroll p = new Payroll(db, w);
p.PayEmployees();
Assert.IsTrue(w.ChecksWereWrittenCorrectly());
Assert.IsTrue(db.PaymentsWerePostedCorrectly());
}
«Тестируемость» ради тестируемости
Стал ли дизайн лучше? Если предположить, что полученный дизайн все же лучше исходного, означает ли это, что аналогичный дизайн, но на языке с динамической типизацией сразу становится лучше? И означает ли это, что в тот момент, когда вы откроете для себя Microsoft Fakes дизайн всех ваших систем одним махом станет лучше, ведь у вас появится возможность покрыть его модульными тестами?
На самом деле, дизайн не изменился: ответственности классов не стали более четкими; мы не разделили существующее решение на более мелкие составляющие, каждую из которых проще самостоятельно анализировать или развивать, да и использовать повторно данный код не слишком просто.
Мы знаем, что расчет заработной платы – дело очень непростое, с множеством граничных случаев и постоянной необходимостью вносить какие-то изменения в реализацию. Будем ли мы использовать 2 или 3 мока для тестирования каждого граничного условия? Не приведет ли такой подход к ситуации, когда изменения реализации класса Payroll будет постоянно приводить к поломкам многих тестов? Не будет ли у нас ситуации, когда все юнит тесты проходят, а система собранная из реальных объектов, а не моков – не работает?
Тестирование – это не самоцель. Наша цель – получить хороший дизайн, который легко развивать и использовать повторно и который не сыпется после внесения даже небольших изменений.
Альтернативный подход
Как и Роберт Мартин, я использую тестируемость решения в качестве лакмусовой бумажки плохого дизайна, но вместо того, чтобы просто сделать текущее решение тестируемым, я задаю себе такой вопрос: как улучшить дизайн таким образом, чтобы он стал тестируемым?
В данном конкретном случае процесс улучшения исходного дизайна довольно прост. Для этого достаточно задать себе несколько вопросов:
-
· Нужно ли выделять интерфейс для CheckWriter? Да. Почти наверняка, это изменчивая зависимость, поскольку «выписка чека» может приводить к отправке электронных писем или другой коммуникации с внешним миром. Мы можем сразу же говорить о «стратегии выписки чека», что подтверждает необходимость выделения интерфейса.
-
· Нужно ли выделять интерфейс для EmployeeDatabase? Скорее всего, да. Но не факт, что это обязательно делать с самого начала!
-
· Нужно ли нам задумываться о том, откуда получены данные о сотруднике при расчета заработной платы? Очевидно, что минимальной информацией для расчета заработной платы является экземпляр класса Employee, при этом совсем не важно, откуда он будет получен верхним уровнем.
-
· Положительный ответ на предыдущий вопрос дает понять, что у нас есть скрытая (не выявленная) абстракция. Нам явно не хватает класса более низкого уровня, который будет заниматься исключительно бизнес-логикой расчета заработной платы, но не задумываться об источнике данных. Нам нужен кто-то типа Payroller или PayrollComputer.
-
· Нужно ли нам абстрагироваться от класса Employee и выделять IEmployee? Пока необходимости в этом не видно! Но явно видна необходимость выкинуть сложную логику расчета заработной платы из класса Employee в PaymentComputer или хотя бы в новый класс Payroller. В последствии класс Employee может быть разбит на более мелкие составляющие, типа PaymentModel, PaymentScheduler, Address etc. Вполне возможно будет полезным разбить Employee на DTO (Data Transfer Object), который будет читаться из базы данных, и на классы моделей с некоторым поведением.
Рисунок 3 – Альтернативный дизайн
Кроме того, такое решение отделяет инфраструктуру («персистентность») от бизнес-логики (расчета и выписки счета) и получили возможность думать о каждом из этих аспектах независимо.
Такое разделение позволит проверить логику работы класса Payroller в большей изоляции: проверить алгоритм выписки чека при заданных входных воздействиях и отдельно протестировать алгоритм начисления заработной платы. Когда каждый из этих аспектов будет усложняться, то мы не будем ломать другие тесты, поскольку каждый тест работает с минимальным количеством абстракций.
В результате мы убрали практически всю логику из класса Payroll: там осталась лишь логика получения данных и сохранения их обратно. Так что в этом случае, вполне возможно, что я остановился бы на интеграционном тестировании класса Payroll вместе с классом EmployeeDatabase даже не абстрагируясь от слоя доступа к данным!
И хотя последнее предложение кажется ересью, это не совсем так. В «дизайне от Боба» большинство граничных условий всего модуля проверялись через класс Payroll. В новом дизайне граничные условия каждого аспекта модуля проверяются в тестах конкретного класса, а в тестах класса Payroll проверяется лишь интеграция модулей вместе.
В одной из предыдущих заметок я приводил аксиому управления зависимостями, которая звучала таким образом: Чем сложнее класс или компонент, тем меньше у него должно внешних связей. Ее же можно переформулировать немного иначе: сделайте так, чтобы на сложной задаче было легко сосредоточиться, не отвлекаясь на необязательные составляющие.
Очень часто чрезмерное выделение интерфейсов для тестирования приводит к решениям сильно завязанным на побочные эффекты. Моки – это типичный пример проверки побочного эффекта класса, которые довольно часто могут являться деталью реализации, а значит они будут изменяться со временем. Побочные эффекты сами по себе не являются вселенским злом, но тестирование таких сценариев обычно более сложное и хрупкое.
Я же обычно стремлюсь к созданию максимально «чистых» классов и модулей, результаты работы которых очевидны и видны: все, что они делают – это принимают входные данные в аргументах и весь необходимый результат доступен в качестве возвращаемого значения. Отсутствие побочных эффектов упрощает понимание кода и очень сильно упрощает тестирование: модуль или функцию достаточно протестировать один раз и она будет работать всегда; да и тестирование будет проще, поскольку входы и выходы очевидны.
Так, в нашем случае я бы сразу выделял класс для расчета выплат, который бы завязал на минимальное количество простых классов:
После чего я смог бы покрыть этот (достаточно сложный класс) десятками тест-кейсов за счет использования параметризованных тестов:
[TestCaseSource("GetPaymentInfo")]
public void Test_Payment_Information(PaymentInfo pi, Money expectedPayment)
{
// Arrange
var calculator = new PaymentCalculator();
// Act
var actualPayment = calculator.Calculate(pi);
// Assert
Assert.That(expectedPayment, Is.EqualTo(actualPayment));
}
Подобное разделение позволит не только покрыть юнит-тестами любое количество граничных условий расчета заработной платы, но и предотвратит от случайных поломок тестов. Данный тест не сломается при внесении изменений в другие классы или модули, он сломается лишь в случае ошибки в классе PaymentCalculator.
Заключение
Когда вы в следующий раз начнете выделять очередной интерфейс исключительно для тестирования, хотя бы задумайтесь о том, насколько это нужно. Может быть проблема отсутствия тестирования заключается в неудачном дизайне и именно его стоит изменить?
Помните о том, что тестируемость далеко не всегда приведет вас к хорошему дизайну, в то время как хороший дизайн не только обеспечит тестируемость, но даст еще и массу других преимуществ.
Поправьте, пожалуйста:
ОтветитьУдалить"Подобное разделение позволит не только покрыть юнит-тестами любое количество граничных условий расчета заработной платы, но и предотвратит от случайных поломок тестов."
->
"Подобное разделение позволит не только покрыть юнит-тестами любое количество граничных условий расчета заработной платы, но и предотвратить случайные поломки тестов."
На рисунке 3 у меня возникают сомнения в необходимости разделения классов Payroll и Payroller.
ОтветитьУдалитьНо чтобы сказать с уверенностью или наоборот опровергнуть, надо больше данных.
Есть много что сказать мне :). Когда ты говоришь о "чистых" классах - ты автоматически отрезаешь классы с состоянием... Т.е. с инкапсуляцией все сложно становится. Я бы сказал так, "чистые" функции, классы, сборки, etc.. можно и нужно тестировать без интерфейсов. А вот если у нас есть класс состояние которого зависит он некоторых "скрытых" переменных - не получится сделать тестирование без интерфейсов. И в общем, в данном случае, Дядя Боб прав - интерфейсы таки нужны, потому как он предполагает, что классы в примере имеют классический вид(т.е. с состоянием). То, что предлагаешь ты - это синергия функционального и ООП подхода(про Бертрана помню). Поэтому как по мне - тестировать надо и так и так, в зависимости от того, что тестируем. Всему свой инструмент :)
ОтветитьУдалить@nightcoder: класс Payroll - слишком высокоуровенвый. Он ведь гребет *все* из базы и по одному выполняет некоторую операцию. Мой Payroller - это как раз сгусток бизнес-логики, работающий над одним сотрудником.
ОтветитьУдалитьМне значительно проще думать об одной операции, "умножая" ее для некоторой группы объектов, нежели думать сразу и о получении данных и о манипулировании ими.
@eugene: А мне есть что ответить:))
ОтветитьУдалитьКак по мне, мы не можем тестировать класс без интерфейса, только если класс завязан на окружение, на многопоточность и т.п. Если же класс с состоянием, но его поведение не завязано на окружение (пусть и с внутренним состоянием), то я буду спокойно его использовать напрямую.
И да, это синергия ООП и ФП, но:
> потому как по мне - тестировать надо и так и так, в зависимости от того, что тестируем
Здесь же важно то, как мы дизайним, а из этого мы получим разные решение, и некоторые будут проще (в понимании и тестировании), а другие сложнее;)
Даже базируясь на этом абстрактном "альтернативном" примере хорошего дизайна я, к сожалению, могу придумать несколько вполне правдоподобных вариантов развития бизнес-логики, которая потребует полного переписывания половины тестов и кода. Я могу также представить еще пару уровней абстракции, которые позволят избежать переписывания тестов и кода, а обеспечить их использование в новых обстоятельствах. А потом придут новые требования, которые все-таки сломают всю красоту архитектуры и заставят переписывать еще половину тестов и весь код.
ОтветитьУдалитьТак вот, а не легче ли хорошо документировать требования и зоны ответственности, вместо юнит-тестов, если подавляющее большинство изменений к требованиям инвалидирует их?
Вообще, имхо, юнит-тесты (именно юнит!) обычно выглядит примерно как
Строитель:
— Я построю дом, вот чертеж, но чтобы не забыть прорубить окна и крыша находилась сверху, я построю сначала рядом карточный домик.
или
— Я построю дом, но чтобы знать, что на *чертеже* будет продумано утепление я вокруг разложу тысячу кусочков утеплителя. Один — рядом с кирпичиком, второй рядом с кусочком оконной рамы, третий рядом с арматурой.
Но чаще всего так:
— Я давно построил дом, но так как все крутые строители занимаются юнит-тестами, я рядом построю макет.
У подхода "а давайте просто задокументируем наши предположения" есть один фатальный :) недостаток: они не проверяются во время исполнения. Как быть, если мы меняем одну часть системы и не знаем, что теперь поведение начинает отличаться от задокументированного?
Удалить