Статьи

Модульное тестирование лаконично: проверка правильности

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

Фраза «доказательство правильности» обычно используется в контексте достоверности вычислений, но в отношении модульного тестирования доказательство правильности фактически имеет три широкие категории, только вторая из которых относится к самим вычислениям:

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

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


Доказательство правильности включает в себя:

  • Проверка договора.
  • Проверка вычислительных результатов.
  • Проверка результатов преобразования данных.
  • Проверка внешних ошибок обрабатывается правильно.

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

Самая основная форма модульного тестирования заключается в проверке того, что разработчик написал метод, в котором четко указывается «контракт» между вызывающей стороной и вызываемым методом. Это обычно принимает форму проверки того, что неправильные входные данные для метода приводят к исключению. Например, метод «делить на» может вызвать ArgumentOutOfRangeException если знаменатель равен 0:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public static int Divide(int numerator, int denominator)
{
  if (denominator == 0)
  {
    throw new ArgumentOutOfRangeException(«Denominator cannot be 0.»);
  }
 
  return numerator / denominator;
}
 
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void BadParameterTest()
{
  Divide(5, 0);
}

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

Более сильный модульный тест включает проверку правильности вычислений. Полезно разделить ваши методы на одну из трех форм вычислений:

  • Сжатие данных
  • Преобразование данных
  • Изменение состояния

Они определяют виды модульных тестов, которые вы можете написать для конкретного метода.

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

1
2
3
4
5
[TestMethod]
public void VerifyDivisionTest()
{
  Assert.IsTrue(Divide(6, 2) == 3, «6/2 should equal 3!»);
}

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

Юнит-тесты преобразования данных, как правило, работают с наборами значений. Например, ниже приведен тест для метода, который преобразует декартовы координаты в полярные координаты.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
public static double[] ConvertToPolarCoordinates(double x, double y)
{
  double dist = Math.Sqrt(x * x + y * y);
  double angle = Math.Atan2(y, x);
 
  return new double[] { dist, angle };
}
 
[TestMethod]
public void ConvertToPolarCoordinatesTest()
{
  double[] pcoord = ConvertToPolarCoordinates(3, 4);
  Assert.IsTrue(pcoord[0] == 5, «Expected distance to equal 5»);
  Assert.IsTrue(pcoord[1] == 0.92729521800161219, «Expected angle to be 53.130
      degrees»);
}

Этот тест проверяет правильность математического преобразования.

Преобразования списка должны быть разделены на два теста:

  • Убедитесь, что основное преобразование правильное.
  • Убедитесь, что операция со списком выполнена правильно.

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public struct Name
{
  public string FirstName { get;
  public string LastName { get;
}
 
public List<string> ConcatNames(List<Name> names)
{
  List<string> concatenatedNames = new List<string>();
 
  foreach (Name name in names)
  {
    concatenatedNames.Add(name.LastName + «, » + name.FirstName);
  }
 
  return concatenatedNames;
}
 
[TestMethod]
public void NameConcatenationTest()
{
  List<Name> names = new List<Name>()
  {
    new Name() { FirstName=»John», LastName=»Travolta»},
    new Name() {FirstName=»Allen», LastName=»Nancy»}
  };
 
  List<string> newNames = ConcatNames(names);
 
  Assert.IsTrue(newNames[0] == «Travolta, John»);
  Assert.IsTrue(newNames[1] == «Nancy, Allen»);
}

Этот код лучше тестируется модулем, отделяя сокращение данных от преобразования данных:

01
02
03
04
05
06
07
08
09
10
11
12
public string Concat(Name name)
{
  return name.LastName + «, » + name.FirstName;
}
 
[TestMethod]
public void ContactNameTest()
{
  Name name = new Name() { FirstName=»John», LastName=»Travolta»};
  string concatenatedName = Concat(name);
  Assert.IsTrue(concatenatedName == «Travolta, John»);
}

Синтаксис Language-Integrated Query (LINQ) тесно связан с лямбда-выражениями, что приводит к легкому для чтения синтаксису, который усложняет жизнь для модульного тестирования. Например, этот код:

1
2
3
4
public List<string> ConcatNamesWithLinq(List<Name> names)
{
  return names.Select(t => t.LastName + «, » + t.FirstName).ToList();
}

является значительно более элегантным, чем предыдущие примеры, но он не подходит для модульного тестирования фактической «единицы», то есть сокращения данных от структуры имени до одной строки с разделителями-запятыми, выраженной в лямбда-функции t => t.LastName + ", " + t.FirstName . Для отделения устройства от списка требуется операция:

1
2
3
4
public List<string> ConcatNamesWithLinq(List<Name> names)
{
  return names.Select(t => Concat(t)).ToList();
}

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

Большинство языков «с состоянием», а классы часто управляют состоянием. Состояние класса, представленное его свойствами, часто полезно проверять. Рассмотрим этот класс, представляющий концепцию соединения:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class AlreadyConnectedToServiceException : ApplicationException
{
  public AlreadyConnectedToServiceException(string msg) : base(msg) { }
}
 
public class ServiceConnection
{
  public bool Connected { get;
 
  public void Connect()
  {
    if (Connected)
    {
      throw new AlreadyConnectedToServiceException(«Only one connection at a time is permitted.»);
    }
 
    // Connect to the service.
    Connected = true;
  }
 
  public void Disconnect()
  {
    // Disconnect from the service.
    Connected = false;
  }
}

Мы можем написать модульные тесты для проверки различных разрешенных и недопустимых состояний объекта:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
[TestClass]
public class ServiceConnectionFixture
{
  [TestMethod]
  public void TestInitialState()
  {
    ServiceConnection conn = new ServiceConnection();
    Assert.IsFalse(conn.Connected);
  }
 
  [TestMethod]
  public void TestConnectedState()
  {
    ServiceConnection conn = new ServiceConnection();
    conn.Connect();
    Assert.IsTrue(conn.Connected);
  }
 
  [TestMethod]
  public void TestDisconnectedState()
  {
    ServiceConnection conn = new ServiceConnection();
    conn.Connect();
    conn.Disconnect();
    Assert.IsFalse(conn.Connected);
  }
 
  [TestMethod]
  [ExpectedException(typeof(AlreadyConnectedToServiceException))]
  public void TestAlreadyConnectedException()
  {
    ServiceConnection conn = new ServiceConnection();
    conn.Connect();
    conn.Connect();
  }
}

Здесь каждый тест проверяет правильность состояния объекта:

  • Когда это инициализируется.
  • Когда поручено подключиться к услуге.
  • Когда поручено отключиться от сервиса.
  • При попытке более одного одновременного подключения.

Государственная проверка часто выявляет ошибки в государственном управлении. Также смотрите следующие «Mocking Classes» для дальнейших улучшений предыдущего примера кода.

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

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

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

Например, предыдущий код для класса «ServiceConnection» не является поддельным. Если вы хотите проверить его управление состоянием, вы должны физически создать соединение со службой (что бы это ни было), которое может или не может быть доступно при выполнении модульных тестов. Лучшая реализация может выглядеть так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class MockableServiceConnection
{
  public bool Connected { get;
 
  protected virtual void ConnectToService()
  {
    // Connect to the service.
  }
 
  protected virtual void DisconnectFromService()
  {
    // Disconnect from the service.
  }
 
  public void Connect()
  {
    if (Connected)
    {
      throw new AlreadyConnectedToServiceException(«Only one connection at a time is
      permitted.»);
    }
 
    ConnectToService();
    Connected = true;
  }
 
  public void Disconnect()
  {
    DisconnectFromService();
    Connected = false;
  }
}

Обратите внимание, что этот незначительный рефакторинг теперь позволяет вам писать фиктивный класс:

01
02
03
04
05
06
07
08
09
10
11
12
public class ServiceConnectionMock : MockableServiceConnection
{
  protected override void ConnectToService()
  {
    // Do nothing.
  }
 
  protected override void DisconnectFromService()
  {
    // Do nothing.
  }
}

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

Ваша первая линия защиты в доказательстве того, что проблема была исправлена, по иронии судьбы, доказывает, что проблема существует. Ранее мы видели пример написания теста, который доказал, что метод Divide проверяет значение знаменателя, равное 0 . Допустим, отчет об ошибке подан, потому что пользователь сбил программу при вводе 0 для значения знаменателя.

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

1
2
3
4
5
6
[TestMethod]
[ExpectedException(typeof(DivideByZeroException))]
public void BadParameterTest()
{
  Divide(5, 0);
}

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

Очевидно, мы хотим доказать, что ошибка была исправлена. Это «положительный» тест.

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

1
2
3
4
5
6
[TestMethod]
[ExpectedException(typeof(ArgumentOutOfRangeException))]
public void BadParameterTest()
{
  Divide(5, 0);
}

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

Хотя это тривиальный пример, он демонстрирует две концепции:

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

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

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

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

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

Давайте возьмем метод Раунда. Метод .NET Math.Round округляет число с дробным компонентом больше 0,5, но округляется, если дробный компонент равен 0,5 или менее. Скажем, это не то поведение, которое мы желаем (по любой причине), и мы хотим округлить, когда дробный компонент равен 0,5 или больше. Это вычислительное требование, которое должно быть получено из требования интеграции более высокого уровня, что приводит к следующему методу и тесту:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static int RoundUpHalf(double n)
{
  if (n < 0) throw new ArgumentOutOfRangeException(«Value must be >= 0.»);
 
  int ret = (int)n;
  double fraction = n — ret;
 
  if (fraction >= 0.5)
  {
    ++ret;
  }
 
  return ret;
}
 
[TestMethod]
public void RoundUpTest()
{
  int result1 = RoundUpHalf(1.5);
  int result2 = RoundUpHalf(1.499999);
  Assert.IsTrue(result1 == 2, «Expected 2.»);
  Assert.IsTrue(result2 == 1, «Expected 1.»);
}

Отдельный тест для исключения также должен быть написан.

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