Модульные тесты, как писать тестируемый код и почему это важно

Опубликовано: 2022-03-11

Модульное тестирование является важным инструментом в наборе инструментов любого серьезного разработчика программного обеспечения. Однако иногда может быть довольно сложно написать хороший модульный тест для определенного фрагмента кода. Испытывая трудности с тестированием собственного или чужого кода, разработчики часто думают, что их трудности вызваны отсутствием каких-то фундаментальных знаний по тестированию или секретных методов модульного тестирования.

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

Учебное пособие по модульному тестированию: иллюстрация на обложке

Что такое модульное тестирование?

По сути, модульный тест — это метод, который создает экземпляр небольшой части нашего приложения и проверяет его поведение независимо от других частей . Типичный модульный тест состоит из 3 фаз: во-первых, он инициализирует небольшую часть приложения, которое необходимо протестировать (также известного как тестируемая система или SUT), затем он применяет некоторые стимулы к тестируемой системе (обычно путем вызова метод на нем), и, наконец, он наблюдает за результирующим поведением. Если наблюдаемое поведение соответствует ожиданиям, модульный тест проходит успешно, в противном случае он терпит неудачу, что указывает на наличие проблемы где-то в тестируемой системе. Эти три этапа модульного тестирования также известны как Arrange, Act и Assert или просто AAA.

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

В качестве метафоры правильного модульного тестирования программного обеспечения представьте себе сумасшедшего ученого, который хочет построить некую сверхъестественную химеру с лягушачьими лапками, щупальцами осьминога, птичьими крыльями и собачьей головой. (Эта метафора довольно близка к тому, чем на самом деле занимаются программисты на работе). Как этому ученому убедиться, что каждая выбранная им часть (или единица) действительно работает? Ну, он может взять, скажем, одну лягушачью лапку, применить к ней электрический стимул и проверить правильное сокращение мышц. То, что он делает, — это, по сути, те же шаги Arrange-Act-Assert модульного теста; единственное отличие состоит в том, что в данном случае единица измерения относится к физическому объекту, а не к абстрактному объекту, из которого мы строим наши программы.

что такое модульное тестирование: иллюстрация

Я буду использовать C# для всех примеров в этой статье, но описанные концепции применимы ко всем объектно-ориентированным языкам программирования.

Пример простого модульного теста может выглядеть так:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

Модульный тест против интеграционного теста

Еще одна важная вещь, которую следует учитывать, — это разница между модульным тестированием и интеграционным тестированием.

Целью модульного теста в разработке программного обеспечения является проверка поведения относительно небольшой части программного обеспечения независимо от других частей. Модульные тесты имеют узкую область применения и позволяют нам охватить все случаи, гарантируя правильную работу каждой отдельной части.

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

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

иллюстрация примера модульного тестирования

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

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

Однако мы должны всегда помнить, какой тест мы реализуем: модульный или интеграционный тест. Разница иногда может быть обманчивой. Если мы думаем, что пишем модульный тест для проверки какого-то тонкого пограничного случая в классе бизнес-логики, и понимаем, что для этого требуются внешние ресурсы, такие как веб-сервисы или базы данных, что-то не так — по сути, мы используем кувалду, чтобы расколоть орех. А это значит плохой дизайн.

Что делает хороший модульный тест?

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

  • Легко писать. Разработчики обычно пишут множество модульных тестов, чтобы охватить различные случаи и аспекты поведения приложения, поэтому кодировать все эти тестовые подпрограммы должно быть легко без особых усилий.

  • Удобочитаемый. Цель модульного теста должна быть ясной. Хороший модульный тест рассказывает историю о некотором поведенческом аспекте нашего приложения, поэтому должно быть легко понять, какой сценарий тестируется, и — если тест не пройден — легко определить, как решить проблему. С хорошим модульным тестом мы можем исправить ошибку без фактической отладки кода!

  • Надежный. Модульные тесты должны давать сбой только в том случае, если в тестируемой системе есть ошибка. Это кажется довольно очевидным, но программисты часто сталкиваются с проблемой, когда их тесты терпят неудачу, даже если ошибок не было. Например, тесты могут быть пройдены при выполнении один за другим, но не пройти при запуске всего набора тестов или пройти на нашей машине разработки и не пройти на сервере непрерывной интеграции. Эти ситуации свидетельствуют о дефекте конструкции. Хорошие модульные тесты должны быть воспроизводимыми и независимыми от внешних факторов, таких как среда или порядок выполнения.

  • Быстро. Разработчики пишут модульные тесты, чтобы многократно запускать их и проверять отсутствие ошибок. Если модульные тесты работают медленно, разработчики, скорее всего, не будут запускать их на своих собственных машинах. Один медленный тест не будет иметь существенного значения; добавь еще тысячу, и мы наверняка застрянем в ожидании на какое-то время. Медленные модульные тесты также могут указывать на то, что либо тестируемая система, либо сам тест взаимодействуют с внешними системами, что делает его зависимым от среды.

  • Действительно единица, а не интеграция. Как мы уже говорили, модульные и интеграционные тесты имеют разные цели. И юнит-тест, и тестируемая система не должны обращаться к сетевым ресурсам, базам данных, файловой системе и т. д., чтобы исключить влияние внешних факторов.

Вот и все — секретов написания юнит-тестов нет. Однако есть некоторые приемы, которые позволяют нам писать тестируемый код .

Тестируемый и нетестируемый код

Некоторый код написан таким образом, что для него сложно или даже невозможно написать хороший модульный тест. Итак, что затрудняет тестирование кода? Давайте рассмотрим некоторые анти-шаблоны, запахи кода и плохие практики, которых следует избегать при написании тестируемого кода.

Отравление кодовой базы недетерминированными факторами

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

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

По сути, этот метод считывает текущее системное время и возвращает результат на основе этого значения. Итак, что не так с этим кодом?

Если мы подумаем об этом с точки зрения модульного тестирования, мы увидим, что невозможно написать правильный модульный тест на основе состояния для этого метода. DateTime.Now — это, по сути, скрытый ввод, который, вероятно, будет меняться во время выполнения программы или между прогонами тестов. Таким образом, последующие обращения к нему будут давать разные результаты.

Такое недетерминированное поведение делает невозможным тестирование внутренней логики метода GetTimeOfDay() без фактического изменения системной даты и времени. Давайте посмотрим, как такой тест должен быть реализован:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

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

Оказывается, все эти проблемы с тестируемостью вызваны некачественным API GetTimeOfDay() . В своем нынешнем виде этот метод страдает от нескольких проблем:

  • Он тесно связан с конкретным источником данных. Невозможно повторно использовать этот метод для обработки даты и времени, полученных из других источников или переданных в качестве аргумента; метод работает только с датой и временем конкретной машины, на которой выполняется код. Тесная связь — основная причина большинства проблем с тестируемостью.

  • Это нарушает принцип единой ответственности (SRP). Метод имеет несколько обязанностей; он потребляет информацию, а также обрабатывает ее. Еще одним признаком нарушения SRP является то, что один класс или метод имеет более одной причины для изменения . С этой точки зрения метод GetTimeOfDay() может быть изменен либо из-за внутренних логических корректировок, либо из-за того, что источник даты и времени должен быть изменен.

  • Он лжет об информации, необходимой для выполнения своей работы. Разработчики должны прочитать каждую строку фактического исходного кода, чтобы понять, какие скрытые входные данные используются и откуда они берутся. Одной сигнатуры метода недостаточно, чтобы понять поведение метода.

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

Изучив API, давайте, наконец, исправим его! К счастью, это намного проще, чем обсуждать все его недостатки — нам просто нужно разбить тесно связанные проблемы.

Исправление API: введение аргумента метода

Самый очевидный и простой способ исправить API — ввести аргумент метода:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

Теперь метод требует, чтобы вызывающая сторона предоставила аргумент DateTime вместо того, чтобы тайно искать эту информацию самостоятельно. С точки зрения модульного тестирования это здорово; метод теперь детерминирован (то есть его возвращаемое значение полностью зависит от ввода), поэтому тестирование на основе состояния так же просто, как передача некоторого значения DateTime и проверка результата:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

Обратите внимание, что этот простой рефакторинг также решил все проблемы API, обсуждавшиеся ранее (сильная связь, нарушение SRP, неясный и трудный для понимания API), введя четкую связь между тем, какие данные должны обрабатываться, и тем, как это должно быть сделано.

Отлично — метод можно протестировать, но как насчет его клиентов ? Теперь ответственность за предоставление даты и времени GetTimeOfDay(DateTime dateTime) лежит на вызывающем объекте, а это означает, что они могут стать непроверяемыми, если мы не уделим достаточного внимания. Давайте посмотрим, как мы можем справиться с этим.

Исправление клиентского API: внедрение зависимостей

Допустим, мы продолжаем работать над системой умного дома и реализуем следующий клиент метода GetTimeOfDay(DateTime dateTime) — вышеупомянутый код микроконтроллера умного дома, отвечающий за включение или выключение света в зависимости от времени суток и обнаружения движения. :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

Ой! У нас такая же проблема со скрытым вводом DateTime.Now — с той лишь разницей, что она расположена чуть выше уровня абстракции. Чтобы решить эту проблему, мы можем ввести еще один аргумент, снова делегируя ответственность за предоставление значения DateTime вызывающей стороне нового метода с сигнатурой ActuateLights(bool motionDetected, DateTime dateTime) . Но вместо того, чтобы еще раз переместить проблему на уровень выше в стеке вызовов, давайте воспользуемся другим методом, который позволит нам поддерживать тестируемость как ActuateLights(bool motionDetected) , так и его клиентов: инверсия управления или IoC.

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

Инверсия управления может быть реализована несколькими способами; давайте рассмотрим один конкретный пример — внедрение зависимостей с использованием конструктора — и то, как это может помочь в создании тестируемого API SmartHomeController .

Сначала создадим интерфейс IDateTimeProvider , содержащий сигнатуру метода для получения даты и времени:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

Затем сделайте SmartHomeController ссылкой на реализацию IDateTimeProvider и делегируйте ему ответственность за получение даты и времени:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

Теперь мы можем понять, почему Inversion of Control так называется: контроль над тем, какой механизм использовать для чтения даты и времени, был инвертирован и теперь принадлежит клиенту SmartHomeController , а не самому SmartHomeController . Таким образом, выполнение метода ActuateLights(bool motionDetected) полностью зависит от двух вещей, которыми можно легко управлять извне: аргумента motionDetected и конкретной реализации IDateTimeProvider , переданной в конструктор SmartHomeController .

Почему это важно для модульного тестирования? Это означает, что разные реализации IDateTimeProvider могут использоваться в производственном коде и коде модульного тестирования. В производственной среде будет внедрена некоторая реальная реализация (например, та, которая считывает фактическое системное время). Однако в модульном тесте мы можем внедрить «фальшивую» реализацию, которая возвращает постоянное или предопределенное значение DateTime , подходящее для тестирования конкретного сценария.

Поддельная реализация IDateTimeProvider может выглядеть так:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

С помощью этого класса можно изолировать SmartHomeController от недетерминированных факторов и выполнить модульный тест на основе состояния. Давайте проверим, что, если движение было обнаружено, время этого движения записывается в свойстве LastMotionTime :

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

Здорово! Такой тест был невозможен до рефакторинга. Теперь, когда мы устранили недетерминированные факторы и проверили сценарий на основе состояний, считаете ли вы, что SmartHomeController полностью пригоден для тестирования?

Отравление кодовой базы побочными эффектами

Несмотря на то, что мы решили проблемы, вызванные недетерминированным скрытым вводом, и смогли протестировать определенный функционал, код (или, по крайней мере, его часть) все еще не тестируется!

Давайте рассмотрим следующую часть метода ActuateLights(bool motionDetected) , отвечающего за включение или выключение света:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

Как мы видим, SmartHomeController делегирует ответственность за включение и выключение света объекту BackyardLightSwitcher , который реализует шаблон Singleton. Что не так с этим дизайном?

Чтобы полностью протестировать метод ActuateLights(bool motionDetected) , мы должны выполнить тестирование на основе взаимодействия в дополнение к тестированию на основе состояния; то есть мы должны убедиться, что методы для включения или выключения света вызываются тогда и только тогда, когда выполняются соответствующие условия. К сожалению, текущий дизайн не позволяет нам это сделать: методы TurnOn() и TurnOff() BackyardLightSwitcher вызывают некоторые изменения состояния в системе, или, другими словами, производят побочные эффекты . Единственный способ проверить, были ли вызваны эти методы, — это проверить, действительно ли возникли соответствующие побочные эффекты, что может быть болезненным.

Действительно, давайте предположим, что датчик движения, фонарь заднего двора и микроконтроллер умного дома подключены к сети Интернета вещей и обмениваются данными по некоторому беспроводному протоколу. В этом случае модульный тест может попытаться получить и проанализировать этот сетевой трафик. Или, если аппаратные компоненты соединены проводом, модульный тест может проверить, подавалось ли напряжение на соответствующую электрическую цепь. Или все-таки может проверить, действительно ли свет включился или выключился, с помощью дополнительного датчика освещенности.

Как мы видим, методы модульного тестирования с побочными эффектами могут быть такими же сложными, как и недетерминированные, и даже могут быть невозможны. Любая попытка приведет к проблемам, подобным тем, которые мы уже видели. Полученный тест будет сложным для реализации, ненадежным, потенциально медленным и не совсем модульным. И после всего этого мигание лампочки каждый раз, когда мы запускаем набор тестов, в конечном итоге сведет нас с ума!

Опять же, все эти проблемы с тестируемостью вызваны плохим API, а не способностью разработчика писать модульные тесты. Независимо от того, как именно реализовано управление освещением, API SmartHomeController страдает от этих уже знакомых проблем:

  • Он тесно связан с конкретной реализацией. API основан на жестко закодированном конкретном экземпляре BackyardLightSwitcher . Невозможно повторно использовать метод ActuateLights(bool motionDetected) для переключения любого источника света, кроме того, который находится на заднем дворе.

  • Это нарушает принцип единственной ответственности. У API есть две причины для изменения: во-первых, изменение внутренней логики (например, выбор включения света только ночью, а не вечером) и во-вторых, если механизм переключения света заменен на другой.

  • Он лжет о своих зависимостях. Разработчики не могут узнать, что SmartHomeController зависит от жестко закодированного компонента BackyardLightSwitcher , кроме как копаться в исходном коде.

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

Неудивительно, что решение как проблем тестируемости, так и проблем с некачественным API заключается в том, чтобы отделить сильно связанные компоненты друг от друга. Как и в предыдущем примере, использование внедрения зависимостей решит эти проблемы; просто добавьте зависимость ILightSwitcher к SmartHomeController , делегируйте ему ответственность за переключение выключателя света и передайте поддельную реализацию ILightSwitcher только для тестирования, которая будет записывать, вызывались ли соответствующие методы при правильных условиях. Однако вместо того, чтобы снова использовать внедрение зависимостей, давайте рассмотрим интересный альтернативный подход к разделению обязанностей.

Исправление API: функции высшего порядка

Такой подход возможен в любом объектно-ориентированном языке, поддерживающем первоклассные функции . Давайте воспользуемся функциональными возможностями C# и заставим метод ActuateLights(bool motionDetected) принимать еще два аргумента: пару делегатов Action , указывающих на методы, которые следует вызывать для включения и выключения света. Это решение преобразует метод в функцию более высокого порядка :

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

Это более функциональное решение, чем классический объектно-ориентированный подход внедрения зависимостей, который мы видели раньше; однако он позволяет нам достичь того же результата с меньшим количеством кода и большей выразительностью, чем внедрение зависимостей. Больше нет необходимости реализовывать класс, соответствующий интерфейсу, чтобы обеспечить SmartHomeController необходимой функциональностью; вместо этого мы можем просто передать определение функции. Функции высшего порядка можно рассматривать как еще один способ реализации инверсии управления.

Теперь, чтобы выполнить модульное тестирование полученного метода на основе взаимодействия, мы можем передать ему легко проверяемые поддельные действия:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

Наконец, мы сделали API SmartHomeController полностью пригодным для тестирования, и мы можем выполнять для него модульные тесты как на основе состояния, так и на основе взаимодействия. Опять же, обратите внимание, что в дополнение к улучшенной тестируемости введение стыка между кодом принятия решений и кодом действий помогло решить проблему жесткой связи и привело к более чистому, повторно используемому API.

Теперь, чтобы достичь полного охвата модульными тестами, мы можем просто реализовать кучу похожих тестов для проверки всех возможных случаев — это не имеет большого значения, поскольку модульные тесты теперь довольно легко реализовать.

Примеси и тестируемость

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

С другой стороны, методы, которые одновременно являются детерминированными и не имеют побочных эффектов, намного легче тестировать, анализировать и повторно использовать для создания более крупных программ. В терминах функционального программирования такие методы называются чистыми функциями . У нас редко будут проблемы с модульным тестированием чистой функции; все, что нам нужно сделать, это передать некоторые аргументы и проверить результат на правильность. Что действительно делает код непроверяемым, так это жестко запрограммированные, нечистые факторы, которые нельзя заменить, переопределить или абстрагировать каким-либо другим способом.

Примесь токсична: если метод Foo() зависит от недетерминированного или вызывающего побочные эффекты метода Bar() , тогда Foo() также становится недетерминированным или вызывающим побочные эффекты. В конце концов, мы можем отравить всю кодовую базу. Умножьте все эти проблемы на размер сложного реального приложения, и мы обнаружим, что обременены трудно поддерживаемой кодовой базой, полной запахов, антишаблонов, секретных зависимостей и всяких уродливых и неприятных вещей.

Пример модульного тестирования: иллюстрация

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

Распространенные предупреждающие признаки трудно тестируемого кода

Проблемы с написанием тестов? Проблема не в вашем наборе тестов. Это в вашем коде.
Твитнуть

Наконец, давайте рассмотрим некоторые распространенные предупреждающие знаки, указывающие на то, что наш код может быть сложно протестировать.

Статические свойства и поля

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

Например, трудно рассуждать о следующем коде, который зависит от глобально доступного свойства:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

Что, если метод HeatWater() не вызывается, хотя мы уверены, что это должно было быть вызвано? Поскольку любая часть приложения могла изменить значение CostSavingEnabled , мы должны найти и проанализировать все места, изменяющие это значение, чтобы выяснить, что не так. Кроме того, как мы уже видели, невозможно установить некоторые статические свойства для целей тестирования (например, DateTime.Now или Environment.MachineName ; они доступны только для чтения, но все еще недетерминированы).

С другой стороны, неизменяемое и детерминированное глобальное состояние совершенно нормально. На самом деле для этого есть более знакомое название — константа. Постоянные значения, такие как Math.PI , не вносят никакой недетерминированности и, поскольку их значения не могут быть изменены, не допускают никаких побочных эффектов:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

Одиночки

По сути, шаблон Singleton — это просто еще одна форма глобального состояния. Синглтоны продвигают малоизвестные API, которые лгут о реальных зависимостях и вводят излишне тесную связь между компонентами. Они также нарушают принцип единой ответственности, потому что, помимо своих основных обязанностей, они контролируют собственную инициализацию и жизненный цикл.

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

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Static Methods

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. Например:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.