Статьи

Анатомия хорошего юнит-тестирования

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

Я пришел из .NET / C # фона и собрал эту коллекцию мыслей и лакомых кусочков, которые я считаю полезными при написании тестов.

Зачем писать юнит-тесты?

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

Подходы к юнит-тестированию

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

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

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

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

Анатомия юнит-теста

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

  1. «Arrange» готовит все для проведения теста. Это может быть объявление переменных, создание требуемых объектов или установка состояния модуля в зависимости от обстоятельств, которые мы хотим проверить. Некоторые или все эти шаги могут выполняться в методе SetUp текущего прибора для модульного тестирования. Для простых модульных тестов этот шаг может не понадобиться.
  2. «Act» выполняет действие, которое мы тестируем на устройстве.
  3. «Подтвердить» проверяет, правильно ли выполнено действие. Мы хотим проверить, ожидается ли возвращаемое значение вызова метода или состояние объекта соответствует ожидаемому.

Примером является:

[Test]
public void GetMinimum_UnsortedIntegerArray_ReturnsSmallestValue() 
{   
  var unsortedArray = new int[] {7,4,9,2,5}; // Arrange     
  var minimum = Statistics.GetMinimum(unsortedArray); // Act     
  Assert.AreEqual(2, minimum); // Assert 
}

Руководство по структурированию юнит-теста

Вот несколько рекомендаций, которым вы можете следовать при написании модульных тестов:

  • Сократите количество утверждений на единицу теста до минимума. Один модульный тест проверяет одну вещь. Несколько утверждений в одном тесте — это хорошо, но если логично разделить утверждения на отдельные тесты, то лучше сделать это.
  • Избегайте проверочных тестов. Это тесты, которые не содержат никаких утверждений (или проверок в случае тестов реализации), и используются для проверки того, что что-то работает без исключения. Мне нравятся мои юнит-тесты, чтобы всегда проверять, что что-то работает.
  • Не повторяйте утверждения, которые были рассмотрены в существующих тестах. Если в модульном тесте утверждается, что результат не является нулевым, или что в коллекции содержится ровно один элемент, то последующим модульным тестам не нужно повторять такие утверждения перед утверждением дополнительного состояния.
  • Утверждения должны располагаться в самих модульных тестах, а не включаться во вспомогательные методы. Если проверка состояния немного сложна и распространена во многих тестах, хорошо написать вспомогательный метод для проверки состояния. Затем легко поместить утверждения в этот вспомогательный метод, хотя я считаю модульные тесты более читабельными, если они содержат утверждения, которые могут проверять логическое значение, возвращаемое вспомогательным методом.
  • Код для упорядочения, действия и утверждения должен быть в своих строках, в идеале с новой строкой между ними. Если вы утверждаете, что метод возвращает true, может возникнуть соблазн выполнить этот вызов метода прямо внутри оператора assert. Я считаю модульные тесты более понятными, когда они хранятся отдельно.

Test Doubles

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

Именно здесь вступают тестовые пары. Существуют различные тестовые пары, которые мы можем использовать для замены реальных объектов. Целью этого является исключительно выполнение требований использования тестируемого устройства. Ниже приведены тесты, которые я знаю и использовал.

Манекены

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

[Test] public void GetOccurrences_NewDateTimePattern_HasZeroOccurrences()
{   
  var pattern = new DateTimePattern();
  var dummy;

  var count = pattern.GetOccurrences(out dummy);

  Assert.AreEqual(0, count);
}

Столбики

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

public bool FileExists(string path)
{   
  return true; 
}

Подделки

Подделки — это полностью функциональные объекты, которые обычно реализуют интерфейс или, по крайней мере, расширяют абстрактный класс, в котором нуждается тестируемый модуль. Подделки — это быстрые и грязные реализации, которые не использовались бы, если бы не были двойным модульным тестом. Есть много хороших примеров подделок, недавно я использовал подделку для реализации интерфейса IRedisClient (хранилище структуры данных). Вместо того, чтобы фактически запускать Redis, данные хранятся в структурах данных C # очень упрощенным способом. Тестируемым модулям, для работы которых требуется IRedisClient, можно дать экземпляр этого фейка, а не полагаться на работу Redis:

public class FakeRedisClient : IRedisClient
{   
  private Dictionary<string, object> _redis = new Dictionary<string,object>();
  // and so on     
  public void AddItemToSet(string setId, string item)
  {
    object obj;
    _redis.TryGetValue(setId, out obj);
    HashSet set = (HashSet)obj;
    if (set == null)
    {
      set = new HashSet();
      _redis[setId] = set;
    }
    set.Add(item);
  }     
  // and so forth 
}

Mocks

Мок используются для проверки внутреннего поведения или реализации, а не состояния. Вы используете их для проверки, например, определенных функций, которые были вызваны или не были вызваны в результате вызова метода, который вы тестируете. Как правило, макетирование достигается с помощью фреймворков, таких как Moq для .NET. Вы издеваетесь над объектом из интерфейса, который дает вам конкретный объект для работы. Как часть настройки, вы можете прикрепить биты логики или жестко закодированные значения вместо методов или свойств. Это сделано для того, чтобы макет объекта действовал корректно при использовании тестируемым модулем. Имитируемый объект может быть функционально похож на подделку и может использоваться для проверки состояния. Я строго использую mocks только в тех немногих случаях, когда пишу тесты реализации.Подделки лучше подходят для тестирования состояния, поскольку они чисто инкапсулируют закрытые члены, такие как переменные и функции:

[SetUp]
public void SetUp()
{  
  _customer = new Mock();   
  _customer.Setup(c => c.PaymentID).Returns(1); 
}

[Test] 
public void CreateSubscription_NewCustomer_ExistingSubscriptionsAreChecked()
{   
  var service = CreatePaymentSubscriptionService(); 

  var subscription = service.CreateSubscription(_customer);

  _customer.Verify(c => c.GetSubscriptions());
}

Итак, где я стою лично?

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

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

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

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

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