Как стать автором
Обновить

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

Время на прочтение11 мин
Количество просмотров23K

Эта статья является конспектом книги «Принципы юнит-тестирования». Материал статьи посвящен структуре юнит-теста.

В этой статье рассмотрим структуру типичного юнит-теста, которая обычно описывается паттерном AAA (arrange, act, assert — подготовка, действие и проверка). Затронем именование юнит-тестов. Автор книги описал распространенные советы по именованию и показал, почему он несогласен с ними и привел альтернативы.

Структура юнит-теста

Согласно паттерну AAA (или 3А) каждый тест разбивается на три части: arrange (подготовка), act (действие) и assert (проверка). Возьмем для примера класс Calculator с методом для вычисления суммы двух чисел:

public class Calculator 
{ 
  public double Sum(double first, double second) 
  { 
    return first + second; 
  }
}
Рис. 1 – Листинг теста для проверки поведения класса по схеме ААА
Рис. 1 – Листинг теста для проверки поведения класса по схеме ААА

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

  • в секции подготовки тестируемая система (system under test, SUT) и ее зависимости приводятся в нужное состояние;

  • в секции действия вызываются методы SUT, передаются подготовленные зависимости и сохраняется выходное значение (если оно есть);

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

Как правило, написание теста начинается с секции подготовки (arrange), так как она предшествует двум другим. Но начинать писать тесты можно также и с секции проверки (assert). Если практиковать разработку через тестирование (TDD), то вы еще не знаете всего о поведении этой функциональности. Возможно, будет полезно сначала описать, чего вы ожидаете от поведения, а уже затем разбираться, как создать систему для удовлетворения этих ожиданий. В этом случае сначала мы думаем о цели: что разрабатываемая функциональность должна делать для нас. А затем начинаем решать задачу. Но следует еще раз подчеркнуть, что эта рекомендация применима только в том случае, когда практикуется TDD. Если вы пишете основной код до кода тестов, то к тому моменту, когда вы доберетесь до теста, вы уже знаете, чего ожидать от поведения, и лучше будет начать с секции подготовки.

Есть еще паттерн «Given-When-Then», похожем на AAA. Этот паттерн также рекомендует разбить тест на три части:

Given — соответствует секции подготовки (arrange);

When — соответствует секции действия (act);

Then — соответствует секции проверки (assert)

В отношении построения теста эти два паттерна ничем не отличаются. Единственное отличие заключается в том, что структура «Given-When-Then» более понятна для непрограммиста. Таким образом, она лучше подойдет для тестов, которые вы собираетесь показывать людям, не имеющим технической подготовки.

Избегайте множественных секций arrange, act и assert

Иногда встречаются тесты с несколькими секциями arrange (подготовка), act (действие) или assert (проверка).

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

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

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

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

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

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

Избегайте команд if в тестах

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

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

Насколько большой должна быть каждая секция?

Насколько большой должна быть каждая секция? И как насчет завершающей (teardown) секции — той, что должна «прибирать» после каждого теста?

Секция подготовки обычно является самой большой из трех. Если она становится слишком большой, лучше выделить отдельные операции подготовки либо в приватные методы того же класса теста, либо в отдельный класс-фабрику. Есть несколько популярных паттернов, которые помогут организовать переиспользование кода в секциях подготовки: «Мать объектов» (Object Mother) и «Построитель тестовых данных» (Test Data Builder).

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

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
  // Arrange
  var store = new Store();
  store.AddInventory(Product.Shampoo, 10);
  var customer = new Customer();
  
  // Act
  bool success = customer.Purchase(store, Product.Shampoo, 5);
  
  // Assert
  Assert.True(success);
  Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

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

[Fact]
public void Purchase_succeeds_when_enough_inventory()
{
  // Arrange
  var store = new Store();
  store.AddInventory(Product.Shampoo, 10);
  var customer = new Customer();
  
  // Act
  bool success = customer.Purchase(store, Product.Shampoo, 5);
  store.RemoveInventory(success, Product.Shampoo, 5);
  
  // Assert
  Assert.True(success);
  Assert.Equal(5, store.GetInventory(Product.Shampoo));
}

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

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

Такое нарушение логической целостности называется нарушением инварианта (invariant violation). Защита кода от потенциальных нарушений инвариантов называется инкапсуляцией (encapsulation). Когда нарушение логической целостности проникает в базу данных, оно становится серьезной проблемой; теперь не удастся сбросить состояние приложения простым перезапуском. Придется разбираться с поврежденными данными в базе и, возможно, связываться с клиентами и решать проблему с каждым из них по отдельности.

Проблема решается поддержанием инкапсуляции кода. В предыдущих примерах удаление запрашиваемого товара со склада должно быть частью метода Purchase. Сustomer не должен полагаться на то, что клиентский код сделает это сам, вызвав метод store.RemoveInventory. Когда речь заходит о поддержании инвариантов в системе, вы должны устранить любую потенциальную возможность нарушить эти инварианты.

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

Сколько проверок должна содержать секция проверки?

Так как под «юнитом» в юнит-тестировании понимается единица поведения, а не единица кода, то одна единица поведения может приводить к нескольким результатам. Проверять все эти результаты в одном тесте вполне нормально.

Тем не менее, если секция проверки получается слишком большой: это может быть признаком того, что в коде недостает какой-то абстракции. Например, вместо того чтобы по отдельности проверять все свойства объекта, возвращенного тестируемой системой, возможно, будет лучше добавить методы проверки равенства (equality members) в класс такого объекта. После этого объект можно будет сравнивать с ожидаемым значением всего одной командой.

Нужна ли завершающая (teardown) фаза?

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

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

Переиспользование тестовых данных между тестами

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

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

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

Он создает сильную связность (high coupling) между тестами. Изменение логики подготовки одного теста повлияет на все тесты в классе. Тем самым нарушается важное правило: изменение одного теста не должно влиять на другие тесты. Чтобы следовать этому правилу, необходимо избегать совместного состояния (shared state) в классах тестов.

Другой недостаток выделения кода подготовки в конструктор — ухудшение читаемости теста. С таким конструктором просмотр самого теста больше не дает полной картины. Чтобы понять, что делает тест, приходится смотреть в два места: сам тест и конструктор тест-класса.

Второй (правильный) способ — написать фабричные методы, как показано ниже.

public class CustomerTests
{
  [Fact]
  public void Purchase_succeeds_when_enough_inventory()
  {
    Store store = CreateStoreWithInventory(Product.Shampoo, 10);
    Customer sut = CreateCustomer();
    
    bool success = sut.Purchase(store, Product.Shampoo, 5);
    
    Assert.True(success);
    Assert.Equal(5, store.GetInventory(Product.Shampoo));
  }
  
  [Fact]
  public void Purchase_fails_when_not_enough_inventory()
  {
    Store store = CreateStoreWithInventory(Product.Shampoo, 10);
    Customer sut = CreateCustomer();
    
    bool success = sut.Purchase(store, Product.Shampoo, 15);
    
    Assert.False(success);
    Assert.Equal(10, store.GetInventory(Product.Shampoo));
  }
  
  private Store CreateStoreWithInventory(Product product, int quantity)
  {
    Store store = new Store();
    store.AddInventory(product, quantity);
    return store;
  }
  
  private static Customer CreateCustomer()
  {
  	return new Customer();
  }
}

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

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

Именование юнит-тестов

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

[ТестируемыйМетод]_[Сценарий]_[ОжидаемыйРезультат]

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

public class CalculatorTests
{
  [Fact]
  public void Sum_of_two_numbers()
  {
    double first = 10;
    double second = 20;
    var sut = new Calculator();
    
    double result = sut.Sum(first, second);
    
    Assert.Equal(30, result);
  }
}

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

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

Рекомендации по именованию юнит-тестов

Не следуйте жесткой структуре именования тестов. Высокоуровневое описание сложного поведения не удастся втиснуть в узкие рамки такой структуры. Сохраняйте свободу самовыражения.

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

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

Также обратите внимание на то, что, хотя автор использует паттерн [ИмяКласса]Tests при выборе имен классов тестов, это не означает, что тесты ограничиваются проверкой только этого класса. Юнитом в юнит-тестировании является единица поведения, а не класс. Единица поведения может охватывать один или несколько классов. Рассматривайте класс в [ИмяКласса]Tests как точку входа — API, при помощи которого можно проверить единицу поведения.

Вывод

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

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

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

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

Ссылки на все части

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии2

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань