Design for Testability
Mocks, Stubs, Refactoring
by Sergey Teplyakov, @STeplyakov
Что не так с
дизайном наших
систем?
Что приводит к плохому
дизайну?
• Ошибки на начальных этапах?
• Недопонимание требований
• Жесткая архитектура
• Предварительное обобщение
• …
• Постоянные изменения требований?
Что такое «плохой
дизайн»?
«Главный» критерий
плохого дизайна
«А вот я бы сделал это не так!»
That’s not the way I would have done
it, TNTWIWHDI
Критерии плохого дизайна
• Жесткость (Rigidity)
• Хрупкость (Fragility)
• Неподвижность (Immobility)
Юнит-тесты, как лакмусовая
бумажка хорошего дизайна
И как этого зверя
тестировать?
ServiceLocator
+ Get<T>() : T
<<Interface>>
ILogger
+LogError(error) : void
Configuration
+Instance: Configuration
ClassUnderTest
Database
+GetEmployee(): Employee
<<Interface>>
IServiceProxy
+Compute(Data) : void
<<Interface>>
IViewModelManager
+Show(ViewModel) : void
«Предусловия» юнит тестов
• Требуется ясный «контракт» класса
• Четкий «вход»
• Четкий «выход»
• Минимальное количество связей
Test Doubles: Stubs & Mocks
• Стабы - эмулируют состояние
• Моки - проверяют поведение
Пример стаб-объекта
Logger
<<Interface>>
ILogConfigurator
+GetConfig() : Config
LogConfiguratorStub
+GetConfig(): Config
Возвращает
"поддельный" конфиг
// Добавляем в поддельную конфигурацию из 3-х аппендеров
int appenders = 3;
var stub = new LogConfiguratorStub(new Config(appenders));
var logger = new Logger(stub);
// Проверяем, что логгер сконфигурирован корректно
Assert.That(
logger.GetAppenders().Count, Is.EqualTo(appenders));
Пример мок-объекта
Logger
<<Interface>>
ILogAppender
+Write(message) : Void
LogAppenderMock
+Write(message) : Void
+WritenMessage: String
Запоминает
информацию о
вызовах
// Arrange
var mock = new LogAppenderMock();
var logger = new Logger(mock);
// Act
logger.Write("Msg");
// Assert
Assert.That(mock.WrittenMessage, Is.Not.Null);
Юнит тесты – не
серебряная пуля!
Наивная реализация модуля
расчета заработной платы
Payroll
+ PayEmployees() : void11
CheckWriter
+ WriteCheck() : void
EmployeeDatabase
+ GetEmployee() : Employee
+ PutEmployee(e: Employee)
1
1
Employee
+ CalculatePay() : Money
+ PostPayment(m: Money)
1 1
«Плохой» дизайн
Выделяем интерфейсы!
Payroll
+ PayEmployees() : void11
CheckWriter
+ WriteCheck() : void
EmployeeDatabase
+ GetEmployee() : Employee
+ PutEmployee(e: Employee)
1
1
Employee
+ CalculatePay() : Money
+ PostPayment(m: Money)
1 1
<<Interface>>
ICheckWriter
+WriteCheck() : void
<<Interface>>
IEmployeeDatabase
+ GetEmployee() : Employee
+ PutEmployee(e: Employee)
<<Interface>>
IEmployee
+ CalculatePay() : Money
+ PostPayment(m: Money)
Теперь дизайн
тестируемый!
[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());
}
Стал ли дизайн лучше?
• Дизайн не изменился!
• Груда кода в каждом тесте (*)
• Сложность проверки граничных условий
• Динамическая типизация != хороший
дизайн!
Альтернативный подход
• Нужно ли выделять интерфейс для
CheckWriter-а?
• Нужно ли выделять интерфейс для
EmployeeDatabase?
• Нет ли скрытых абстракций?
• Нужен ли IEmployee?
• Не делает ли Employee слишком много?
Альтернативный подход
Payroller
+ PayEmployee(Employee)
+ CalculatePayment(Money)
1
1
CheckWriter
+ WriteCheck() : void
EmployeeDatabase
+ GetEmployee() : Employee
+ PutEmployee(e: Employee)
1
1
Employee
+ PostPayment(m: Money)
1
1
Payroll
+ PayEmployees()
1
1
<<Interface>>
ICheckWriter
+WriteCheck() : void
Переносим
логику из Payroll
Убираем метод
CalculatePayment
Проверяем
интеграционными
тестами
Идеальный дизайн для
тестирования
PaymentCalculator
+Calculate(PaymentInfo) : Money
PaymentInfo
+ WorkScheduler: Scheduler
Money
+ Value: Decimal
Argument Result
Метод Calculate без
побочных эффектов!
[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));
}
А в чем разница?
• Отделение инфраструктуры от логики
• Уменьшение связанности
• Возможность повторного
использования
• Простота тестов
Дизайн и борьба со
сложностью
Любая сложная система строится на
основе проверенных модулей более
низкого уровня.
Гради Буч
Аксиома управления
зависимостями
The more complex a class or component
is, the more decoupled it should be.
Ted Faison – Event-Based Programming
Слепое стремление к
тестируемости ведет к …
• нарушению инкапсуляции;
• проблемам сопровождения;
• неявной связности;
Важное следствие …
Хороший дизайн == тестируемый дизайн
Тестируемый дизайн != хороший дизайн
ASARULEOFTHUMB
Design for Testability…
От тестируемости к хорошему дизайну
От хорошего дизайна к тестируемости
Вопросы?
Design for
Testability:
Mocks, Stubs, Re
factoring
Sergey Teplyakov
Visual C# MVP
SergeyTeplyakov.blogspot.com
Заповни Анкету
Виграй Приз
http://anketa.msswit.in.ua
MS SWIT 2013 Design for Testability

MS SWIT 2013 Design for Testability

Editor's Notes

  • #4 У большинства из нас был печальный опыт работы с фрагментами системы, у которых был «плохой дизайн». Более того, у некоторых из нас был еще более печальный опыт, осознания того, что именно они были авторами систем с «плохим дизайном». Так что же приводит к плохому дизайну?
  • #6 Когда кто-либо задает подобный вопрос, то мы, разработчики, становимся похожими на наших пользователей. Наши пользователи очень часто не знают точно, чего они хотят, но как только они увидят конкретный результат, они точно скажут оно это или нет.Точно также и мы, даже если мы не всегда можем четко сформулировать, что же такое плохой и хороший дизайн, но в большинстве случаев, внимательно посмотрев на существующее решение мы сможем определить, хорош дизайн или нет. Но прежде чем признать дизайн хорошим, нам придется преодолеть первый критерий плохого дизайна:
  • #9 Рисунок с кучей связей и подписью: И как это дело тестировать?Во время дизайна класса или модуля, я постоянно задаю себе (или своим коллегам) такой вопрос: «Отлично, дизайн хороший, но как ты собираешься это тестировать?». У этого класса приличное количество бизнес-логики, завязка на свою собственную инфраструктуру (которую пол дня поднимать), завязка на сервис-локатор и на 3 синглтона.Очевидно, что в этом случае протестировать такого паука, который сидит в середине своей паутины, очень сложно.Если же написать модульный тест достаточно просто (и для этого не нужно использовать кастомный тестовый фреймворк), то это наверняка означает, что класс легко использовать повторно, и в него скорее всего легко внести изменения.
  • #10 Мы можем рассматривать большинство модулей в виде некоторого серого ящика, который принимает определенные данные на вход, и выдают некоторый результат на выходе.Входными данными могут служить аргументы методов, а выходными данными – изменение состояния, возвращаемое значение методов или некоторые побочные эффекты, которые мы с вами можем каким-то образом наблюдать.Если «контракт» модуля не четкий, то мы просто не сможем его формализовать в виде набора юнит-тестов.С другой стороны, если у модуля слишком много внешних связей, то нам будет сложно получать его экземпляр для тестирования. Если класс завязан на 3 синглтона и вытаскивает еще 4 зависимости из сервис-локатора, то нам придется потратить существенные усилия для инициализации тестов.Мы можем рассматривать юнит-тесты в качестве еще одного клиента нашего модуля, что не только показывает его граничные условия, но и гарантирует относительную простоту использования класса/модуля в другом контексте.
  • #11 Каким бы модульным не было наше приложение, некоторые его части всегда будут зависеть от внешних ресурсов, которые будет проблематично использовать во время тестирования. В этом случае используется хорошо проверенная техника выделения «швов» приложения (т.е. некоторых абстракций), поведение которых будет подменено специальным образом для тестовых целей.Существует несколько видов таких «подделок», но двумя наиболее распространенными из них являются стабы и моки.Разница между этими двумя вариантами не слишком существенная с технической точки зрения, но она может быть достаточно важной, когда речь будет касаться сопровождаемости тестов и их простоты.Стабы – это заглушки, которые позволяют получить тестируемому классу необходимые данные. Так, например, если у нас есть интерфейс получения данных из базы данных, то тестовый объект, возвращающий тестовые данные является стабом.Мок предназначен для проверки поведения тестируемого класса. Например, с его помощью мы можем убедиться в том, что при выполнении WPF команды OK, вызовется метод Save репозитория.
  • #12 Stubs is a test-specific object that feeds the desired indirect inputs into the system under test.Анимацией показать пример кода, в котором показать, что ассерты находятся на CUT, а не фейкеГлавное отличие стаба от мока заключается в том, что стаб возвращает тестовые данные и никогда не участвует в ассертах. Вместо этого, при использовании стабов мы рассчитываем на некоторое состояние объекта, полученное благодаря стабу и проверяем именно его.
  • #13 Mock object replaces an object the system under test (SUT) depends on with a test-specific object that verifies it is being used correctly by the SUT.Анимацией показать пример кода, в котором показать, что ассерты находятся на моке, а не CUT-е!Рассказать о том, зачем знать об этих отличиях!Что моки сложнее и вообще, «я сейчас скажу ересь: я предпочту не использовать фейки, если это возможно!» Если у меня есть возможность не вносить зависимостей, то я не буду их вносить!Мок-объекты, напротив, используются в утверждениях и проверяются, что наш тестируемый класс ведет себя корректным образом.
  • #14 МНЕ СКАЗАЛИ, ЧТО КОТИК ДОЛЖЕН БЫТЬ У КАЖДОГО! ВЫ НЕ ЗНАЕТЕ, ВСЕ ВЫСТУПАЮЩИЕ СЛЕДУЮТ ЭТОМУ ПРАВИЛУ ИЛИ ЕСТЬ ИСКЛЮЧЕНИЯ?Однако не все так просто в этом деле. Несмотря на явную пользу от юнит-тестирования в контексте улучшения дизайна кода и многих других аспектах, у этого подхода есть и свои недостатки.Точнее, недостатки не связаны с юнит-тестированием как таковым, а скорее связаны с чрезмерным увлечением этим инструментом или его неверным использованием.Давайте рассмотрим два примера, приведенные в книги Роберта Мартина «Принципы, паттерны и методики гибкой разработки». Я в данном случае ни коем образом не хочу обидеть старину Боба или же уменьшить его вклад в развитие гибких методологий и разработки ПО в целом. Просто сейчас ситуация несколько изменилась. И если 10 лет назад стояла задача изменения сознания разработчиков в сторону объектной ориентации и модульного тестирования, то сейчас пришло время оценить то, насколько правильно мы этими инструментами пользуемся!
  • #15 Когда мы говорим или пишем что-либо о проблемах дизайна, то у нас возникает небольшая проблема в том, что нам достаточно сложно привести по настоящему реальные примеры, сложность которых кроется в деталях. Давайте рассмотрим пример, приведенный Робертом Мартином в своей книге.Предположим, мы занимаемся разработкой нового модуля (подчеркиваю, что речь идет именно о новом модуле, а не о легаси коде). Суть модуля заключается в расчете заработной плате и выписке чеков сотруднику. При этом данные о сотруднике располагаются в базе данных.Наивной реализация этого модуля заключается в том, что у нас есть класс Payroll с одним методом PayEmployees, который прочитает все из базы данных, вычислит все необходимое для каждого сотрудника, выпишет чек и сохранит обновленную информацию обратно в базу данных.Очевидно, что такой подход сложно назвать адекватным, ведь наш класс по расчету заработной платы явно делает слишком многое. Кроме того, мы никак не можем его протестировать, поскольку нам потребуется работающая база данных и модуль выписки чеков.Сейчас решение выглядит очевидным: давайте выделим интерфейсы для каждого существующего класса.Выдержка из книги Боба Мартина:Написание тестов до кода нередко выявляет части программы, которые нуждаются в разъединении. Например, на рис. 4.1 приведена простая UML-диаграмма приложения для расчета заработной платы. Класс Payroll использует класс EmployeeDatabase для получения объекта Employee, затем просит Employee вычислить свою зарплату, передает величину зарплаты объекту CheckWriter, чтобы тот выписал чек, и напоследок передает сумму платежа объекту Employee и записывает объект обратно в базу данных.Предположим, что весь этот код еще не написан. Пока это всего лишь диаграмма, нарисованная на доске в ходе эскизного проектирования. Теперь необходимо написать тесты, описывающие поведение объекта Payroll. Но при попытке сделать это возникает ряд проблем. Во-первых, какую СУБД мы будем использовать? Ведь приложение должно читать данные из некоторой базы. Необходимо ли иметь полнофункциональную базу данных перед тем, как тестировать класс Payroll? Какие данные в нее загрузить? Во-вторых, как проверить, что напечатан правильный чек? Не можем же мы написать автоматизированный тест, который будет смотреть на напечатанный принтером чек и сверять проставленную сумму!Решение этих проблем дает паттерн Объект-имитация (MockObject). Мы можем вставить между всеми классами, сотрудничающими с Payroll, интерфейсы, а затем создать тестовые заглушки, реализующие эти интерфейсы.
  • #16 Теперь мы получаем по одному интерфейсу для каждой зависимости класса Payroll, что позволяет протестировать каждый его аспект. Правильно? Да, правильно.Но кто считает, что на этом мы должны остановиться? Напомню, что мы находимся на этапе разработки модуля, а не в фазе сопровождения!С моей точки зрения, такой подход несколько наивен. Добавление швов в приложении имеет свою цену. Это требует не только дополнительных усилий, усложняет навигацию по коду, но и подрывает инкапсуляцию класса увеличивая при этом общую сложность модуля. Принимая это решение нам нужно четко осознавать плюсы и минусы этого решения и понять, а нужно ли абстрагироваться от всего?После таких изменений мы сможем протестировать каждый аспект класса Payroll:староеОднако с моей точки зрения, этот подход слишком наивен. Его можно рассматривать, как базовый вариант, но не более того.Выделение интерфейсов – это не только дополнительные усилия, но и подрыв инкапсуляции и увеличение сложности. При этом мы должны себе четко отдавать отчет в том, стоит это того или нет.Принимая решение о выделении интерфейса нам нужно четко понять, что это и правда необходимо. Класс сам по себе уже защищает нас от внутреннего устройства за счет сокрытия информации. Дополнительное выделение интерфейса с передачей его в качестве зависимости позволит нам не просто защититься от изменения зависимости, это позволяет нам заменить поведение во время исполнения.Так, например, нам нужно понять, какой из этих классов следует отнести к изменчивой зависимости, а какой – к постоянной.Более того, нужно подумать о том, а должен ли класс платежной ведомости вообще заботится о базе данных?Вполне возможно, более подходящим дизайном является использование напрямую CheckWriter-а и Employee (поскольку эти классы вполне могут быть стабильными), и полностью переложить ответственность за работу с базой данных (чтение и запись) на вышестоящий уровень.
  • #17 Да, мы добились тестируемости нашего кода, но стал ли наш дизайн лучше?Кто сталкивался с ситуацией, когда стоимость сопровождения тестового кода становилась соизмеримой со стоимостью сопровождения кода бизнес-логики? Или когда блок инициализации теста занимал половину экрана?Можно ли сказать, что это хороший дизайн в том плане, в котором мы описали ранее? Я не уверен.
  • #18 (*) – можно выделить базовые классы для тестов, можно использовать TestFixtureSetup, но это не решение проблемы!
  • #20 Поскольку данная задача описана не слишком подробно, то не всегда легко судить, какой подход будет более подходящим. В данном случае это скорее пример шагов, которые я предпринимаю для упрощения дизайна.Прежде всего нужно ответить на вопрос, где нам нужно расположить ключевую бизнес логику модуля и какие классы представляют собой «стратегии» в терминах GoF паттернов.Класс CheckWriter (выписка чеков) может быть завязан на внешнее окружение, поэтому мы не можем его использовать напрямую.Вы можете сказать, что с точки зрения количества зависимостей сам класс Payroll не изменился. И будете правы.Но разница здесь в том, что мы разрезали систему на две составляющие: бизнес-логику вычисления заработной платы (класс Payroller) и процесс работы с базой данных.Вопрос такой: что проще проверить класс с четырьмя состояниями или два класса, у каждого из которых по два состояния?Основной смысл принципа единой обязанности заключается в борьбе со сложностью. От перемены мест слагаемых в данном случае, сумма очень даже изменяется. Все дело в том, что сложность растет не линейно при увеличении переменных состояний, а экспоненциально. Поэтому выделение В данном случае это означает, что мы можем сосредоточиться на бизнес-логике приложения, проверив тщательно класс Payroller, а затем нам останется проверить класс Payroll, логика которого будет заключаться лишь в маршалинге данных между классами EmployeeDatabase и классом Payroller.
  • #21 TODO: тест для нового варианта!
  • #24 Если посмотреть на все танцы с бубнами по выделению и управлению зависимостями, то может показаться, что это борьба ради борьбы.На самом деле, это не так. Наличие зависимостей увеличивают сложность класса или компонента, ведь теперь для понимания его работы нам нужно перепрыгивать с одного места на другое, анализируя не только класс, но и его зависимости.Такое усложнение не является проблемой, если сложность класса/модуля не высока, но если сама природа задачи сложная, то дополнительное усложнение за счет связности может сделать решение несопровождаемым.Поэтому смысл управления зависимости сводится к переносу сложности из сложных частей системы в более простые, для того, чтобы каждая из этих частей смогла поместиться в нашей голове.
  • #25 Используя любой инструмент нужно четко помнить, что инструмент не является самоцелью. Мы хотим получить хороший дизайн приложения, а значит уменьшить стоимость внесения изменений.Если же любое изменение деталей реализации приводит к поломке десятка тестов, то это вряд ли путь в правильном направлении.Возвращаясь к предыдущему примеру: важно ли вам, как
  • #26 Упомянуть коммутативность. Что это отношение не коммутативно!
  • #27 Не через тестабилити к хорошему дизайну, а через хороший дизайн к тестабилити (на отдельный слайд)Не через хороший дизайн к тестабилити, а через