Статьи

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

Это отрывок из электронной книги «Единичное тестирование » Марка Клифтона, любезно предоставленный Syncfusion.

В предыдущих статьях затрагивались различные проблемы и преимущества модульного тестирования. Эта статья представляет собой более формализованный взгляд на стоимость и преимущества модульного тестирования.

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

  • планирование
  • развитие
  • Тестирование (да, юнит-тесты должны быть проверены)

Кроме того, юнит-тесты также могут:

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

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

  • Подтверждает ли это договор?
  • Расчет работает правильно?
  • Правильно ли установлено внутреннее состояние объекта?
  • Возвращает ли он объект в «нормальное» состояние, если происходит исключение?
  • Все ли пути кода проверены?
  • Какие требования к настройке или демонтажу имеет метод?

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


Изменение производственного кода часто может сделать недействительными юнит-тесты. Изменения кода примерно делятся на две категории:

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

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

  • Рефакторинг параметров конкретного класса для интерфейсов или абстрактных классов.
  • Рефакторинг иерархии классов.
  • Замена сторонней технологии на другую.
  • Рефакторинг кода для выполнения асинхронных или вспомогательных задач.
  • Другие:
    • Пример: переход от конкретного класса базы данных, такого как SqlConnection, к IDbConnection, чтобы код поддерживал разные базы данных и требовал доработки модульных тестов, которые вызывают методы, зависящие от конкретных классов для их параметров.
    • Пример: изменение модели для использования стандартного формата сериализации, такого как XML, а не пользовательской методологии сериализации.
    • Пример. Переход с внутреннего ORM на сторонний ORM, такой как Entity Framework, может потребовать значительных изменений в настройке или демонтаже модульных тестов.

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


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

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


Есть несколько стратегий снижения затрат, которые следует рассмотреть.

Наиболее эффективный способ снижения стоимости модульного тестирования — избежать написания теста. Хотя это кажется очевидным, как это достигается? Ответ заключается в том, чтобы гарантировать, что данные, передаваемые в метод, являются правильными — другими словами, — правильный ввод, правильный вывод (обратное выражение «мусор входит, мусор выходит»). Да, вы, вероятно, все еще хотите протестировать само вычисление, но если вы можете гарантировать, что вызывающий объект выполнил контракт, нет особой необходимости тестировать метод, чтобы увидеть, обрабатывает ли он неправильные параметры (нарушения контракта).

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

Как вы обеспечиваете правильные данные? Для значений, которые поступают из пользовательского интерфейса, одним из подходов является соответствующая фильтрация и управление взаимодействием пользователя для предварительной фильтрации значений. Более сложный подход состоит в том, чтобы определять специализированные типы, а не полагаться на типы общего назначения. Рассмотрим метод деления, описанный ранее:

1
2
3
4
5
6
7
8
9
public static int Divide(int numerator, int denominator)
{
  if (denominator == 0)
  {
    throw new ArgumentOutOfRangeException(«Denominator cannot be 0.»);
  }
 
  return numerator / denominator;
}

Если знаменатель был специализированным типом, который гарантировал ненулевое значение:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public class NonZeroDouble
{
  protected int val;
 
  public int Value
  {
    get { return val;
    set
    {
      if (value == 0)
      {
        throw new ArgumentOutOfRangeException(«Value cannot be 0.»);
      }
 
      val = value;
    }
  }
}

метод Divide никогда не нужно будет проверять для этого случая:

1
2
3
4
5
6
7
/// <summary>
/// An example of using type specificity to avoid a contract test.
/// </summary>
public static int Divide(int numerator, NonZeroDouble denominator)
{
  return numerator / denominator.Value;
}

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

Задайте себе вопрос: должен ли мой метод отвечать за обработку исключений от третьих сторон, таких как веб-службы, базы данных, сетевые подключения и т. Д.? Можно утверждать, что ответ «нет». Конечно, это требует некоторой дополнительной предварительной работы — стороннему (или даже каркасному) API требуется оболочка, которая обрабатывает исключение, и архитектура, в которой внутреннее состояние приложение может быть откатано при возникновении исключения и, вероятно, должно быть реализовано. Тем не менее, в любом случае, это, вероятно, полезные улучшения приложения.

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


Как упоминалось ранее, есть определенные ценовые преимущества для модульного тестирования.

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

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

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

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

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

Существует несколько видов обзоров кода. Модульные тесты могут сократить количество времени, затрачиваемого на проверку кода на предмет архитектурных проблем, поскольку они, как правило, обеспечивают реализацию архитектуры. Кроме того, модульные тесты проверяют вычисления и могут также использоваться для проверки всех путей кода для данного метода. Это делает проверку кода почти ненужной — модульный тест становится самоанализом кода.

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

  • Проблемы с требованиями часто обнаруживаются.
  • Архитектурные требования выявлены.
  • Допущения и другие пробелы в требованиях определены.

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